├── .gitignore ├── .npmignore ├── Makefile ├── README.md ├── package-lock.json ├── package.json ├── server.js ├── source ├── css.js ├── css │ ├── cascade.js │ ├── compare-specificity.js │ ├── compute.js │ ├── declarations.js │ ├── declarations.json │ ├── expand-shorthand.js │ ├── stylesheet.js │ └── values.js ├── draw.js ├── draw │ ├── background.js │ ├── border.js │ ├── image.js │ └── text.js ├── html.js ├── images.js ├── index.js ├── layout.js ├── layout │ ├── block-box.js │ ├── box.js │ ├── image-box.js │ ├── inline-box.js │ ├── line-box.js │ ├── line-break-box.js │ ├── parent-box.js │ ├── text-box.js │ ├── viewport.js │ └── whitespace │ │ ├── breaks.js │ │ └── collapse.js └── stylesheets.js └── test ├── assets ├── block-image.html ├── block-in-inline.html ├── br.html ├── column-inline.html ├── css │ ├── default.css │ └── reset.css ├── empty-inline.html ├── font-size.html ├── image.html ├── images │ └── waves.png ├── inline-image.html ├── line-height.html ├── mixed-white-space.html ├── multiline-font-size.html ├── multiline-image.html ├── multiline.html ├── nested-block-in-inline.html ├── nested-block.html ├── nested-inline.html ├── padded-all.html ├── padded-block-in-inline.html ├── padded-br.html ├── padded-inline.html ├── pre.html ├── shorthand.html ├── simple-block.html ├── simple-inline.html ├── stack-block.html ├── vertical-align-image.html ├── vertical-align.html └── white-space.html ├── index.html ├── index.js └── serialize.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | dist 2 | test 3 | Makefile 4 | server.js 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build clean test-build test-watch test-copy test-clean 2 | 3 | build: dist 4 | browserify source/index.js -o dist/index.js 5 | 6 | clean: 7 | rm -rf dist 8 | 9 | test-build: test-clean test-copy 10 | browserify test/index.js -t brfs -o dist/test/index.js 11 | 12 | test-watch: test-clean test-copy 13 | onchange test/index.html -- /bin/sh -c 'make test-copy' &\ 14 | watchify test/index.js -v -d -t brfs -o dist/test/index.js &\ 15 | node server.js 16 | 17 | test-copy: dist/test/index.html dist/test/css dist/test/images 18 | 19 | test-clean: 20 | rm -rf dist/test 21 | 22 | dist: 23 | mkdir -p dist 24 | 25 | dist/test: 26 | mkdir -p dist/test 27 | 28 | dist/test/index.html: dist/test test/index.html 29 | cp test/index.html dist/test/index.html 30 | 31 | dist/test/css: dist/test test/assets/css/* 32 | rm -rf dist/test/css 33 | cp -r test/assets/css dist/test/css 34 | 35 | dist/test/images: dist/test test/assets/images/* 36 | rm -rf dist/test/images 37 | cp -r test/assets/images dist/test/images 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # repaint 2 | 3 | A HTML layout engine written in Javascript. Takes HTML as input, renders it and outputs an image. 4 | 5 | Parses HTML, CSS, combines them and then calculates position and dimensions of all elements. It's essentially the same as your browser's rendering engine. 6 | 7 | [https://kapetan.github.io/repaint-chrome/examples/wiki.html][wiki] 8 | 9 | # Why? 10 | 11 | Three years ago [@devongovett][govett] claimed that he had ported [WebKit to Javascript][wkjs]. Obviously it was a hoax as it makes no sense to run a browser in a browser. 12 | 13 | But then in August I happened to run into a [post][toy] by [Matt Brubeck][brubeck] who wanted to write a browser engine in Rust, and it got me thinking. So I started working on this project around that time, and I now think it's ready to be judged by others. 14 | 15 | There are still a lot of functionalities missing, but it is able to render some basic HTML. Right now it only works in the browser, but might be made to run in a node environment with some modifications. 16 | 17 | # Examples 18 | 19 | The [repaint-chrome][rc] repository provides a simple interface for editing and rendering markdown and HTML using `repaint`. The markdown is rendered by first converting it to plain HTML. 20 | 21 | - [kapetan/text-width][tw] markdown formatted readme 22 | - [mafintosh/peerflix][pf] markdown formatted readme with images 23 | - [Marcus Tullius Tiro][wiki] wikipedia mobile page. 24 | - [Repaint test page][test]. Simple test page presenting the HTML rendered by `repaint` on the left and an iframe with the same HTML on the right. 25 | 26 | # Usage 27 | 28 | Available on `npm`. 29 | 30 | npm install repaint 31 | 32 | The module exposes a rendering function which accepts an options object and a callback. The `content`, `context` and `viewport.dimensions.width` options are required, and should respectively provide the HTML content as a string, the canvas 2d context used for drawing and the initial viewport width. 33 | 34 | Additionally the `url` is used to resolve any resources linked in the HTML (images and stylesheet links), also `viewport.position` specifies the initial viewport offset, e.g. a position of `{ x: 0, y: -10 }` would correspond to scrolling the page down 10 pixels. 35 | 36 | Require the module with `browserify` or a similar tool. 37 | 38 | ```javascript 39 | var repaint = require('repaint'); 40 | 41 | repaint({ 42 | url: '' + window.location, 43 | stylesheets: ['body { color: red; }'], 44 | content: 'Hello', 45 | context: canvas.getContext('2d'), 46 | viewport: { 47 | position: { x: 0, y: 0 }, 48 | dimensions: { width: 512, height: 1024 } 49 | } 50 | }, function(err, page) { 51 | if(err) throw err; 52 | console.log(page); 53 | }); 54 | ``` 55 | 56 | The resulting `page` object contains some rendering details, like the parsed HTML (`page.document`) and the layout tree (`page.layout`). 57 | 58 | The provided content is first parsed using the [htmlparser2][htmlparser2] module, and all stylesheets, including style attributes, are parsed with [css][css] and matched to HTML nodes with [css-select][css-select]. Before drawing the HTML the layout tree is constructed. Each node in the tree has the absolute position and dimensions calculated, and the text content is laid out according to the specification (e.g. each text line is contained in a line box). 59 | 60 | There is no default stylesheet included, so all css properties fallback to their default values. This also means that everything in the `head` tag will be visible unless explicitly hidden. 61 | 62 | It's possible to use the `stylesheets` option to provide styles that will be included before all other stylesheets in the HTML. 63 | 64 | The module tries to follow the [CSS 2.1][css21] specification. 65 | 66 | # Issues 67 | 68 | At the moment only normal flow is implemented, and without support for lists and tables. 69 | 70 | Follows a non-exhaustive list of missing functionallity. 71 | 72 | - Collapsing margins 73 | - Background images 74 | - Inline blocks 75 | - Support for more vertical align values 76 | - Text decoration and align 77 | - Lists and counter properties (`counter-reset` and `counter-increment`) 78 | - Tables 79 | - Pseudo elements, `:before`, `:after` and the `content` property 80 | - `overflow` property 81 | - `float` and `clear` property 82 | - `position` property 83 | - CSS media types (`@media` rule) 84 | - Rounded corners (CSS3 functionallity, but would be nice to have) 85 | 86 | [govett]: https://twitter.com/devongovett 87 | [wkjs]: https://badassjs.com/post/20294238453/webkit-js-yes-it-has-finally-happened-browser 88 | [toy]: https://limpet.net/mbrubeck/2014/08/08/toy-layout-engine-1.html 89 | [brubeck]: https://limpet.net/mbrubeck 90 | [rc]: https://github.com/kapetan/repaint-chrome 91 | [test]: https://kapetan.github.io/repaint/dist/test/index.html 92 | [css21]: https://www.w3.org/TR/2011/REC-CSS2-20110607 93 | [htmlparser2]: https://github.com/fb55/htmlparser2 94 | [css]: https://github.com/reworkcss/css 95 | [css-select]: https://github.com/fb55/css-select 96 | 97 | [tw]: https://kapetan.github.io/repaint-chrome/examples/text-width.html 98 | [pf]: https://kapetan.github.io/repaint-chrome/examples/peerflix.html 99 | [wiki]: https://kapetan.github.io/repaint-chrome/examples/wiki.html 100 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "repaint", 3 | "version": "0.0.11", 4 | "description": "HTML rendering engine", 5 | "main": "source/index.js", 6 | "scripts": { 7 | "build": "make build", 8 | "clean": "make clean", 9 | "test-build": "make test-build", 10 | "test-watch": "make test-watch" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/kapetan/repaint" 15 | }, 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/kapetan/repaint/issues" 19 | }, 20 | "homepage": "https://github.com/kapetan/repaint", 21 | "keywords": [ 22 | "html", 23 | "render", 24 | "layout", 25 | "engine", 26 | "browser" 27 | ], 28 | "dependencies": { 29 | "after-all": "^2.0.2", 30 | "camelize": "^1.0.0", 31 | "capitalize": "^2.0.3", 32 | "css": "^3.0.0", 33 | "css-select": "^2.1.0", 34 | "css-shorthand-expand": "^1.2.0", 35 | "css-shorthand-properties": "^1.1.1", 36 | "domelementtype": "^2.0.1", 37 | "domhandler": "^3.0.0", 38 | "dot-prop": "^5.3.0", 39 | "flatten": "^1.0.3", 40 | "he": "^1.2.0", 41 | "htmlparser2": "^4.1.0", 42 | "parse-color": "^1.0.0", 43 | "specificity": "^0.1.4", 44 | "text-height": "^1.0.2", 45 | "text-width": "^1.2.0", 46 | "tuple": "0.0.1", 47 | "xhr": "^2.5.0", 48 | "xtend": "^4.0.2" 49 | }, 50 | "devDependencies": { 51 | "brfs": "^2.0.2", 52 | "browserify": "^16.5.2", 53 | "onchange": "^7.0.2", 54 | "root": "^3.2.0", 55 | "send": "^0.17.1", 56 | "watchify": "^3.11.1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | 3 | var root = require('root'); 4 | var send = require('send'); 5 | 6 | var PORT = process.env.PORT || 10104; 7 | 8 | var app = root(); 9 | 10 | app.get('{*}', function(request, response) { 11 | send(request, request.params.glob, { root: __dirname }) 12 | .pipe(response); 13 | }); 14 | 15 | app.listen(PORT, function() { 16 | console.log('Server listening on port', PORT); 17 | }); 18 | -------------------------------------------------------------------------------- /source/css.js: -------------------------------------------------------------------------------- 1 | var ElementType = require('domelementtype'); 2 | 3 | var cascade = require('./css/cascade'); 4 | var compute = require('./css/compute'); 5 | 6 | module.exports = function(stylesheets, html) { 7 | var stack = [html]; 8 | 9 | while(stack.length) { 10 | var nodes = stack.pop(); 11 | 12 | nodes.forEach(function(node) { 13 | if(ElementType.isTag(node)) { 14 | var style = cascade(stylesheets, node); 15 | var parentStyle = node.parentNode && node.parentNode.style; 16 | 17 | node.style = compute(style, parentStyle); 18 | } 19 | 20 | if(node.childNodes) stack.push(node.childNodes); 21 | }); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /source/css/cascade.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var css = require('css'); 3 | var dot = require('dot-prop'); 4 | 5 | var compare = require('./compare-specificity'); 6 | var declarations = require('./declarations'); 7 | 8 | var parse = function(property, value, order, rule) { 9 | var declaration = declarations[property]; 10 | return declaration && declaration.parse(property, value, order, rule); 11 | }; 12 | 13 | var reduce = function(arr, fn) { 14 | return arr.reduce(function(acc, item) { 15 | return acc.concat(fn(item)); 16 | }, []); 17 | }; 18 | 19 | var ElementRule = function(element) { 20 | this.element = element; 21 | 22 | this.declarations = []; 23 | this.specificity = [1, 0, 0, 0]; 24 | }; 25 | 26 | ElementRule.parse = function(element, str) { 27 | str = util.format('element.style { %s }', str); 28 | 29 | var rule = new ElementRule(element); 30 | var ast = css.parse(str, { silent: true }); 31 | var order = 1; 32 | 33 | var declarations = dot.get(ast, 'stylesheet.rules.0.declarations'); 34 | if(!declarations) return rule; 35 | 36 | declarations.forEach(function(d) { 37 | var declaration = parse(d.property, d.value, order, rule); 38 | if(!declaration) return; 39 | 40 | rule.declarations.push(declaration); 41 | order++; 42 | }); 43 | 44 | return rule; 45 | }; 46 | 47 | ElementRule.prototype.matches = function(element) { 48 | return this.element === element; 49 | }; 50 | 51 | ElementRule.prototype.compareTo = function(other) { 52 | return compare(this.specificity, other.specificity); 53 | }; 54 | 55 | ElementRule.prototype.toString = function() { 56 | return util.format('element.style { %s }', this.declarations.join(' ')); 57 | }; 58 | 59 | module.exports = function(stylesheets, node) { 60 | var rules = reduce(stylesheets, function(stylesheet) { 61 | return stylesheet.match(node); 62 | }); 63 | 64 | var declarations = reduce(rules, function(rule) { 65 | return rule.declarations; 66 | }); 67 | 68 | if(node.attribs.style) { 69 | var rule = ElementRule.parse(node, node.attribs.style); 70 | declarations = declarations.concat(rule.declarations); 71 | } 72 | 73 | declarations.sort(function(a, b) { 74 | return a.compareTo(b); 75 | }); 76 | 77 | var style = {}; 78 | declarations.forEach(function(declaration) { 79 | style[declaration.property] = declaration.value; 80 | }); 81 | 82 | return style; 83 | }; 84 | -------------------------------------------------------------------------------- /source/css/compare-specificity.js: -------------------------------------------------------------------------------- 1 | module.exports = function(first, second) { 2 | for(var i = 0; i < first.length; i++) { 3 | var a = first[i]; 4 | var b = second[i]; 5 | 6 | if(a === b) continue; 7 | return a - b; 8 | } 9 | 10 | return 0; 11 | }; 12 | -------------------------------------------------------------------------------- /source/css/compute.js: -------------------------------------------------------------------------------- 1 | var declarations = require('./declarations'); 2 | var values = require('./values'); 3 | 4 | var Keyword = values.Keyword; 5 | var PROPERTIES = Object.keys(declarations); 6 | 7 | module.exports = function(style, parentStyle) { 8 | var parentOrInitial = function(property) { 9 | return parentStyle ? parentStyle[property] : declarations[property].INITIAL; 10 | }; 11 | 12 | var initial = function(property) { 13 | return declarations[property].INITIAL; 14 | }; 15 | 16 | var inherit = function(property) { 17 | var Declaration = declarations[property]; 18 | return parentStyle ? parentStyle[property] : Declaration.INITIAL; 19 | }; 20 | 21 | var currentColor = function(property) { 22 | return property === 'color' ? 23 | parentOrInitial('color') : 24 | compute('color'); 25 | }; 26 | 27 | var em = function(property) { 28 | var length = style[property].length; 29 | var basis = property === 'font-size' ? 30 | parentOrInitial('font-size') : 31 | compute('font-size'); 32 | 33 | return values.Length.px(basis.length * length); 34 | }; 35 | 36 | var fontPercentage = function(property) { 37 | var percent = style[property].percentage; 38 | var basis = property === 'font-size' ? 39 | parentOrInitial('font-size') : 40 | compute('font-size'); 41 | 42 | return values.Length.px(basis.length * percent / 100); 43 | }; 44 | 45 | var compute = function(property) { 46 | var value = style[property]; 47 | var Declaration = declarations[property]; 48 | 49 | if(!value) { 50 | if(Declaration.INHERITED) return inherit(property); 51 | else return Declaration.INITIAL; 52 | } else { 53 | if(Keyword.Inherit.is(value)) return inherit(property); 54 | if(Keyword.Initial.is(value)) return initial(property); 55 | if(Keyword.CurrentColor.is(value)) return currentColor(property); 56 | if(values.Length.is(value) && value.unit === 'em') return em(property); 57 | if(property === 'line-height' && values.Percentage.is(value)) return fontPercentage(property); 58 | if(property === 'font-size' && values.Percentage.is(value)) return fontPercentage(property); 59 | } 60 | 61 | return value; 62 | }; 63 | 64 | PROPERTIES.forEach(function(property) { 65 | style[property] = compute(property); 66 | }); 67 | 68 | return style; 69 | }; 70 | -------------------------------------------------------------------------------- /source/css/declarations.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var capitalize = require('capitalize'); 3 | var camelize = require('camelize'); 4 | 5 | var values = require('./values'); 6 | var declarations = require('./declarations.json'); 7 | 8 | var resolve = function(arr) { 9 | return ['inherit', 'initial'] 10 | .concat(arr) 11 | .map(function(val) { 12 | if(Array.isArray(val)) { 13 | var resolved = resolve(val); 14 | return values.CommaSeparated.define(resolved); 15 | } 16 | 17 | if(val === values.Length.TYPE) return values.Length; 18 | if(val === values.Percentage.TYPE) return values.Percentage; 19 | if(val === values.Number.TYPE) return values.Number; 20 | if(val === values.Color.TYPE) return values.Color; 21 | if(val === values.FamilyName.TYPE) return values.FamilyName; 22 | else { 23 | val = capitalize(camelize(val), true); 24 | return values.Keyword[val]; 25 | } 26 | }); 27 | }; 28 | 29 | var parse = function(arr, value) { 30 | for(var i = 0; i < arr.length; i++) { 31 | var v = arr[i].parse(value); 32 | if(v) return v; 33 | } 34 | }; 35 | 36 | var define = function(property, definition) { 37 | var Klass = function(value, important, order, rule) { 38 | if(!(this instanceof Klass)) return new Klass(value); 39 | 40 | this.value = value; 41 | this.important = important; 42 | this.order = order; 43 | this.rule = rule; 44 | }; 45 | 46 | Klass.parse = function(p, v, order, rule) { 47 | if(property !== p.toLowerCase()) return; 48 | 49 | var important = /\s+!important$/.test(v); 50 | 51 | v = v.replace(/\s+!important$/, ''); 52 | v = parse(Klass.VALUES, v); 53 | 54 | if(v) return new Klass(v, important, order, rule); 55 | }; 56 | 57 | Klass.PROPERTY = property; 58 | Klass.VALUES = resolve(definition.values); 59 | Klass.INITIAL = parse(Klass.VALUES, definition.initial); 60 | Klass.INHERITED = definition.inherited; 61 | 62 | Klass.prototype.property = property; 63 | 64 | Klass.prototype.compareTo = function(other) { 65 | if(!this.important && other.important) return -1; 66 | if(this.important && !other.important) return 1; 67 | 68 | var specificity = this.rule.compareTo(other.rule); 69 | if(specificity) return specificity; 70 | 71 | return this.order - other.order; 72 | }; 73 | 74 | Klass.prototype.toString = function() { 75 | return util.format('%s: %s;', property, this.value); 76 | }; 77 | 78 | return Klass; 79 | }; 80 | 81 | Object.keys(declarations).forEach(function(property) { 82 | var definition = declarations[property]; 83 | definition = (typeof definition === 'string') ? declarations[definition] : definition; 84 | 85 | exports[property] = define(property, definition); 86 | }); 87 | -------------------------------------------------------------------------------- /source/css/declarations.json: -------------------------------------------------------------------------------- 1 | { 2 | "display": { 3 | "initial": "inline", 4 | "values": ["inline", "block", "none", "line-break"], 5 | "inherited": false 6 | }, 7 | "text-align": { 8 | "initial": "left", 9 | "values": ["left", "center", "right"], 10 | "inherited": true 11 | }, 12 | "color": { 13 | "initial": "#000000", 14 | "values": [""], 15 | "inherited": true 16 | }, 17 | "font-family": { 18 | "initial": "Times New Roman", 19 | "values": [["sans-serif", "serif", "monospace", "cursive", "fantasy", ""]], 20 | "inherited": true 21 | }, 22 | "font-weight": { 23 | "initial": "normal", 24 | "values": ["normal", "bold"], 25 | "inherited": true 26 | }, 27 | "font-style": { 28 | "initial": "normal", 29 | "values": ["normal", "italic", "oblique"], 30 | "inherited": true 31 | }, 32 | "font-size": { 33 | "initial": "16px", 34 | "values": ["", ""], 35 | "inherited": true 36 | }, 37 | "line-height": { 38 | "initial": "1.2", 39 | "values": ["", "", ""], 40 | "inherited": true 41 | }, 42 | "vertical-align": { 43 | "initial": "baseline", 44 | "values": ["baseline", "", ""], 45 | "inherited": false 46 | }, 47 | "white-space": { 48 | "initial": "normal", 49 | "values": ["normal", "nowrap", "pre", "pre-wrap", "pre-line"], 50 | "inherited": true 51 | }, 52 | "background-color": { 53 | "initial": "rgba(0, 0, 0, 0)", 54 | "values": [""], 55 | "inherited": false 56 | }, 57 | "width": { 58 | "initial": "auto", 59 | "values": ["", "", "auto"], 60 | "inherited": false 61 | }, 62 | "height": "width", 63 | "margin-top": { 64 | "initial": "0px", 65 | "values": ["", "", "auto"], 66 | "inherited": false 67 | }, 68 | "margin-right": "margin-top", 69 | "margin-bottom": "margin-top", 70 | "margin-left": "margin-top", 71 | "padding-top": { 72 | "initial": "0px", 73 | "values": ["", ""], 74 | "inherited": false 75 | }, 76 | "padding-right": "padding-top", 77 | "padding-bottom": "padding-top", 78 | "padding-left": "padding-top", 79 | "border-top-style": { 80 | "initial": "none", 81 | "values": ["none", "solid", "dashed"], 82 | "inherited": false 83 | }, 84 | "border-right-style": "border-top-style", 85 | "border-bottom-style": "border-top-style", 86 | "border-left-style": "border-top-style", 87 | "border-top-width": { 88 | "initial": "1px", 89 | "values": [""], 90 | "inherited": false 91 | }, 92 | "border-right-width": "border-top-width", 93 | "border-bottom-width": "border-top-width", 94 | "border-left-width": "border-top-width", 95 | "border-top-color": { 96 | "initial": "currentColor", 97 | "values": ["", "currentColor"], 98 | "inherited": false 99 | }, 100 | "border-right-color": "border-top-color", 101 | "border-bottom-color": "border-top-color", 102 | "border-left-color": "border-top-color" 103 | } 104 | -------------------------------------------------------------------------------- /source/css/expand-shorthand.js: -------------------------------------------------------------------------------- 1 | var expand = require('css-shorthand-expand'); 2 | var properties = require('css-shorthand-properties'); 3 | var flatten = require('flatten'); 4 | var extend = require('xtend'); 5 | var tuple = require('tuple'); 6 | 7 | module.exports = function(name, value) { 8 | var expanded = expand(name, value); 9 | if(!expanded) return tuple(name, value); 10 | 11 | var recursive = /^border/.test(name); 12 | var all = properties.expand(name, recursive); 13 | all = flatten(all); 14 | all = all.reduce(function(acc, property) { 15 | acc[property] = 'initial'; 16 | return acc; 17 | }, {}); 18 | 19 | return extend(all, expanded); 20 | }; 21 | -------------------------------------------------------------------------------- /source/css/stylesheet.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | 3 | var CSSselect = require('css-select'); 4 | var css = require('css'); 5 | var specificity = require('specificity'); 6 | 7 | var declarations = require('./declarations'); 8 | var compare = require('./compare-specificity'); 9 | var expand = require('./expand-shorthand'); 10 | 11 | var parse = function(property, value, order, rule) { 12 | var declaration = declarations[property]; 13 | return declaration && declaration.parse(property, value, order, rule); 14 | }; 15 | 16 | var compile = function(selector) { 17 | try { 18 | return CSSselect.compile(selector); 19 | } catch(err) { 20 | return function() { 21 | return false; 22 | }; 23 | } 24 | }; 25 | 26 | var Rule = function(selector, order, stylesheet) { 27 | this.selector = selector; 28 | this.order = order; 29 | this.stylesheet = stylesheet; 30 | 31 | this.declarations = []; 32 | this.matches = compile(selector); 33 | 34 | this.specificity = specificity 35 | .calculate(selector)[0] 36 | .specificity 37 | .split(',') 38 | .map(function(s) { 39 | return parseInt(s, 10); 40 | }); 41 | }; 42 | 43 | Rule.prototype.compareTo = function(other) { 44 | var priority = other.stylesheet && this.stylesheet.comparePriorityTo(other.stylesheet); 45 | if(priority) return priority; 46 | 47 | var specificity = compare(this.specificity, other.specificity); 48 | if(specificity) return specificity; 49 | 50 | var order = other.stylesheet && this.stylesheet.compareOrderTo(other.stylesheet); 51 | return order ? order : this.order - other.order; 52 | }; 53 | 54 | Rule.prototype.toString = function() { 55 | return util.format('%s { %s }', this.selector, this.declarations.join(' ')); 56 | }; 57 | 58 | var Stylesheet = function(order, priority) { 59 | this.order = order || 0; 60 | this.priority = priority || 0; 61 | this.rules = []; 62 | }; 63 | 64 | Stylesheet.empty = function(order, priority) { 65 | return new Stylesheet(order, priority); 66 | }; 67 | 68 | Stylesheet.parse = function(str, order, priority) { 69 | var ast = css.parse(str, { silent: true }); 70 | var rules = ast.stylesheet.rules; 71 | 72 | var stylesheet = new Stylesheet(order, priority); 73 | 74 | rules.forEach(function(r, i) { 75 | if(r.type !== 'rule' || !r.declarations) return; 76 | 77 | r.selectors.forEach(function(s) { 78 | var rule = new Rule(s, i, stylesheet); 79 | var order = 1; 80 | 81 | r.declarations.forEach(function(d) { 82 | var longhand = expand(d.property, d.value); 83 | 84 | Object.keys(longhand).forEach(function(key) { 85 | var declaration = parse(key, longhand[key], order, rule); 86 | if(!declaration) return; 87 | 88 | rule.declarations.push(declaration); 89 | order++; 90 | }); 91 | }); 92 | 93 | if(rule.declarations.length) stylesheet.rules.push(rule); 94 | }); 95 | }); 96 | 97 | return stylesheet; 98 | }; 99 | 100 | Stylesheet.prototype.match = function(node) { 101 | return this.rules.filter(function(rule) { 102 | return rule.matches(node); 103 | }); 104 | }; 105 | 106 | Stylesheet.prototype.comparePriorityTo = function(other) { 107 | return this.priority - other.priority; 108 | }; 109 | 110 | Stylesheet.prototype.compareOrderTo = function(other) { 111 | return this.order - other.order; 112 | }; 113 | 114 | Stylesheet.prototype.toString = function() { 115 | return this.rules.join(' '); 116 | }; 117 | 118 | module.exports = Stylesheet; 119 | -------------------------------------------------------------------------------- /source/css/values.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var capitalize = require('capitalize'); 3 | var camelize = require('camelize'); 4 | var parseColor = require('parse-color'); 5 | 6 | var declarations = require('./declarations.json'); 7 | 8 | var VALUE_WITH_UNIT = /^([+-]?\d*[\.]?\d+)(\%|\w+)$/ 9 | var NUMBER = /^[+-]?\d*[\.]?\d+$/; 10 | 11 | var define = function(fn) { 12 | var Klass = function() { 13 | var self = Object.create(Klass.prototype); 14 | fn.apply(self, arguments); 15 | 16 | return self; 17 | }; 18 | 19 | return Klass; 20 | }; 21 | 22 | var keywords = function(values, Klass) { 23 | values.forEach(function(v) { 24 | if(Array.isArray(v)) return keywords(v, Klass); 25 | 26 | var isPredefined = v === Length.TYPE || v === Percentage.TYPE || 27 | v === Number.TYPE || v === Color.TYPE; 28 | 29 | if(isPredefined) return; 30 | 31 | var n = capitalize(camelize(v), true); 32 | if(Klass[n]) return; 33 | 34 | Klass[n] = new Klass(v); 35 | }); 36 | }; 37 | 38 | var CommaSeparated = define(function(values) { 39 | this.values = values; 40 | }); 41 | 42 | CommaSeparated.TYPE = ''; 43 | 44 | CommaSeparated.parse = function(str, definitions) { 45 | var parseValue = function(value) { 46 | for(var i = 0; i < definitions.length; i++) { 47 | var v = definitions[i].parse(value); 48 | if(v) return v; 49 | } 50 | }; 51 | 52 | var values = str 53 | .split(',') 54 | .map(function(v) { 55 | v = v.trim(); 56 | return parseValue(v); 57 | }) 58 | .filter(Boolean); 59 | 60 | return new CommaSeparated(values); 61 | }; 62 | 63 | CommaSeparated.define = function(definitions) { 64 | var Klass = function(values) { 65 | return new CommaSeparated(values); 66 | }; 67 | 68 | Klass.TYPE = CommaSeparated.TYPE; 69 | Klass.is = CommaSeparated.is; 70 | 71 | Klass.parse = function(str) { 72 | return CommaSeparated.parse(str, definitions); 73 | }; 74 | 75 | return Klass; 76 | }; 77 | 78 | CommaSeparated.is = function(value) { 79 | return value.type === CommaSeparated.TYPE; 80 | }; 81 | 82 | CommaSeparated.prototype.type = CommaSeparated.TYPE; 83 | CommaSeparated.prototype.toString = function() { 84 | return this.values.join(', '); 85 | }; 86 | 87 | var Length = define(function(length, unit) { 88 | this.length = length; 89 | this.unit = unit; 90 | }); 91 | 92 | Length.TYPE = ''; 93 | Length.UNITS = ['px', 'em']; 94 | 95 | Length.parse = function(str) { 96 | var match = str.match(VALUE_WITH_UNIT); 97 | if(!match) { 98 | if(str !== '0') return; 99 | return new Length(0, 'px'); 100 | } 101 | 102 | var number = match[1]; 103 | var unit = match[2]; 104 | 105 | if(!NUMBER.test(number) || Length.UNITS.indexOf(unit) === -1) return; 106 | 107 | return new Length(parseFloat(number), unit); 108 | }; 109 | 110 | Length.is = function(value) { 111 | return value.type === Length.TYPE; 112 | }; 113 | 114 | Length.px = function(length) { 115 | return new Length(length, 'px'); 116 | }; 117 | 118 | Length.em = function(length) { 119 | return new Length(length, 'em'); 120 | }; 121 | 122 | Length.prototype.type = Length.TYPE; 123 | Length.prototype.toString = function() { 124 | return this.length + this.unit; 125 | }; 126 | 127 | var Percentage = define(function(percentage) { 128 | this.percentage = percentage; 129 | }); 130 | 131 | Percentage.TYPE = ''; 132 | 133 | Percentage.parse = function(str) { 134 | var match = str.match(VALUE_WITH_UNIT); 135 | if(!match || match[2] !== '%') return; 136 | 137 | return new Percentage(parseFloat(match[1])); 138 | }; 139 | 140 | Percentage.is = function(value) { 141 | return value.type === Percentage.TYPE; 142 | }; 143 | 144 | Percentage.prototype.type = Percentage.TYPE; 145 | Percentage.prototype.toString = function() { 146 | return this.percentage + '%'; 147 | }; 148 | 149 | var Number = define(function(number) { 150 | this.number = number; 151 | }); 152 | 153 | Number.TYPE = ''; 154 | 155 | Number.parse = function(str) { 156 | if(!NUMBER.test(str)) return; 157 | return new Number(parseFloat(str)); 158 | }; 159 | 160 | Number.is = function(value) { 161 | return value.type === Number.TYPE; 162 | }; 163 | 164 | Number.prototype.type = Number.TYPE; 165 | Number.prototype.toString = function() { 166 | return this.number.toString(); 167 | }; 168 | 169 | var Color = define(function(red, green, blue, alpha) { 170 | this.red = red; 171 | this.green = green; 172 | this.blue = blue; 173 | this.alpha = (typeof alpha !== 'number') ? 1 : alpha; 174 | }); 175 | 176 | Color.TYPE = ''; 177 | 178 | Color.parse = function(str) { 179 | var rgba = parseColor(str).rgba; 180 | if(rgba) return new Color(rgba[0], rgba[1], rgba[2], rgba[3]); 181 | if(str === 'transparent') return Color(0, 0, 0, 0); 182 | }; 183 | 184 | Color.is = function(value) { 185 | return value.type === Color.TYPE; 186 | }; 187 | 188 | Color.prototype.type = Color.TYPE; 189 | Color.prototype.toString = function() { 190 | return util.format('rgba(%s, %s, %s, %s)', this.red, this.green, this.blue, this.alpha); 191 | }; 192 | 193 | var FamilyName = define(function(name) { 194 | this.name = name; 195 | }); 196 | 197 | FamilyName.TYPE = ''; 198 | 199 | FamilyName.parse = function(str) { 200 | var first = str.charAt(0); 201 | var last = str.charAt(str.length - 1); 202 | 203 | var isFirstQuote = /'|"/.test(first); 204 | var isLastQuote = /'|"/.test(last); 205 | 206 | if((isFirstQuote || isLastQuote) && first !== last) return; 207 | if(isFirstQuote && isLastQuote) str = str.slice(1, -1); 208 | 209 | return new FamilyName(str); 210 | }; 211 | 212 | FamilyName.is = function(value) { 213 | return value.type === FamilyName.TYPE; 214 | }; 215 | 216 | FamilyName.prototype.type = FamilyName.TYPE; 217 | FamilyName.prototype.toString = function() { 218 | return util.format('"%s"', this.name); 219 | }; 220 | 221 | var Keyword = define(function(keyword) { 222 | this.keyword = keyword; 223 | this.normalized = keyword.toLowerCase(); 224 | this.type = keyword; 225 | }); 226 | 227 | Keyword.prototype.parse = function(str) { 228 | if(this.normalized === str.toLowerCase()) return this; 229 | }; 230 | 231 | Keyword.prototype.is = function(value) { 232 | return !!(value.keyword && value.keyword === this.keyword); 233 | }; 234 | 235 | Keyword.prototype.toString = function() { 236 | return this.keyword; 237 | }; 238 | 239 | Object.keys(declarations).forEach(function(property) { 240 | var definition = declarations[property]; 241 | if(typeof definition === 'string') return; 242 | 243 | keywords(definition.values, Keyword); 244 | }); 245 | 246 | Keyword.Initial = new Keyword('initial'); 247 | Keyword.Inherit = new Keyword('inherit'); 248 | 249 | exports.CommaSeparated = CommaSeparated; 250 | exports.Length = Length; 251 | exports.Percentage = Percentage; 252 | exports.Number = Number; 253 | exports.Color = Color; 254 | exports.FamilyName = FamilyName; 255 | exports.Keyword = Keyword; 256 | -------------------------------------------------------------------------------- /source/draw.js: -------------------------------------------------------------------------------- 1 | var Viewport = require('./layout/viewport'); 2 | var LineBox = require('./layout/line-box'); 3 | var TextBox = require('./layout/text-box'); 4 | var ImageBox = require('./layout/image-box'); 5 | 6 | var background = require('./draw/background'); 7 | var border = require('./draw/border'); 8 | var text = require('./draw/text'); 9 | var image = require('./draw/image'); 10 | 11 | var drawChildren = function(box, context) { 12 | if(!box.children) return; 13 | 14 | box.children.forEach(function(child) { 15 | draw(child, context); 16 | }); 17 | }; 18 | 19 | var draw = function(box, context) { 20 | if(box instanceof Viewport || box instanceof LineBox) return drawChildren(box, context); 21 | if(box instanceof TextBox) return text(box, context); 22 | 23 | background(box, context); 24 | border(box, context); 25 | 26 | if(box instanceof ImageBox) image(box, context); 27 | 28 | drawChildren(box, context); 29 | }; 30 | 31 | module.exports = draw; 32 | -------------------------------------------------------------------------------- /source/draw/background.js: -------------------------------------------------------------------------------- 1 | module.exports = function(box, context) { 2 | var color = box.style['background-color']; 3 | 4 | if((!box.innerWidth() && !box.innerHeight()) || !color.alpha) return; 5 | 6 | var x = box.position.x - box.padding.left; 7 | var y = box.position.y - box.padding.top; 8 | var width = box.padding.left + box.dimensions.width + box.padding.right; 9 | var height = box.padding.top + box.dimensions.height + box.padding.bottom; 10 | 11 | context.fillStyle = color.toString(); 12 | context.fillRect(x, y, width, height); 13 | }; 14 | -------------------------------------------------------------------------------- /source/draw/border.js: -------------------------------------------------------------------------------- 1 | var drawBorderTop = function(box, context) { 2 | if(!box.border.top) return; 3 | 4 | var x = box.position.x - box.padding.left - box.border.left; 5 | var y = box.position.y - box.padding.top - box.border.top; 6 | var width = box.border.left + box.padding.left + box.dimensions.width + box.padding.right + box.border.right; 7 | var height = box.border.top; 8 | 9 | context.fillStyle = box.style['border-top-color'].toString(); 10 | context.fillRect(x, y, width, height); 11 | }; 12 | 13 | var drawBorderRight = function(box, context) { 14 | if(!box.border.right) return; 15 | 16 | var x = box.position.x + box.dimensions.width + box.padding.right; 17 | var y = box.position.y - box.padding.top - box.border.top; 18 | var width = box.border.right; 19 | var height = box.border.top + box.padding.top + box.dimensions.height + box.padding.bottom + box.border.bottom; 20 | 21 | context.fillStyle = box.style['border-right-color'].toString(); 22 | context.fillRect(x, y, width, height); 23 | }; 24 | 25 | var drawBorderBottom = function(box, context) { 26 | if(!box.border.bottom) return; 27 | 28 | var x = box.position.x - box.padding.left - box.border.left; 29 | var y = box.position.y + box.dimensions.height + box.padding.bottom; 30 | var width = box.border.left + box.padding.left + box.dimensions.width + box.padding.right + box.border.right; 31 | var height = box.border.bottom; 32 | 33 | context.fillStyle = box.style['border-bottom-color'].toString(); 34 | context.fillRect(x, y, width, height); 35 | }; 36 | 37 | var drawBorderLeft = function(box, context) { 38 | if(!box.border.left) return; 39 | 40 | var x = box.position.x - box.padding.left - box.border.left; 41 | var y = box.position.y - box.padding.top - box.border.top; 42 | var width = box.border.left; 43 | var height = box.border.top + box.padding.top + box.dimensions.height + box.padding.bottom + box.border.bottom; 44 | 45 | context.fillStyle = box.style['border-left-color'].toString(); 46 | context.fillRect(x, y, width, height); 47 | }; 48 | 49 | module.exports = function(box, context) { 50 | drawBorderTop(box, context); 51 | drawBorderRight(box, context); 52 | drawBorderBottom(box, context); 53 | drawBorderLeft(box, context); 54 | }; 55 | -------------------------------------------------------------------------------- /source/draw/image.js: -------------------------------------------------------------------------------- 1 | module.exports = function(box, context) { 2 | if(!box.image.complete) return; 3 | 4 | context.drawImage(box.image.data, 5 | box.position.x, 6 | box.position.y, 7 | box.dimensions.width, 8 | box.dimensions.height); 9 | }; 10 | -------------------------------------------------------------------------------- /source/draw/text.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | 3 | module.exports = function(box, context) { 4 | var style = box.style; 5 | 6 | context.font = util.format('%s %s %s %s', 7 | style['font-style'], 8 | style['font-weight'], 9 | style['font-size'], 10 | style['font-family']); 11 | context.textBaseline = 'bottom'; 12 | context.fillStyle = style['color'].toString(); 13 | context.fillText(box.display, box.position.x, box.position.y + box.dimensions.height); 14 | }; 15 | -------------------------------------------------------------------------------- /source/html.js: -------------------------------------------------------------------------------- 1 | var htmlparser = require('htmlparser2'); 2 | var ElementType = require('domelementtype'); 3 | var DomHandler = require('domhandler').DomHandler; 4 | 5 | module.exports = function(html, callback) { 6 | var stylesheets = []; 7 | var scripts = []; 8 | var images = []; 9 | var anchors = []; 10 | var title = null; 11 | 12 | var ondone = function(err, html) { 13 | if(err) return callback(err); 14 | callback(null, { 15 | html: html, 16 | stylesheets: stylesheets, 17 | scripts: scripts, 18 | images: images, 19 | anchors: anchors, 20 | title: title 21 | }); 22 | }; 23 | 24 | var handler = new DomHandler(ondone, { 25 | withDomLvl1: true, 26 | normalizeWhitespace: false 27 | }, function(element) { 28 | if(element.type === ElementType.Script) scripts.push(element); 29 | if(element.type === ElementType.Style) stylesheets.push(element); 30 | if(element.type === ElementType.Tag && element.name === 'link' && element.attribs.rel === 'stylesheet') stylesheets.push(element); 31 | if(element.type === ElementType.Tag && element.name === 'img') images.push(element); 32 | if(element.type === ElementType.Tag && element.name === 'a') anchors.push(element); 33 | if(element.type === ElementType.Tag && element.name === 'title') title = element; 34 | }); 35 | 36 | var parser = new htmlparser.Parser(handler); 37 | 38 | parser.write(html); 39 | parser.done(); 40 | }; 41 | -------------------------------------------------------------------------------- /source/images.js: -------------------------------------------------------------------------------- 1 | var url = require('url'); 2 | var afterAll = require('after-all'); 3 | var he = require('he'); 4 | 5 | var empty = function(src) { 6 | return { 7 | width: 0, 8 | height: 0, 9 | src: src, 10 | complete: false, 11 | data: null 12 | }; 13 | }; 14 | 15 | module.exports = function(base, nodes, callback) { 16 | if(!nodes.length) return callback(null, []); 17 | 18 | var images = new Array(nodes.length); 19 | var next = afterAll(function(err) { 20 | callback(err, images); 21 | }); 22 | 23 | nodes.forEach(function(node, i) { 24 | var src = node.attribs.src; 25 | var cb = next(function(err, image) { 26 | image = image || empty(src); 27 | 28 | images[i] = node.image = { 29 | width: image.width, 30 | height: image.height, 31 | src: image.src, 32 | complete: image.complete, 33 | data: image 34 | }; 35 | }); 36 | 37 | if(!src) return cb(); 38 | 39 | src = he.decode(src); 40 | src = url.resolve(base, src); 41 | 42 | var image = new Image(); 43 | image.onload = function() { 44 | cb(null, image); 45 | }; 46 | image.onerror = function() { 47 | cb(); 48 | }; 49 | 50 | image.src = src; 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /source/index.js: -------------------------------------------------------------------------------- 1 | var events = require('events'); 2 | var afterAll = require('after-all'); 3 | var extend = require('xtend/mutable'); 4 | 5 | var html = require('./html'); 6 | var stylesheets = require('./stylesheets'); 7 | var css = require('./css'); 8 | var images = require('./images'); 9 | var layout = require('./layout'); 10 | var draw = require('./draw'); 11 | var Stylesheet = require('./css/stylesheet'); 12 | 13 | var noop = function() {}; 14 | 15 | var clone = function(obj) { 16 | return JSON.parse(JSON.stringify(obj)); 17 | }; 18 | 19 | var defaultStylesheets = function(stylesheets) { 20 | return (stylesheets || []).map(function(sheet, i) { 21 | return Stylesheet.parse(sheet, i - stylesheets.length); 22 | }); 23 | }; 24 | 25 | module.exports = function(options, callback) { 26 | callback = callback || noop; 27 | 28 | var page = new events.EventEmitter(); 29 | var emit = page.emit.bind(page); 30 | var ondone = function(err) { 31 | if(err) { 32 | emit('fail', err); 33 | return callback(err); 34 | } 35 | 36 | emit('ready', page); 37 | callback(null, page); 38 | }; 39 | 40 | extend(page, options, { 41 | stylesheets: null, 42 | viewport: clone(options.viewport) 43 | }); 44 | 45 | var position = options.viewport.position; 46 | options.viewport.position = extend({ x: 0, y: 0 }, position); 47 | 48 | html(options.content, function(err, document) { 49 | if(err) return ondone(err); 50 | 51 | page.document = document; 52 | emit('html', document); 53 | 54 | var next = afterAll(function(err) { 55 | if(err) return ondone(err); 56 | 57 | var tree = layout(document.html, options.viewport); 58 | page.layout = tree; 59 | emit('layout', tree); 60 | 61 | draw(tree, options.context); 62 | emit('draw'); 63 | 64 | ondone(); 65 | }); 66 | 67 | images(options.url, document.images, next(function(err, images) { 68 | page.images = images; 69 | emit('images', images); 70 | })); 71 | 72 | stylesheets(options.url, document.stylesheets, next(function(err, stylesheets) { 73 | stylesheets = defaultStylesheets(options.stylesheets).concat(stylesheets); 74 | css(stylesheets, document.html); 75 | page.stylesheets = stylesheets; 76 | emit('stylesheets', stylesheets); 77 | })); 78 | }); 79 | 80 | return page; 81 | }; 82 | -------------------------------------------------------------------------------- /source/layout.js: -------------------------------------------------------------------------------- 1 | var ElementType = require('domelementtype'); 2 | 3 | var values = require('./css/values'); 4 | var Viewport = require('./layout/viewport'); 5 | var BlockBox = require('./layout/block-box'); 6 | var LineBox = require('./layout/line-box'); 7 | var LineBreakBox = require('./layout/line-break-box'); 8 | var InlineBox = require('./layout/inline-box'); 9 | var TextBox = require('./layout/text-box'); 10 | var ImageBox = require('./layout/image-box'); 11 | 12 | var None = values.Keyword.None; 13 | var Block = values.Keyword.Block; 14 | var Inline = values.Keyword.Inline; 15 | var LineBreak = values.Keyword.LineBreak; 16 | 17 | var isInlineLevelBox = function(box) { 18 | return box instanceof InlineBox || 19 | box instanceof TextBox || 20 | box instanceof LineBreakBox || 21 | box instanceof ImageBox.Inline; 22 | }; 23 | 24 | var isInlineContainerBox = function(box) { 25 | return box instanceof InlineBox || 26 | box instanceof LineBox; 27 | }; 28 | 29 | var isBlockLevelBox = function(box) { 30 | return box instanceof Viewport || 31 | box instanceof LineBox || 32 | box instanceof BlockBox || 33 | box instanceof ImageBox.Block; 34 | }; 35 | 36 | var isBlockContainerBox = function(box) { 37 | return box instanceof Viewport || 38 | box instanceof BlockBox; 39 | }; 40 | 41 | var branch = function(ancestor, descedant) { 42 | var first, current; 43 | 44 | while(descedant !== ancestor) { 45 | var d = descedant.clone(); 46 | descedant.addLink(d); 47 | 48 | if(current) d.attach(current); 49 | if(!first) first = d; 50 | 51 | current = d; 52 | 53 | if(!descedant.parent) throw new Error('No ancestor match'); 54 | descedant = descedant.parent; 55 | } 56 | 57 | if(current) ancestor.attach(current); 58 | return first; 59 | }; 60 | 61 | var build = function(parent, nodes) { 62 | nodes.forEach(function(node) { 63 | var box; 64 | 65 | if(ElementType.isTag(node)) { 66 | var style = node.style; 67 | var display = style.display; 68 | 69 | if(None.is(display)) { 70 | return; 71 | } else if(node.name === 'img') { 72 | var image = node.image; 73 | 74 | if(Block.is(display)) box = new ImageBox.Block(parent, style, image); 75 | else box = new ImageBox.Inline(parent, style, image); 76 | } else if(Inline.is(display)) { 77 | box = new InlineBox(parent, style); 78 | } else if(Block.is(display)) { 79 | box = new BlockBox(parent, style); 80 | } else if(LineBreak.is(display)) { 81 | box = new LineBreakBox(parent, style); 82 | } 83 | 84 | build(box, node.childNodes); 85 | } else if(node.type === ElementType.Text) { 86 | box = new TextBox(parent, node.data); 87 | } 88 | 89 | if(box) parent.children.push(box); 90 | }); 91 | }; 92 | 93 | var blocks = function(parent, boxes, ancestor) { 94 | ancestor = ancestor || parent; 95 | 96 | var isInline = isInlineContainerBox(parent); 97 | var resume; 98 | 99 | boxes.forEach(function(child) { 100 | var isBlock = isBlockLevelBox(child); 101 | var box; 102 | 103 | if(isInline && isBlock) { 104 | box = child.clone(ancestor); 105 | parent = branch(ancestor, parent); 106 | resume = parent.parent; 107 | } else { 108 | box = child.cloneWithLinks(parent); 109 | } 110 | 111 | if(child.children) { 112 | var a = isBlockContainerBox(box) ? box : ancestor; 113 | parent = blocks(box, child.children, a) || parent; 114 | } 115 | }); 116 | 117 | return resume; 118 | }; 119 | 120 | var lines = function(parent, boxes) { 121 | var isBlock = isBlockContainerBox(parent); 122 | var line; 123 | 124 | boxes.forEach(function(child) { 125 | var isInline = isInlineLevelBox(child); 126 | var box; 127 | 128 | if(isBlock && isInline) { 129 | if(!line) { 130 | line = new LineBox(parent); 131 | parent.children.push(line); 132 | } 133 | 134 | box = child.cloneWithLinks(line); 135 | } else { 136 | line = null; 137 | box = child.cloneWithLinks(parent); 138 | } 139 | 140 | if(child.children) lines(box, child.children); 141 | }); 142 | }; 143 | 144 | module.exports = function(html, viewport) { 145 | viewport = new Viewport(viewport.position, viewport.dimensions); 146 | 147 | build(viewport, html); 148 | 149 | viewport = [ 150 | blocks, 151 | lines 152 | ].reduce(function(acc, fn) { 153 | var a = acc.clone(); 154 | fn(a, acc.children); 155 | return a; 156 | }, viewport); 157 | 158 | viewport.layout(); 159 | 160 | return viewport; 161 | }; 162 | -------------------------------------------------------------------------------- /source/layout/block-box.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | 3 | var ParentBox = require('./parent-box'); 4 | var values = require('../css/values'); 5 | 6 | var Auto = values.Keyword.Auto; 7 | var Percentage = values.Percentage; 8 | var Length = values.Length; 9 | 10 | var auto = function(value) { 11 | return Auto.is(value); 12 | }; 13 | 14 | var BlockBox = function(parent, style) { 15 | ParentBox.call(this, parent, style); 16 | }; 17 | 18 | util.inherits(BlockBox, ParentBox); 19 | 20 | BlockBox.prototype.addLine = function(child, branch) { 21 | var children = this.children; 22 | var i = children.indexOf(child); 23 | 24 | this.attach(branch, i + 1); 25 | }; 26 | 27 | BlockBox.prototype.layout = function(offset) { 28 | this._layoutWidth(); 29 | this._layoutPosition(offset); 30 | this._layoutChildren(); 31 | this._layoutHeight(); 32 | }; 33 | 34 | BlockBox.prototype._layoutWidth = function() { 35 | var self = this; 36 | var parent = this.parent; 37 | var style = this.style; 38 | 39 | var width = style.width; 40 | 41 | var marginLeft = style['margin-left']; 42 | var marginRight = style['margin-right']; 43 | 44 | var borderLeft = this.styledBorderWidth('left'); 45 | var borderRight = this.styledBorderWidth('right'); 46 | 47 | var paddingLeft = style['padding-left']; 48 | var paddingRight = style['padding-right']; 49 | 50 | var total = [ 51 | width, 52 | marginLeft, 53 | marginRight, 54 | borderLeft, 55 | borderRight, 56 | paddingLeft, 57 | paddingRight 58 | ].reduce(function(acc, v) { 59 | return acc + self.toPx(v); 60 | }, 0); 61 | 62 | var underflow = parent.dimensions.width - total; 63 | 64 | if(!auto(width) && underflow < 0) { 65 | if(auto(marginLeft)) marginLeft = Length.px(0); 66 | if(auto(marginRight)) marginRight = Length.px(0); 67 | } 68 | 69 | var isWidthAuto = auto(width); 70 | var isMarginLeftAuto = auto(marginLeft); 71 | var isMarginRightAuto = auto(marginRight); 72 | 73 | if(!isWidthAuto && !isMarginLeftAuto && !isMarginRightAuto) { 74 | var margin = this.toPx(marginRight); 75 | marginRight = Length.px(margin + underflow); 76 | } else if(!isWidthAuto && !isMarginLeftAuto && isMarginRightAuto) { 77 | marginRight = Length.px(underflow); 78 | } else if(!isWidthAuto && isMarginLeftAuto && !isMarginRightAuto) { 79 | marginLeft = Length.px(underflow); 80 | } else if(isWidthAuto && !isMarginLeftAuto && !isMarginRightAuto) { 81 | if(isMarginLeftAuto) marginLeft = Length.px(0); 82 | if(isMarginRightAuto) marginRight = Length.px(0); 83 | 84 | if(underflow >= 0) { 85 | width = Length.px(underflow); 86 | } else { 87 | var margin = this.toPx(marginRight); 88 | 89 | width = Length.px(0); 90 | marginRight = Length.px(margin + underflow); 91 | } 92 | } else if(!isWidthAuto && isMarginLeftAuto && isMarginRightAuto) { 93 | marginLeft = Length.px(underflow / 2); 94 | marginRight = Length.px(underflow / 2); 95 | } 96 | 97 | this.dimensions.width = this.toPx(width); 98 | 99 | this.margin.left = this.toPx(marginLeft); 100 | this.margin.right = this.toPx(marginRight); 101 | 102 | this.border.left = this.toPx(borderLeft); 103 | this.border.right = this.toPx(borderRight); 104 | 105 | this.padding.left = this.toPx(paddingLeft); 106 | this.padding.right = this.toPx(paddingRight); 107 | }; 108 | 109 | BlockBox.prototype._layoutPosition = function(offset) { 110 | var parent = this.parent; 111 | var style = this.style; 112 | 113 | this.margin.top = this.toPx(style['margin-top']); 114 | this.margin.bottom = this.toPx(style['margin-bottom']); 115 | 116 | this.border.top = this.toPx(this.styledBorderWidth('top')); 117 | this.border.bottom = this.toPx(this.styledBorderWidth('bottom')); 118 | 119 | this.padding.top = this.toPx(style['padding-top']); 120 | this.padding.bottom = this.toPx(style['padding-bottom']); 121 | 122 | this.position.x = parent.position.x + this.leftWidth(); 123 | this.position.y = parent.position.y + offset.height + this.topWidth(); 124 | }; 125 | 126 | BlockBox.prototype._layoutChildren = function() { 127 | var offset = { width: 0, height: 0 }; 128 | 129 | this.forEach(function(child) { 130 | child.layout(offset); 131 | offset.height += child.height(); 132 | }); 133 | 134 | this.dimensions.height = offset.height; 135 | }; 136 | 137 | BlockBox.prototype._layoutHeight = function() { 138 | var parent = this.parent; 139 | var height = this.style.height; 140 | var parentHeight = parent.style.height; 141 | 142 | if(Length.is(height)) { 143 | this.dimensions.height = height.length; 144 | } else if(Percentage.is(height) && Length.is(parentHeight)) { 145 | this.dimensions.height = parentHeight.length * height.percentage / 100; 146 | } 147 | }; 148 | 149 | module.exports = BlockBox; 150 | -------------------------------------------------------------------------------- /source/layout/box.js: -------------------------------------------------------------------------------- 1 | var values = require('../css/values'); 2 | 3 | var None = values.Keyword.None; 4 | var Length = values.Length; 5 | 6 | var Widths = function() { 7 | this.top = 0; 8 | this.right = 0; 9 | this.bottom = 0; 10 | this.left = 0; 11 | }; 12 | 13 | Widths.prototype.some = function() { 14 | return [ 15 | this.top, 16 | this.right, 17 | this.bottom, 18 | this.left 19 | ].some(function(v) { 20 | return v !== 0; 21 | }); 22 | }; 23 | 24 | Widths.prototype.reset = function() { 25 | this.top = 26 | this.right = 27 | this.bottom = 28 | this.left = 0; 29 | }; 30 | 31 | var Box = function(style) { 32 | this.style = style; 33 | 34 | this.position = { x: 0, y: 0 }; 35 | this.dimensions = { width: 0, height: 0 }; 36 | 37 | this.margin = new Widths(); 38 | this.border = new Widths(); 39 | this.padding = new Widths(); 40 | }; 41 | 42 | Box.prototype.topWidth = function() { 43 | return this.margin.top + this.border.top + this.padding.top; 44 | }; 45 | 46 | Box.prototype.rightWidth = function() { 47 | return this.margin.right + this.border.right + this.padding.right; 48 | }; 49 | 50 | Box.prototype.bottomWidth = function() { 51 | return this.margin.bottom + this.border.bottom + this.padding.bottom; 52 | }; 53 | 54 | Box.prototype.leftWidth = function() { 55 | return this.margin.left + this.border.left + this.padding.left; 56 | }; 57 | 58 | Box.prototype.innerWidth = function() { 59 | return this.padding.left + this.dimensions.width + this.padding.right; 60 | }; 61 | 62 | Box.prototype.innerHeight = function() { 63 | return this.padding.top + this.dimensions.height + this.padding.bottom; 64 | }; 65 | 66 | Box.prototype.outerWidth = function() { 67 | return this.border.left + this.innerWidth() + this.border.right; 68 | }; 69 | 70 | Box.prototype.outerHeight = function() { 71 | return this.border.top + this.innerHeight() + this.border.bottom; 72 | }; 73 | 74 | Box.prototype.width = function() { 75 | return this.margin.left + this.outerWidth() + this.margin.right; 76 | }; 77 | 78 | Box.prototype.height = function() { 79 | return this.margin.top + this.outerHeight() + this.margin.bottom; 80 | }; 81 | 82 | Box.prototype.translate = function(dx, dy) { 83 | this.position.x += dx; 84 | this.position.y += dy; 85 | }; 86 | 87 | Box.prototype.styledBorderWidth = function(direction) { 88 | var borderWidth = this.style['border-' + direction + '-width']; 89 | var borderStyle = this.style['border-' + direction + '-style']; 90 | 91 | return None.is(borderStyle) ? Length.px(0) : borderWidth; 92 | }; 93 | 94 | module.exports = Box; 95 | -------------------------------------------------------------------------------- /source/layout/image-box.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | 3 | var Box = require('./box'); 4 | var ParentBox = require('./parent-box'); 5 | var values = require('../css/values'); 6 | 7 | var Auto = values.Keyword.Auto; 8 | var Length = values.Length; 9 | var Percentage = values.Percentage; 10 | 11 | var ImageBox = function(parent, style, image) { 12 | Box.call(this, style); 13 | 14 | this.parent = parent; 15 | this.image = image; 16 | }; 17 | 18 | util.inherits(ImageBox, Box); 19 | 20 | ImageBox.prototype.layout = function() { 21 | var style = this.style; 22 | var image = this.image; 23 | var width = style.width; 24 | var height = style.height; 25 | var ratio = image.width / image.height; 26 | 27 | var isWidthAuto = Auto.is(width); 28 | var isHeightAuto = Auto.is(height); 29 | 30 | if(isWidthAuto && isHeightAuto) { 31 | width = Length.px(image.width); 32 | height = Length.px(image.height); 33 | } else if(isWidthAuto) { 34 | width = Length.px(this.toPx(height) * ratio); 35 | } else if(isHeightAuto) { 36 | height = Length.px(this.toPx(width) / ratio); 37 | } 38 | 39 | this.dimensions.width = this.toPx(width); 40 | this.dimensions.height = this.toPx(height); 41 | 42 | this.margin.top = this.toPx(style['margin-top']); 43 | this.margin.bottom = this.toPx(style['margin-bottom']); 44 | 45 | this.border.left = this.toPx(this.styledBorderWidth('left')); 46 | this.border.right = this.toPx(this.styledBorderWidth('right')); 47 | this.border.top = this.toPx(this.styledBorderWidth('top')); 48 | this.border.bottom = this.toPx(this.styledBorderWidth('bottom')); 49 | 50 | this.padding.left = this.toPx(style['padding-left']); 51 | this.padding.right = this.toPx(style['padding-right']); 52 | this.padding.top = this.toPx(style['padding-top']); 53 | this.padding.bottom = this.toPx(style['padding-bottom']); 54 | }; 55 | 56 | ImageBox.prototype.collapseWhitespace = function() { 57 | return false; 58 | }; 59 | 60 | ImageBox.prototype.hasContent = function() { 61 | return true; 62 | }; 63 | 64 | ImageBox.prototype.clone = function(parent) { 65 | var clone = new this.constructor(parent, this.style, this.image); 66 | if(parent) parent.children.push(clone); 67 | 68 | return clone; 69 | }; 70 | 71 | ImageBox.prototype.cloneWithLinks = ParentBox.prototype.cloneWithLinks; 72 | ImageBox.prototype.addLink = ParentBox.prototype.addLink; 73 | ImageBox.prototype.toPx = ParentBox.prototype.toPx; 74 | 75 | var InlineImageBox = function(parent, style, image) { 76 | ImageBox.call(this, parent, style, image); 77 | this.baseline = 0; 78 | }; 79 | 80 | util.inherits(InlineImageBox, ImageBox); 81 | 82 | InlineImageBox.prototype.layout = function(offset, line) { 83 | ImageBox.prototype.layout.call(this); 84 | 85 | var style = this.style; 86 | 87 | this.margin.left = this.toPx(style['margin-left']); 88 | this.margin.right = this.toPx(style['margin-right']); 89 | 90 | var parent = this.parent; 91 | var x = parent.position.x + offset.width + this.leftWidth(); 92 | var available = line.position.x + line.dimensions.width - x; 93 | 94 | if(this.width() > available && !this._isFirst(line)) { 95 | this._reset(); 96 | return parent.addLine(this); 97 | } 98 | 99 | this._layoutBaseline(); 100 | 101 | this.position.x = x; 102 | this.position.y = this.baseline - this.dimensions.height - this.bottomWidth(); 103 | }; 104 | 105 | InlineImageBox.prototype.linePosition = function() { 106 | return { 107 | x: this.position.x - this.leftWidth(), 108 | y: this.position.y - this.topWidth() 109 | }; 110 | }; 111 | 112 | InlineImageBox.prototype.lineHeight = function() { 113 | return this.height(); 114 | }; 115 | 116 | InlineImageBox.prototype._layoutBaseline = function() { 117 | var parent = this.parent; 118 | var style = this.style; 119 | var alignment = this.style['vertical-align']; 120 | 121 | if(Length.is(alignment)) { 122 | this.baseline = parent.baseline - alignment.length; 123 | } else if(Percentage.is(alignment)) { 124 | var size = this.toPx(style['font-size']); 125 | var lineHeight = style['line-height']; 126 | 127 | var lh = values.Number.is(lineHeight) ? 128 | lineHeight.number * size : this.toPx(lineHeight); 129 | 130 | this.baseline = parent.baseline - (alignment.percentage * lh / 100); 131 | } else { 132 | this.baseline = parent.baseline; 133 | } 134 | }; 135 | 136 | InlineImageBox.prototype._reset = function() { 137 | this.padding.reset(); 138 | this.border.reset(); 139 | this.margin.reset(); 140 | 141 | this.baseline = 0; 142 | this.dimensions.width = 0; 143 | this.dimensions.height = 0; 144 | }; 145 | 146 | InlineImageBox.prototype._isFirst = function(line) { 147 | return line.contents().indexOf(this) === 0; 148 | }; 149 | 150 | var BlockImageBox = function(parent, style, image) { 151 | ImageBox.call(this, parent, style, image); 152 | }; 153 | 154 | util.inherits(BlockImageBox, ImageBox); 155 | 156 | BlockImageBox.prototype.layout = function(offset) { 157 | ImageBox.prototype.layout.call(this); 158 | 159 | this._layoutWidth(); 160 | this._layoutPosition(offset); 161 | }; 162 | 163 | BlockImageBox.prototype._layoutWidth = function() { 164 | var self = this; 165 | var style = this.style; 166 | var parent = this.parent; 167 | 168 | var marginLeft = style['margin-left']; 169 | var marginRight = style['margin-right']; 170 | 171 | var total = [ 172 | this.dimensions.width, 173 | this.padding.left, 174 | this.padding.right, 175 | this.border.left, 176 | this.border.right, 177 | marginLeft, 178 | marginRight 179 | ].reduce(function(acc, v) { 180 | return acc + (typeof v === 'number' ? v : self.toPx(v)); 181 | }, 0); 182 | 183 | var underflow = parent.dimensions.width - total; 184 | 185 | if(underflow < 0) { 186 | if(Auto.is(marginLeft)) marginLeft = Length.px(0); 187 | if(Auto.is(marginRight)) marginRight = Length.px(0); 188 | } 189 | 190 | var isMarginLeftAuto = Auto.is(marginLeft); 191 | var isMarginRightAuto = Auto.is(marginRight); 192 | 193 | if(!isMarginLeftAuto && !isMarginRightAuto) { 194 | var margin = this.toPx(marginRight); 195 | marginRight = Length.px(margin + underflow); 196 | } else if(!isMarginLeftAuto && isMarginRightAuto) { 197 | marginRight = Length.px(underflow); 198 | } else if(isMarginLeftAuto && !isMarginRightAuto) { 199 | marginLeft = Length.px(underflow); 200 | } else { 201 | marginLeft = Length.px(underflow / 2); 202 | marginRight = Length.px(underflow / 2); 203 | } 204 | 205 | this.margin.left = this.toPx(marginLeft); 206 | this.margin.right = this.toPx(marginRight); 207 | }; 208 | 209 | BlockImageBox.prototype._layoutPosition = function(offset) { 210 | var parent = this.parent; 211 | 212 | this.position.x = parent.position.x + this.leftWidth(); 213 | this.position.y = parent.position.y + offset.height + this.topWidth(); 214 | }; 215 | 216 | ImageBox.Inline = InlineImageBox; 217 | ImageBox.Block = BlockImageBox; 218 | 219 | module.exports = ImageBox; 220 | -------------------------------------------------------------------------------- /source/layout/inline-box.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var textHeight = require('text-height'); 3 | 4 | var ParentBox = require('./parent-box'); 5 | var values = require('../css/values'); 6 | 7 | var Length = values.Length; 8 | var Percentage = values.Percentage; 9 | 10 | var InlineBox = function(parent, style) { 11 | ParentBox.call(this, parent, style); 12 | this.baseline = 0; 13 | }; 14 | 15 | util.inherits(InlineBox, ParentBox); 16 | 17 | InlineBox.prototype.layout = function(offset, line) { 18 | this._layoutWidth(); 19 | this._layoutBaseline(); 20 | this._layoutPosition(offset); 21 | this._layoutHeight(); 22 | this._layoutChildren(line); 23 | this._layoutWidth(); 24 | }; 25 | 26 | InlineBox.prototype.linePosition = function() { 27 | var lineHeight = this.lineHeight(); 28 | var size = this.toPx(this.style['font-size']); 29 | var leading = (lineHeight - size) / 2; 30 | 31 | return { 32 | x: this.position.x, 33 | y: this.position.y - leading 34 | }; 35 | }; 36 | 37 | InlineBox.prototype.lineHeight = function() { 38 | var style = this.style; 39 | var size = this.toPx(style['font-size']); 40 | var lineHeight = style['line-height']; 41 | 42 | return values.Number.is(lineHeight) ? 43 | lineHeight.number * size : this.toPx(lineHeight); 44 | }; 45 | 46 | InlineBox.prototype._layoutWidth = function() { 47 | var self = this; 48 | var style = this.style; 49 | 50 | var iif = function(direction, value) { 51 | return self[direction + 'Link'] ? 0 : self.toPx(value); 52 | }; 53 | 54 | this.margin.left = iif('left', style['margin-left']); 55 | this.border.left = iif('left', this.styledBorderWidth('left')); 56 | this.padding.left = iif('left', style['padding-left']); 57 | 58 | this.margin.right = iif('right', style['margin-right']); 59 | this.border.right = iif('right', this.styledBorderWidth('right')); 60 | this.padding.right = iif('right', style['padding-right']); 61 | }; 62 | 63 | InlineBox.prototype._layoutPosition = function(offset) { 64 | var parent = this.parent; 65 | var style = this.style; 66 | var size = this.toPx(style['font-size']); 67 | 68 | this.border.top = this.toPx(this.styledBorderWidth('top')); 69 | this.border.bottom = this.toPx(this.styledBorderWidth('bottom')); 70 | 71 | this.padding.top = this.toPx(style['padding-top']); 72 | this.padding.bottom = this.toPx(style['padding-bottom']); 73 | 74 | this.position.x = parent.position.x + offset.width + this.leftWidth(); 75 | this.position.y = this.baseline - size + this._textHeight().descent; 76 | }; 77 | 78 | InlineBox.prototype._layoutChildren = function(line) { 79 | var offset = { width: 0, height: 0 }; 80 | 81 | this.forEach(function(child) { 82 | child.layout(offset, line); 83 | offset.width += child.width(); 84 | }); 85 | 86 | this.dimensions.width = offset.width; 87 | }; 88 | 89 | InlineBox.prototype._layoutHeight = function() { 90 | this.dimensions.height = this.toPx(this.style['font-size']); 91 | }; 92 | 93 | InlineBox.prototype._layoutBaseline = function() { 94 | var parent = this.parent; 95 | var alignment = this.style['vertical-align']; 96 | 97 | if(Length.is(alignment)) { 98 | this.baseline = parent.baseline - alignment.length; 99 | } else if(Percentage.is(alignment)) { 100 | this.baseline = parent.baseline - (alignment.percentage * this.lineHeight() / 100); 101 | } else { 102 | this.baseline = parent.baseline; 103 | } 104 | }; 105 | 106 | InlineBox.prototype._textHeight = function() { 107 | var style = this.style; 108 | return textHeight({ 109 | size: style['font-size'].toString(), 110 | family: style['font-family'].toString(), 111 | weight: style['font-weight'].keyword, 112 | style: style['font-style'].keyword 113 | }); 114 | }; 115 | 116 | module.exports = InlineBox; 117 | -------------------------------------------------------------------------------- /source/layout/line-box.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var textHeight = require('text-height'); 3 | 4 | var ParentBox = require('./parent-box'); 5 | var TextBox = require('./text-box'); 6 | var ImageBox = require('./image-box'); 7 | var LineBreakBox = require('./line-break-box'); 8 | var values = require('../css/values'); 9 | 10 | var LineBox = function(parent, style) { 11 | ParentBox.call(this, parent, style); 12 | this.baseline = 0; 13 | }; 14 | 15 | util.inherits(LineBox, ParentBox); 16 | 17 | LineBox.prototype.flatten = function() { 18 | var descedants = []; 19 | var flatten = function(parent) { 20 | descedants.push(parent); 21 | if(parent.children) parent.children.forEach(flatten); 22 | }; 23 | 24 | this.children.forEach(flatten); 25 | return descedants; 26 | }; 27 | 28 | LineBox.prototype.contents = function(breaks) { 29 | return this.flatten().filter(function(box) { 30 | return box instanceof TextBox || 31 | box instanceof ImageBox || 32 | (breaks && box instanceof LineBreakBox); 33 | }); 34 | }; 35 | 36 | LineBox.prototype.addLine = function(child, branch) { 37 | ParentBox.prototype.addLine.call(this, child, branch, true); 38 | }; 39 | 40 | LineBox.prototype.layout = function(offset) { 41 | var parent = this.parent; 42 | 43 | this.dimensions.width = parent.dimensions.width; 44 | 45 | this.position.x = parent.position.x; 46 | this.position.y = parent.position.y + offset.height; 47 | 48 | this._layoutStrut(); 49 | this._layoutChildren(); 50 | this._layoutHeight(); 51 | }; 52 | 53 | LineBox.prototype.collapseWhitespace = function() { 54 | return ParentBox.prototype.collapseWhitespace.call(this, false); 55 | }; 56 | 57 | LineBox.prototype._layoutStrut = function() { 58 | var style = this.style; 59 | var size = this.toPx(style['font-size']); 60 | var height = textHeight({ 61 | size: style['font-size'].toString(), 62 | family: style['font-family'].toString(), 63 | weight: style['font-weight'].keyword, 64 | style: style['font-style'].keyword 65 | }); 66 | 67 | this.baseline = this.position.y + size - height.descent; 68 | }; 69 | 70 | LineBox.prototype._layoutChildren = function() { 71 | var self = this; 72 | var offset = { width: 0, height: 0 }; 73 | 74 | this.forEach(function(child) { 75 | child.layout(offset, self); 76 | offset.width += child.width(); 77 | }); 78 | }; 79 | 80 | LineBox.prototype._layoutHeight = function() { 81 | if(!this.hasContent()) return; 82 | 83 | var minY = this._linePosition(); 84 | var maxY = minY + this._lineHeight(); 85 | 86 | var height = function(parent) { 87 | var position = parent.linePosition(); 88 | 89 | minY = Math.min(minY, position.y); 90 | maxY = Math.max(maxY, position.y + parent.lineHeight()); 91 | if(parent.children) parent.children.forEach(height); 92 | }; 93 | 94 | this.children.forEach(height); 95 | this.dimensions.height = maxY - minY; 96 | this.translateChildren(0, this.position.y - minY); 97 | }; 98 | 99 | LineBox.prototype._linePosition = function() { 100 | var lineHeight = this._lineHeight(); 101 | var size = this.toPx(this.style['font-size']); 102 | var leading = (lineHeight - size) / 2; 103 | 104 | return this.position.y - leading; 105 | }; 106 | 107 | LineBox.prototype._lineHeight = function() { 108 | var style = this.style; 109 | var size = this.toPx(style['font-size']); 110 | var lineHeight = style['line-height']; 111 | 112 | return values.Number.is(lineHeight) ? 113 | lineHeight.number * size : this.toPx(lineHeight); 114 | }; 115 | 116 | module.exports = LineBox; 117 | -------------------------------------------------------------------------------- /source/layout/line-break-box.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | 3 | var Box = require('./box'); 4 | var ParentBox = require('./parent-box'); 5 | 6 | var LineBreakBox = function(parent, style) { 7 | Box.call(this, style); 8 | this.parent = parent; 9 | 10 | this.leftLink = false; 11 | this.rightLink = false; 12 | }; 13 | 14 | util.inherits(LineBreakBox, Box); 15 | 16 | LineBreakBox.prototype.layout = function(offset, line) { 17 | var parent = this.parent; 18 | 19 | this.position.x = parent.position.x + offset.width; 20 | this.position.y = parent.position.y; 21 | 22 | parent.breakLine(this); 23 | }; 24 | 25 | LineBreakBox.prototype.collapseWhitespace = function() { 26 | return false; 27 | }; 28 | 29 | LineBreakBox.prototype.hasContent = function() { 30 | return true; 31 | }; 32 | 33 | LineBreakBox.prototype.linePosition = function() { 34 | return this.position; 35 | }; 36 | 37 | LineBreakBox.prototype.lineHeight = function() { 38 | return 0; 39 | }; 40 | 41 | LineBreakBox.prototype.isPx = ParentBox.prototype.isPx; 42 | LineBreakBox.prototype.clone = ParentBox.prototype.clone; 43 | LineBreakBox.prototype.cloneWithLinks = ParentBox.prototype.cloneWithLinks; 44 | 45 | module.exports = LineBreakBox; 46 | -------------------------------------------------------------------------------- /source/layout/parent-box.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | 3 | var Box = require('./box'); 4 | var compute = require('../css/compute'); 5 | var values = require('../css/values'); 6 | 7 | var Auto = values.Keyword.Auto; 8 | var Percentage = values.Percentage; 9 | 10 | var ParentBox = function(parent, style) { 11 | Box.call(this, style); 12 | 13 | this.style = style || compute({}, parent.style); 14 | this.parent = parent; 15 | this.children = []; 16 | 17 | this.leftLink = false; 18 | this.rightLink = false; 19 | }; 20 | 21 | util.inherits(ParentBox, Box); 22 | 23 | ParentBox.prototype.layout = function() {}; 24 | 25 | ParentBox.prototype.addLink = function(box) { 26 | box.leftLink = true; 27 | box.rightLink = this.rightLink; 28 | 29 | this.rightLink = true; 30 | }; 31 | 32 | ParentBox.prototype.addLine = function(child, branch, force) { 33 | this.stopEach(); 34 | 35 | var parent = this.parent; 36 | var i = this.children.indexOf(child); 37 | if(i === 0 && !branch && !force) return parent.addLine(this); 38 | 39 | var children = this.children.slice(); 40 | var box = this.clone(); 41 | 42 | if(branch) box.attach(branch); 43 | else box.attach(child); 44 | 45 | for(var j = i + 1; j < children.length; j++) { 46 | box.attach(children[j]); 47 | } 48 | 49 | this.addLink(box); 50 | parent.addLine(this, box); 51 | }; 52 | 53 | ParentBox.prototype.breakLine = function(child) { 54 | var children = this.children.slice(); 55 | var box = this.clone(); 56 | var i = children.indexOf(child); 57 | 58 | for(var j = i + 1; j < children.length; j++) { 59 | box.attach(children[j]); 60 | } 61 | 62 | this.addLink(box); 63 | this.parent.addLine(this, box); 64 | }; 65 | 66 | ParentBox.prototype.hasContent = function() { 67 | var hasOutline = this.padding.some() || 68 | this.border.some() || 69 | this.margin.some(); 70 | 71 | return hasOutline || this.children.some(function(child) { 72 | return child.hasContent(); 73 | }); 74 | }; 75 | 76 | ParentBox.prototype.collapseWhitespace = function(strip) { 77 | this.children.forEach(function(child) { 78 | strip = child.collapseWhitespace(strip); 79 | }); 80 | 81 | return strip; 82 | }; 83 | 84 | ParentBox.prototype.attach = function(node, i) { 85 | if(node.parent) node.parent.detach(node); 86 | 87 | node.parent = this; 88 | 89 | if(i !== undefined) this.children.splice(i, 0, node); 90 | else this.children.push(node); 91 | }; 92 | 93 | ParentBox.prototype.detach = function(node) { 94 | var children = this.children; 95 | var i = children.indexOf(node); 96 | 97 | if(i < 0) return; 98 | 99 | node.parent = null; 100 | children.splice(i, 1); 101 | }; 102 | 103 | ParentBox.prototype.clone = function(parent) { 104 | var clone = new this.constructor(parent, this.style); 105 | if(parent) parent.children.push(clone); 106 | 107 | return clone; 108 | }; 109 | 110 | ParentBox.prototype.cloneWithLinks = function(parent) { 111 | var clone = this.clone(parent); 112 | clone.leftLink = this.leftLink; 113 | clone.rightLink = this.rightLink; 114 | 115 | return clone; 116 | }; 117 | 118 | ParentBox.prototype.forEach = function(fn) { 119 | var children = this.children; 120 | var stop = false; 121 | 122 | this._stop = function() { 123 | stop = true; 124 | }; 125 | 126 | for(var i = 0; i < children.length && !stop; i++) { 127 | fn(children[i], i); 128 | } 129 | }; 130 | 131 | ParentBox.prototype.stopEach = function() { 132 | if(this._stop) this._stop(); 133 | }; 134 | 135 | ParentBox.prototype.translate = function(dx, dy) { 136 | Box.prototype.translate.call(this, dx, dy); 137 | this.translateChildren(dx, dy); 138 | }; 139 | 140 | ParentBox.prototype.translateChildren = function(dx, dy) { 141 | this.children.forEach(function(child) { 142 | child.translate(dx, dy); 143 | }); 144 | }; 145 | 146 | ParentBox.prototype.visibleWidth = function() { 147 | var min = function(box) { 148 | return box.position.x - box.leftWidth(); 149 | }; 150 | 151 | var max = function(box) { 152 | return box.position.x + box.dimensions.width + box.rightWidth(); 153 | }; 154 | 155 | var minX = min(this); 156 | var maxX = max(this); 157 | 158 | var height = function(parent) { 159 | minX = Math.min(minX, min(parent)); 160 | maxX = Math.max(maxX, max(parent)); 161 | 162 | if(parent.children) parent.children.forEach(height); 163 | }; 164 | 165 | this.children.forEach(height); 166 | return maxX - minX; 167 | }; 168 | 169 | ParentBox.prototype.visibleHeight = function() { 170 | var min = function(box) { 171 | return box.position.y - box.topWidth(); 172 | }; 173 | 174 | var max = function(box) { 175 | return box.position.y + box.dimensions.height + box.bottomWidth(); 176 | }; 177 | 178 | var minY = min(this); 179 | var maxY = max(this); 180 | 181 | var height = function(parent) { 182 | minY = Math.min(minY, min(parent)); 183 | maxY = Math.max(maxY, max(parent)); 184 | 185 | if(parent.children) parent.children.forEach(height); 186 | }; 187 | 188 | this.children.forEach(height); 189 | return maxY - minY; 190 | }; 191 | 192 | ParentBox.prototype.toPx = function(value) { 193 | if(Auto.is(value)) return 0; 194 | if(Percentage.is(value)) { 195 | var width = this.parent.dimensions.width; 196 | return width * value.percentage / 100; 197 | } 198 | 199 | return value.length; 200 | }; 201 | 202 | module.exports = ParentBox; 203 | -------------------------------------------------------------------------------- /source/layout/text-box.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var textWidth = require('text-width'); 3 | var he = require('he'); 4 | 5 | var values = require('../css/values'); 6 | var collapse = require('./whitespace/collapse'); 7 | var breaks = require('./whitespace/breaks'); 8 | var Box = require('./box'); 9 | var ParentBox = require('./parent-box'); 10 | var Viewport = require('./viewport'); 11 | 12 | var Normal = values.Keyword.Normal; 13 | var Nowrap = values.Keyword.Nowrap; 14 | var PreLine = values.Keyword.PreLine; 15 | var PreWrap = values.Keyword.PreWrap; 16 | 17 | var NEWLINE = '\n'; 18 | var TAB = ' '; 19 | 20 | var isBreakable = function(box) { 21 | var format = box.style['white-space']; 22 | return Normal.is(format) || PreWrap.is(format) || PreLine.is(format); 23 | }; 24 | 25 | var TextString = function(str, style) { 26 | this.original = str; 27 | this.style = style; 28 | 29 | this.normalized = he.decode(str).replace(/\t/g, TAB); 30 | }; 31 | 32 | TextString.prototype.trimLeft = function() { 33 | this.normalized = this.normalized.replace(/^ /, ''); 34 | }; 35 | 36 | TextString.prototype.trimRight = function() { 37 | this.normalized = this.normalized.replace(/ $/, ''); 38 | }; 39 | 40 | TextString.prototype.append = function(str) { 41 | return new TextString(this.original + str, this.style); 42 | }; 43 | 44 | TextString.prototype.width = function() { 45 | var style = this.style; 46 | 47 | return textWidth(this.normalized, { 48 | size: style['font-size'].toString(), 49 | family: style['font-family'].toString(), 50 | weight: style['font-weight'].keyword, 51 | style: style['font-style'].keyword 52 | }); 53 | }; 54 | 55 | var TextBox = function(styleOrParent, text) { 56 | var isParent = styleOrParent instanceof ParentBox || styleOrParent instanceof Viewport; 57 | var parent = isParent ? styleOrParent : null; 58 | var style = isParent ? styleOrParent.style : styleOrParent; 59 | 60 | text = text || ''; 61 | 62 | Box.call(this, style); 63 | this.parent = parent; 64 | this.text = text; 65 | this.display = text; 66 | 67 | this.leftLink = false; 68 | this.rightLink = false; 69 | this.preservedNewline = false; 70 | }; 71 | 72 | util.inherits(TextBox, Box); 73 | 74 | TextBox.prototype.layout = function(offset, line) { 75 | var parent = this.parent; 76 | var style = this.style; 77 | var format = style['white-space'].keyword; 78 | var textContext = this._textContext(line); 79 | var lines = breaks.hard(this.text, format); 80 | 81 | var textString = function(t) { 82 | return new TextString(t || '', style); 83 | }; 84 | 85 | var text = textString(lines[0]); 86 | var isCollapsible = this._isCollapsible(); 87 | var isBreakable = this._isBreakable() || textContext.precededByBreakable; 88 | var isMultiline = lines.length > 1; 89 | var isTrimable = isCollapsible && textContext.precededByEmpty; 90 | 91 | if(isTrimable) text.trimLeft(); 92 | if(isCollapsible && (textContext.followedByEmpty || isMultiline)) text.trimRight(); 93 | 94 | var x = parent.position.x + offset.width; 95 | var available = line.position.x + line.dimensions.width - x; 96 | var rest; 97 | 98 | if(isBreakable && available < 0 && !textContext.first) { 99 | rest = this.text; 100 | text = textString(); 101 | } else if(isBreakable && text.width() > available) { 102 | var i = 0; 103 | var words = breaks.soft(text.original, format); 104 | var fillCurrent, fillNext = textString(words[i]); 105 | 106 | if(isTrimable) fillNext.trimLeft(); 107 | 108 | while(fillNext.width() <= available && i++ < words.length) { 109 | fillCurrent = fillNext; 110 | fillNext = fillNext.append(words[i]); 111 | if(isTrimable) fillNext.trimLeft(); 112 | } 113 | 114 | fillCurrent = fillCurrent || textString(); 115 | 116 | if(!fillCurrent.width() && textContext.first) { 117 | i = 0; 118 | 119 | do { 120 | fillCurrent = fillCurrent.append(words[i]); 121 | if(isTrimable) fillCurrent.trimLeft(); 122 | } while(!fillCurrent.width() && i++ < words.length); 123 | } 124 | 125 | if(isCollapsible) fillCurrent.trimRight(); 126 | 127 | var newline = fillCurrent.original === text.original ? 1 : 0; 128 | 129 | rest = this.text.slice(fillCurrent.original.length + newline); 130 | text = fillCurrent; 131 | } else { 132 | rest = this.text.slice(text.original.length + 1); 133 | } 134 | 135 | if(this.text.charAt(text.length) === NEWLINE) this.preservedNewline = true; 136 | 137 | if(rest || isMultiline) { 138 | var textBox = rest === this.text ? null : new TextBox(style, rest); 139 | parent.addLine(this, textBox); 140 | if(!textBox) return; 141 | } 142 | 143 | this.dimensions.height = this.toPx(style['font-size']); 144 | this.dimensions.width = text.width(); 145 | 146 | this.position.x = x; 147 | this.position.y = parent.position.y; 148 | 149 | this.display = text.normalized; 150 | }; 151 | 152 | TextBox.prototype.endsWithCollapsibleWhitespace = function() { 153 | var text = collapse(this.text, { format: this.style['white-space'].keyword }); 154 | return / $/.test(text) && this._isCollapsible(); 155 | }; 156 | 157 | TextBox.prototype.collapseWhitespace = function(strip) { 158 | var wh = this.endsWithCollapsibleWhitespace(); 159 | var text = collapse(this.text, { 160 | format: this.style['white-space'].keyword, 161 | strip: strip 162 | }); 163 | 164 | this.text = text; 165 | return wh; 166 | }; 167 | 168 | TextBox.prototype.hasContent = function() { 169 | return this._isCollapsible() ? !this._isWhitespace() : (this.preservedNewline || !!this.dimensions.width); 170 | }; 171 | 172 | TextBox.prototype.linePosition = function() { 173 | return this.position; 174 | }; 175 | 176 | TextBox.prototype.lineHeight = function() { 177 | return this.dimensions.height; 178 | }; 179 | 180 | TextBox.prototype.clone = function(parent) { 181 | var clone = new TextBox(parent, this.text); 182 | parent.children.push(clone); 183 | 184 | return clone; 185 | }; 186 | 187 | TextBox.prototype.cloneWithLinks = ParentBox.prototype.cloneWithLinks; 188 | TextBox.prototype.addLink = ParentBox.prototype.addLink; 189 | TextBox.prototype.toPx = ParentBox.prototype.toPx; 190 | 191 | TextBox.prototype._isCollapsible = function() { 192 | var format = this.style['white-space']; 193 | return Normal.is(format) || Nowrap.is(format) || PreLine.is(format); 194 | }; 195 | 196 | TextBox.prototype._isBreakable = function() { 197 | return isBreakable(this); 198 | }; 199 | 200 | TextBox.prototype._isWhitespace = function() { 201 | return /^[\t\n\r ]*$/.test(this.text); 202 | }; 203 | 204 | TextBox.prototype._textContext = function(line) { 205 | var contents = line.contents(); 206 | var i = contents.indexOf(this); 207 | var precededByBreakable = false; 208 | var precededByEmpty = true; 209 | var followedByEmpty = true; 210 | 211 | for(var j = 0; j < contents.length; j++) { 212 | var empty = !contents[j].hasContent(); 213 | if(j < i) precededByBreakable = precededByBreakable || isBreakable(contents[j]); 214 | if(j < i) precededByEmpty = precededByEmpty && empty; 215 | if(j > i) followedByEmpty = followedByEmpty && empty; 216 | } 217 | 218 | return { 219 | first: i === 0, 220 | last: i === (contents.length - 1), 221 | precededByBreakable: precededByBreakable, 222 | precededByEmpty: precededByEmpty, 223 | followedByEmpty: followedByEmpty 224 | }; 225 | }; 226 | 227 | module.exports = TextBox; 228 | -------------------------------------------------------------------------------- /source/layout/viewport.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | 3 | var values = require('../css/values'); 4 | var compute = require('../css/compute'); 5 | var Box = require('./box'); 6 | var ParentBox = require('./parent-box'); 7 | var BlockBox = require('./block-box'); 8 | 9 | var Length = values.Length; 10 | var Auto = values.Keyword.Auto; 11 | 12 | var Viewport = function(position, dimensions) { 13 | var height = (typeof dimensions.height === 'number') ? 14 | Length.px(dimensions.height) : Auto; 15 | 16 | Box.call(this, compute({ 17 | width: Length.px(dimensions.width), 18 | height: height 19 | })); 20 | 21 | this.position = position; 22 | this.dimensions = dimensions; 23 | this.children = []; 24 | }; 25 | 26 | util.inherits(Viewport, Box); 27 | 28 | Viewport.prototype.clone = function() { 29 | return new Viewport(this.position, this.dimensions); 30 | }; 31 | 32 | Viewport.prototype.layout = function() { 33 | var offset = { width: 0, height: 0 }; 34 | 35 | this.collapseWhitespace(false); 36 | 37 | this.children.forEach(function(child) { 38 | child.layout(offset); 39 | offset.height += child.height(); 40 | }); 41 | 42 | var dimensions = this.dimensions; 43 | if(typeof dimensions.height !== 'number') dimensions.height = offset.height; 44 | }; 45 | 46 | Viewport.prototype.attach = ParentBox.prototype.attach; 47 | Viewport.prototype.detach = ParentBox.prototype.detach; 48 | Viewport.prototype.collapseWhitespace = ParentBox.prototype.collapseWhitespace; 49 | Viewport.prototype.addLink = ParentBox.prototype.addLink; 50 | Viewport.prototype.visibleWidth = ParentBox.prototype.visibleWidth; 51 | Viewport.prototype.visibleHeight = ParentBox.prototype.visibleHeight; 52 | Viewport.prototype.addLine = BlockBox.prototype.addLine; 53 | 54 | module.exports = Viewport; 55 | -------------------------------------------------------------------------------- /source/layout/whitespace/breaks.js: -------------------------------------------------------------------------------- 1 | exports.hard = function(str, format) { 2 | var hard = format === 'pre' || format === 'pre-wrap' || format === 'pre-line'; 3 | return hard ? str.split('\n') : [str]; 4 | }; 5 | 6 | exports.soft = function(str, format) { 7 | var soft = format === 'normal' || format === 'pre-wrap' || format === 'pre-line'; 8 | return soft ? str.split(/( +|-+)/) : [str]; 9 | }; 10 | -------------------------------------------------------------------------------- /source/layout/whitespace/collapse.js: -------------------------------------------------------------------------------- 1 | module.exports = function(str, options) { 2 | options = options || {}; 3 | 4 | var format = options.format || 'normal'; 5 | var strip = options.strip; 6 | 7 | str = str.replace(/\r\n?/g, '\n'); 8 | 9 | if(format === 'normal' || format === 'nowrap' || format === 'pre-line') { 10 | str = str.replace(/[\t ]*\n[\t ]*/g, '\n'); 11 | } 12 | if(format === 'normal' || format === 'nowrap') { 13 | str = str.replace(/\n/g, ' '); 14 | } 15 | if(format === 'normal' || format === 'nowrap' || format === 'pre-line') { 16 | str = str.replace(/\t/g, ' '); 17 | str = str.replace(/ +/g, ' '); 18 | 19 | if(strip) str = str.replace(/^ /, ''); 20 | } 21 | 22 | return str; 23 | }; 24 | -------------------------------------------------------------------------------- /source/stylesheets.js: -------------------------------------------------------------------------------- 1 | var url = require('url'); 2 | 3 | var ElementType = require('domelementtype'); 4 | var afterAll = require('after-all'); 5 | var xhr = require('xhr'); 6 | var he = require('he'); 7 | 8 | var Stylesheet = require('./css/stylesheet'); 9 | 10 | var link = function(base, node, i, callback) { 11 | var href = node.attribs.href; 12 | if(!href) return callback(null, Stylesheet.empty(i)); 13 | 14 | href = he.decode(href); 15 | href = url.resolve(base, href); 16 | 17 | xhr({ 18 | method: 'GET', 19 | url: href 20 | }, function(err, response, body) { 21 | var errored = err || !/2\d\d/.test(response.statusCode); 22 | var stylesheet = errored ? Stylesheet.empty(i) : Stylesheet.parse(body, i); 23 | 24 | callback(null, stylesheet); 25 | }); 26 | }; 27 | 28 | var style = function(node, i) { 29 | var text = node.childNodes && node.childNodes[0]; 30 | if(!text || text.type !== ElementType.Text) return Stylesheet.empty(i); 31 | 32 | return Stylesheet.parse(text.data, i); 33 | }; 34 | 35 | module.exports = function(base, nodes, callback) { 36 | if(!nodes.length) return callback(null, []); 37 | 38 | var stylesheets = new Array(nodes.length); 39 | var next = afterAll(function(err) { 40 | callback(err, stylesheets); 41 | }); 42 | 43 | nodes.forEach(function(node, i) { 44 | var cb = next(function(err, stylesheet) { 45 | node.stylesheet = stylesheet; 46 | stylesheets[i] = stylesheet; 47 | }); 48 | 49 | if(node.name === 'link') link(base, node, i, cb); 50 | else cb(null, style(node, i)); 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /test/assets/block-image.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 39 | 40 | 41 | 42 | Lorem ipsum dolor sit amet 43 | 44 | consectetur adipiscing elit. 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /test/assets/block-in-inline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 27 | 28 | 29 | 30 | H 31 |
e
32 | l 33 |
l
34 | o 35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /test/assets/br.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 27 | 28 | 29 |
30 | 31 | Lorem ipsum dolor sit amet,
32 | consectetur adipiscing elit. 33 |
34 |
35 | 36 | Donec ac convallis nisi.
37 | Mauris fermentum est nec placerat tincidunt. 38 |
39 |
40 | 41 | Nunc non risus gravida, pharetra felis et,
42 | scelerisque lacus. 43 |
44 |
45 |
46 | 47 | Suspendisse lacinia sit amet est id ornare. 48 | 49 |
50 |
51 |
52 | 53 | Quisque sit amet lectus ornare,
54 | ullamcorper diam in,
55 | varius mi. 56 |
57 |
58 |
59 |
60 | 61 | 62 | -------------------------------------------------------------------------------- /test/assets/column-inline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 49 | 50 | 51 | 52 | H 53 | e 54 | l 55 | l 56 | o 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /test/assets/css/default.css: -------------------------------------------------------------------------------- 1 | * { 2 | display: inline; 3 | } 4 | 5 | html { 6 | display: block; 7 | font-size: 16px; 8 | font-family: "Times New Roman"; 9 | line-height: 1.2; 10 | } 11 | 12 | head, link, style, meta, title, script { 13 | display: none; 14 | } 15 | 16 | body { 17 | display: block; 18 | margin-top: 8px; 19 | margin-right: 8px; 20 | margin-bottom: 8px; 21 | margin-left: 8px; 22 | } 23 | 24 | div { 25 | display: block; 26 | } 27 | 28 | br { 29 | display: line-break; 30 | } 31 | -------------------------------------------------------------------------------- /test/assets/css/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | vertical-align: baseline; 25 | } 26 | /* HTML5 display-role reset for older browsers */ 27 | article, aside, details, figcaption, figure, 28 | footer, header, hgroup, menu, nav, section { 29 | display: block; 30 | } 31 | body { 32 | line-height: 1; 33 | } 34 | ol, ul { 35 | list-style: none; 36 | } 37 | blockquote, q { 38 | quotes: none; 39 | } 40 | table { 41 | border-collapse: collapse; 42 | border-spacing: 0; 43 | } -------------------------------------------------------------------------------- /test/assets/empty-inline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /test/assets/font-size.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 32 | 33 | 34 | 35 | Lorem 36 | ipsum 37 | dolor 38 | sit 39 | amet 40 | consectetur 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /test/assets/image.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/assets/images/waves.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kapetan/repaint/2b603fae5704891a4f0102ef9ea5f353827f3604/test/assets/images/waves.png -------------------------------------------------------------------------------- /test/assets/inline-image.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 38 | 39 | 40 | 41 | Lorem ipsum dolor sit amet 42 | 43 | consectetur adipiscing elit. 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /test/assets/line-height.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 45 | 46 | 47 | 48 | Lorem ipsum dolor sit amet consectetur
49 | Donec ac convallis nisi. Mauris fermentum est nec placerat tincidunt.
50 | Nunc non risus gravida, pharetra felis et, scelerisque lacus.
51 | Suspendisse lacinia sit amet est id ornare.
52 | Quisque sit amet lectus ornare,
53 | ullamcorper diam in, varius mi.
54 |
55 | 56 |
57 | Lorem ipsum dolor sit amet consectetur
58 | Donec ac convallis nisi. Mauris fermentum est nec placerat tincidunt.
59 |
60 | 61 | 62 | -------------------------------------------------------------------------------- /test/assets/mixed-white-space.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 29 | 30 | 31 |
32 | 33 | Lorem ipsum dolor sit amet, consectetur adipiscing elit.
34 |
35 | 36 | 37 | Nunc non risus gravida, pharetra felis et, scelerisque lacus.
38 |
39 | 40 | 41 | Suspendisse lacinia sit amet est id ornare. 42 | 43 |
44 | 45 |
46 | 47 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec ac convallis nisi. Mauris fermentum est nec placerat tincidunt. 48 |
49 | 50 | 51 | -------------------------------------------------------------------------------- /test/assets/multiline-font-size.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 32 | 33 | 34 | 35 | Lorem 36 | ipsum 37 | dolor 38 | sit 39 | amet 40 | consectetur 41 | 42 |
43 | 44 | Donec 45 | ac 46 | convallis 47 | nisi. 48 | Mauris 49 | fermentum 50 | 51 |
52 | 53 | Nunc 54 | non 55 | risus 56 | gravida, 57 | pharetra 58 | felis 59 | 60 |
61 | 62 | 63 | -------------------------------------------------------------------------------- /test/assets/multiline-image.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 40 | 41 | 42 | 43 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 44 | 45 | Donec ac convallis nisi. 46 | 47 | 48 |
49 | Mauris fermentum est nec placerat tincidunt. 50 |
51 | 52 | 53 | -------------------------------------------------------------------------------- /test/assets/multiline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 28 | 29 | 30 |
31 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 32 | Donec ac convallis nisi. Mauris fermentum est nec placerat tincidunt. 33 | Nunc non risus gravida, pharetra felis et, scelerisque lacus. 34 | Suspendisse lacinia sit amet est id ornare. 35 | Quisque sit amet lectus ornare, ullamcorper diam in, varius mi. 36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /test/assets/nested-block-in-inline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 27 | 28 | 29 | 30 | 31 |
H
32 |
33 |
e
34 | 35 |
l
36 |
37 |
l
38 | 39 |
o
40 |
41 |
42 | 43 | 44 | -------------------------------------------------------------------------------- /test/assets/nested-block.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 46 | 47 | 48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | 58 | 59 | -------------------------------------------------------------------------------- /test/assets/nested-inline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 46 | 47 | 48 | 49 | H 50 | 51 | e 52 | 53 | l 54 | 55 | l 56 | o 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /test/assets/padded-all.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 40 | 41 | 42 |
43 | 44 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 45 |
Donec ac convallis nisi.
46 | Mauris fermentum est nec placerat tincidunt. 47 |
48 | Nunc non risus gravida, pharetra felis et, scelerisque lacus. 49 |
50 |
51 | 52 | 53 | -------------------------------------------------------------------------------- /test/assets/padded-block-in-inline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 40 | 41 | 42 |
43 | 44 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 45 |
Donec ac convallis nisi.
46 | Mauris fermentum est nec placerat tincidunt. 47 |
48 |
49 | 50 | 51 | -------------------------------------------------------------------------------- /test/assets/padded-br.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 40 | 41 | 42 |
43 | 44 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 45 |
46 | Mauris fermentum est nec placerat tincidunt. 47 |
48 |
49 | 50 | 51 | -------------------------------------------------------------------------------- /test/assets/padded-inline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 53 | 54 | 55 |
56 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 57 | Donec ac convallis nisi. Mauris fermentum est nec placerat tincidunt. 58 | Nunc non risus gravida, pharetra felis et, scelerisque lacus. 59 | Suspendisse lacinia sit amet est id ornare. 60 | Quisque sit amet lectus ornare, ullamcorper diam in, varius mi. 61 |
62 | 63 | 64 | -------------------------------------------------------------------------------- /test/assets/pre.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 55 | 56 | 57 |
58 | 59 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 60 | 61 | 62 | Donec ac convallis nisi. Mauris fermentum est nec placerat tincidunt. 63 | 64 | 65 | Nunc non risus gravida, pharetra felis et, scelerisque lacus. 66 | 67 | 68 | Suspendisse lacinia sit amet est id ornare.Lorem ipsum dolor sit amet 69 | 70 | 71 | consectetur adipiscing elit. 72 | 73 | 74 | Donec ac convallisnisi. Mauris fermentum est nec placerat tincidunt. 75 | 76 | 77 | Nunc non risus gravida, pharetrafelis et, scelerisque lacus. 78 | 79 |
80 | 81 |
82 | Lorem ipsum 83 | 84 | 85 | dolor sit amet 86 | 87 | 88 |
89 | 90 | 91 | -------------------------------------------------------------------------------- /test/assets/shorthand.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 33 | 34 | 35 |
36 |
37 |
38 | 39 | 40 | Lorem ipsum dolor sit amet 41 | consectetur 42 | adipiscing elit 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /test/assets/simple-block.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 28 | 29 | 30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /test/assets/simple-inline.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 33 | 34 | 35 | Hello 36 | 37 | 38 | -------------------------------------------------------------------------------- /test/assets/stack-block.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 52 | 53 | 54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | 62 | 63 | -------------------------------------------------------------------------------- /test/assets/vertical-align-image.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 24 | 25 | 26 | 27 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 28 | 29 |
30 | 31 | Donec ac convallis nisi. Mauris fermentum est nec placerat tincidunt. 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /test/assets/vertical-align.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 130 | 131 | 132 | 133 | Lorem 134 | ipsum 135 | dolor 136 | sit 137 | amet 138 | 139 |
140 | 141 | consectetur 142 | Donec 143 | ac 144 | convallis 145 | nisi. 146 | 147 |
148 | 149 | Mauris 150 | 151 | fermentum 152 | 153 | est 154 | 155 | nec 156 | 157 | placerat 158 | 159 | 160 | 161 | 162 | 163 |
164 | 165 | tincidunt. 166 | 167 | Nunc 168 | 169 | non 170 | 171 | risus 172 | 173 | gravida 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /test/assets/white-space.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 33 | 34 | 35 |
36 | 37 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 38 | Donec ac convallis nisi. Mauris fermentum est nec placerat tincidunt. 39 | 40 | Nunc non risus gravida, pharetra felis et, scelerisque lacus. 41 | Suspendisse lacinia sit amet est id ornare. 42 | 43 | 44 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 45 | Donec ac convallis nisi. Mauris fermentum est nec placerat tincidunt. 46 | 47 | Nunc non risus gravida, pharetra felis et, scelerisque lacus. 48 | Suspendisse lacinia sit amet est id ornare. 49 | 50 | 51 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 52 | Donec ac convallis nisi. Mauris fermentum est nec placerat tincidunt. 53 | 54 | Nunc non risus gravida, pharetra felis et, scelerisque lacus. 55 | Suspendisse lacinia sit amet est id ornare. 56 | 57 | 58 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 59 | Donec ac convallis nisi. Mauris fermentum est nec placerat tincidunt. 60 | 61 | Nunc non risus gravida, pharetra felis et, scelerisque lacus. 62 | Suspendisse lacinia sit amet est id ornare. 63 | 64 | 65 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. 66 | Donec ac convallis nisi. Mauris fermentum est nec placerat tincidunt. 67 | 68 | Nunc non risus gravida, pharetra felis et, scelerisque lacus. 69 | Suspendisse lacinia sit amet est id ornare. 70 | 71 |
72 | 73 | 74 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Browser Test 5 | 6 | 44 | 45 | 46 |
47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | var util = require('util'); 2 | var fs = require('fs'); 3 | var qs = require('querystring'); 4 | var url = require('url'); 5 | 6 | var serialize = require('./serialize'); 7 | var render = require('../'); 8 | 9 | var query = qs.parse(window.location.search.replace(/^\?/, '')); 10 | 11 | var assets = {}; 12 | assets['simple-block.html'] = fs.readFileSync(__dirname + '/assets/simple-block.html', 'utf-8'); 13 | assets['nested-block.html'] = fs.readFileSync(__dirname + '/assets/nested-block.html', 'utf-8'); 14 | assets['stack-block.html'] = fs.readFileSync(__dirname + '/assets/stack-block.html', 'utf-8'); 15 | assets['simple-inline.html'] = fs.readFileSync(__dirname + '/assets/simple-inline.html', 'utf-8'); 16 | assets['empty-inline.html'] = fs.readFileSync(__dirname + '/assets/empty-inline.html', 'utf-8'); 17 | assets['nested-inline.html'] = fs.readFileSync(__dirname + '/assets/nested-inline.html', 'utf-8'); 18 | assets['column-inline.html'] = fs.readFileSync(__dirname + '/assets/column-inline.html', 'utf-8'); 19 | assets['white-space.html'] = [fs.readFileSync(__dirname + '/assets/white-space.html', 'utf-8'), 512, 768]; 20 | assets['pre.html'] = [fs.readFileSync(__dirname + '/assets/pre.html', 'utf-8'), 512, 384]; 21 | assets['mixed-white-space.html'] = fs.readFileSync(__dirname + '/assets/mixed-white-space.html', 'utf-8'); 22 | assets['multiline.html'] = fs.readFileSync(__dirname + '/assets/multiline.html', 'utf-8'); 23 | assets['br.html'] = fs.readFileSync(__dirname + '/assets/br.html', 'utf-8'); 24 | assets['block-in-inline.html'] = fs.readFileSync(__dirname + '/assets/block-in-inline.html', 'utf-8'); 25 | assets['nested-block-in-inline.html'] = fs.readFileSync(__dirname + '/assets/nested-block-in-inline.html', 'utf-8'); 26 | assets['padded-block-in-inline.html'] = fs.readFileSync(__dirname + '/assets/padded-block-in-inline.html', 'utf-8'); 27 | assets['padded-inline.html'] = fs.readFileSync(__dirname + '/assets/padded-inline.html', 'utf-8'); 28 | assets['padded-br.html'] = fs.readFileSync(__dirname + '/assets/padded-br.html', 'utf-8'); 29 | assets['padded-all.html'] = fs.readFileSync(__dirname + '/assets/padded-all.html', 'utf-8'); 30 | assets['font-size.html'] = fs.readFileSync(__dirname + '/assets/font-size.html', 'utf-8'); 31 | assets['multiline-font-size.html'] = fs.readFileSync(__dirname + '/assets/multiline-font-size.html', 'utf-8'); 32 | assets['image.html'] = fs.readFileSync(__dirname + '/assets/image.html', 'utf-8'); 33 | assets['inline-image.html'] = fs.readFileSync(__dirname + '/assets/inline-image.html', 'utf-8'); 34 | assets['block-image.html'] = fs.readFileSync(__dirname + '/assets/block-image.html', 'utf-8'); 35 | assets['multiline-image.html'] = fs.readFileSync(__dirname + '/assets/multiline-image.html', 'utf-8'); 36 | assets['line-height.html'] = [fs.readFileSync(__dirname + '/assets/line-height.html', 'utf-8'), 512, 384]; 37 | assets['vertical-align.html'] = fs.readFileSync(__dirname + '/assets/vertical-align.html', 'utf-8'); 38 | assets['vertical-align-image.html'] = fs.readFileSync(__dirname + '/assets/vertical-align-image.html', 'utf-8'); 39 | assets['shorthand.html'] = fs.readFileSync(__dirname + '/assets/shorthand.html', 'utf-8'); 40 | 41 | var resolve = function(name) { 42 | var loc = window.location; 43 | var path = url.resolve(loc.pathname, name); 44 | 45 | return util.format('%s//%s%s', loc.protocol, loc.host, path); 46 | }; 47 | 48 | var canvas = function(element, options, callback) { 49 | var canvas = document.createElement('canvas'); 50 | var dimensions = options.viewport.dimensions; 51 | var dpr = window.devicePixelRatio || 1; 52 | 53 | canvas.width = dimensions.width * dpr; 54 | canvas.height = dimensions.height * dpr; 55 | canvas.style.width = dimensions.width + 'px'; 56 | canvas.style.height = dimensions.height + 'px'; 57 | 58 | element.appendChild(canvas); 59 | var context = canvas.getContext('2d'); 60 | context.scale(dpr, dpr); 61 | options.context = context; 62 | 63 | render(options, callback); 64 | }; 65 | 66 | var iframe = function(element, options) { 67 | var dimensions = options.viewport.dimensions; 68 | var iframe = document.createElement('iframe'); 69 | element.appendChild(iframe); 70 | 71 | iframe.width = dimensions.width; 72 | iframe.height = dimensions.height; 73 | 74 | var doc = iframe.contentDocument; 75 | 76 | doc.open(); 77 | doc.write(options.content); 78 | doc.close(); 79 | }; 80 | 81 | var row = function(element, options) { 82 | var row = document.createElement('div'); 83 | row.className = 'row clearfix'; 84 | 85 | var top = document.createElement('div'); 86 | top.className = 'top'; 87 | 88 | var name = options.url.split('/').pop(); 89 | var content = document.createTextNode(name); 90 | var link = document.createElement('a'); 91 | link.href = window.location.pathname + '?name=' + encodeURIComponent(name); 92 | 93 | link.appendChild(content); 94 | top.appendChild(link); 95 | 96 | var left = document.createElement('div'); 97 | left.className = 'left-column'; 98 | 99 | var right = document.createElement('div'); 100 | right.className = 'right-column'; 101 | 102 | row.appendChild(top); 103 | row.appendChild(left); 104 | row.appendChild(right); 105 | 106 | element.appendChild(row); 107 | 108 | iframe(right, options); 109 | canvas(left, options, function(err, page) { 110 | if(err) throw err; 111 | 112 | console.log('--', page.url); 113 | console.log(serialize(page.layout)); 114 | }); 115 | }; 116 | 117 | var container = document.getElementById('container'); 118 | 119 | Object.keys(assets).forEach(function(asset) { 120 | if(query.name && query.name !== asset) return; 121 | if(query.name) document.title += util.format(' (%s)', asset); 122 | 123 | var data = assets[asset]; 124 | data = Array.isArray(data) ? data : [data, 512, 256]; 125 | 126 | row(container, { 127 | url: resolve(asset), 128 | content: data[0], 129 | viewport: { 130 | position: { x: 0, y: 0 }, 131 | dimensions: { width: data[1], height: data[2] } 132 | } 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /test/serialize.js: -------------------------------------------------------------------------------- 1 | var Viewport = require('../source/layout/viewport'); 2 | var BlockBox = require('../source/layout/block-box'); 3 | var LineBox = require('../source/layout/line-box'); 4 | var LineBreakBox = require('../source/layout/line-break-box'); 5 | var InlineBox = require('../source/layout/inline-box'); 6 | var TextBox = require('../source/layout/text-box'); 7 | var ImageBox = require('../source/layout/image-box'); 8 | 9 | var indent = function(i) { 10 | return (new Array(i + 1)).join('| '); 11 | }; 12 | 13 | var name = function(box) { 14 | if(box instanceof Viewport) return 'Viewport'; 15 | if(box instanceof BlockBox) return 'BlockBox'; 16 | if(box instanceof LineBox) return 'LineBox'; 17 | if(box instanceof LineBreakBox) return 'LineBreakBox'; 18 | if(box instanceof InlineBox) return 'InlineBox'; 19 | if(box instanceof TextBox) return 'TextBox'; 20 | if(box instanceof ImageBox.Block) return 'BlockImageBox'; 21 | if(box instanceof ImageBox.Inline) return 'InlineImageBox'; 22 | }; 23 | 24 | var attributes = function(box) { 25 | return '(' + [ 26 | ['x', box.position.x], 27 | ['y', box.position.y], 28 | ['width', box.dimensions.width], 29 | ['height', box.dimensions.height] 30 | ].map(function(pair) { 31 | return pair[0] + '=' + pair[1]; 32 | }).join(', ') + ')'; 33 | }; 34 | 35 | var toString = function(box, indentation) { 36 | var space = indent(indentation); 37 | 38 | if(box instanceof TextBox) return [space, name(box), attributes(box), '[', JSON.stringify(box.display), ']'].join(''); 39 | if(box instanceof ImageBox) return [space, name(box), attributes(box), '[', JSON.stringify(box.image.src), ']'].join(''); 40 | if(box instanceof LineBreakBox) return [space, name(box)].join(''); 41 | 42 | var children = box.children 43 | .map(function(child) { 44 | return toString(child, indentation + 1); 45 | }).filter(Boolean); 46 | 47 | return [space + name(box) + attributes(box) + '['] 48 | .concat(children) 49 | .concat([space + ']']) 50 | .join('\n'); 51 | }; 52 | 53 | module.exports = function(box) { 54 | return toString(box, 0); 55 | }; 56 | --------------------------------------------------------------------------------