├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── api-extractor.json ├── examples ├── CSSParser.js ├── HTMLParser.js ├── example.png ├── layout.js ├── painting.js └── style.js ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── rollup.config.ts ├── scripts ├── copyDTS.mjs └── verifyCommitMsg.mjs ├── src ├── CSSParser.ts ├── HTMLParser.ts ├── Parser.ts ├── index.ts ├── layout │ ├── Dimensions.ts │ ├── LayoutBox.ts │ ├── Rect.ts │ ├── index.ts │ └── type.ts ├── painting.ts └── style.ts ├── tests ├── CSSParser.spec.ts ├── HTMLParser.spec.ts ├── layout.spec.ts └── style.spec.ts ├── tsconfig.build.json ├── tsconfig.json └── types └── index.d.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true, 6 | es6: true, 7 | jest: true, 8 | }, 9 | extends: ['eslint-config-airbnb-vue3-ts'], 10 | rules: { 11 | 'lines-between-class-members': 'off', 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | output.png 2 | output.pdf 3 | node_modules 4 | dist 5 | tsconfig.tsbuildinfo 6 | tsconfig.build.tsbuildinfo 7 | .rollup.cache -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | FORCE_COLOR=1 node scripts/verifyCommitMsg.mjs $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run type-check 5 | npm run test 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll": true 4 | }, 5 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], 6 | "files.eol": "\n", 7 | "editor.quickSuggestions": { 8 | "strings": true 9 | }, 10 | "eslint.format.enable": true 11 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tiny-rendering-engine 2 | 3 | 从零开始实现一个玩具版浏览器渲染引擎 4 | 5 | ![](examples/example.png) 6 | 7 | ## 功能 8 | 9 | * [x] HTML 解析器 - v1 分支 10 | * [x] CSS 解析器 - v2 分支 11 | * [x] 构建样式树 - v3 分支 12 | * [x] 布局树 - v4 分支 13 | * [x] 绘制 - v5 分支 14 | 15 | ## 文档 16 | 17 | * [从零开始实现一个玩具版浏览器渲染引擎](https://github.com/woai3c/Front-end-articles/issues/44) 18 | 19 | ## 疑难问题 20 | 21 | ### 安装 canvas 报错 22 | 23 | 请参考 canvas 安装指引文档: 24 | 25 | ## 开发 26 | 安装依赖 27 | ```sh 28 | pnpm i 29 | ``` 30 | 开发 31 | ``` 32 | pnpm dev 33 | ``` 34 | 构建 35 | ``` 36 | pnpm build 37 | ``` 38 | 测试 39 | ``` 40 | pnpm test 41 | ``` 42 | 43 | ## 示例 44 | 所有示例均在 examples 目录下,查看示例前需要先执行构建命令 `pnpm build`。 45 | 46 | ## 参考资料 47 | * [Let's build a browser engine!](https://limpet.net/mbrubeck/2014/08/08/toy-layout-engine-1.html) 48 | * [robinson](https://github.com/mbrubeck/robinson) 49 | * [渲染页面:浏览器的工作原理](https://developer.mozilla.org/zh-CN/docs/Web/Performance/How_browsers_work) 50 | * [关键渲染路径](https://developer.mozilla.org/zh-CN/docs/Web/Performance/Critical_rendering_path) 51 | * [计算机系统要素](https://book.douban.com/subject/1998341/) 52 | -------------------------------------------------------------------------------- /api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", 3 | "projectFolder": ".", 4 | "mainEntryPointFilePath": "temp/index.d.ts", 5 | "dtsRollup": { 6 | "enabled": true, 7 | "untrimmedFilePath": "", 8 | "publicTrimmedFilePath": "dist/index.d.ts" 9 | }, 10 | "docModel": { 11 | "enabled": false 12 | }, 13 | "apiReport": { 14 | "enabled": false 15 | }, 16 | "tsdocMetadata": { 17 | "enabled": false 18 | }, 19 | "messages": { 20 | "extractorMessageReporting": { 21 | "ae-forgotten-export": { 22 | "logLevel": "none" 23 | }, 24 | "ae-missing-release-tag": { 25 | "logLevel": "none" 26 | } 27 | } 28 | } 29 | } -------------------------------------------------------------------------------- /examples/CSSParser.js: -------------------------------------------------------------------------------- 1 | const { CSSParser } = require('../dist/render-engine.js') 2 | 3 | function parse(css) { 4 | const parser = new CSSParser() 5 | return JSON.stringify(parser.parse(css), null, 4) 6 | } 7 | 8 | console.log( 9 | parse(` 10 | div, 11 | *, 12 | .class-div, 13 | #id-div { 14 | font-size: 14px; 15 | position: relative; 16 | width: 100%; 17 | height: 100%; 18 | background: rgba(0, 0, 0, 1); 19 | margin-bottom: 20px; 20 | } 21 | 22 | div { 23 | font-size: 16px; 24 | } 25 | 26 | body { 27 | font-size: 88px; 28 | color: #000; 29 | } 30 | `) 31 | ) 32 | 33 | console.log( 34 | parse(` 35 | .class-div, 36 | .class-div2 { 37 | font-size: 14px; 38 | } 39 | `) 40 | ) 41 | -------------------------------------------------------------------------------- /examples/HTMLParser.js: -------------------------------------------------------------------------------- 1 | const { HTMLParser } = require('../dist/render-engine.js') 2 | 3 | function parse(html) { 4 | const parser = new HTMLParser() 5 | return JSON.stringify(parser.parse(html), null, 4) 6 | } 7 | 8 | console.log(parse('
test!
')) 9 | console.log( 10 | parse(` 11 | 12 | 13 |
test!
14 | 15 | 16 | `) 17 | ) 18 | console.log( 19 | parse(` 20 |
test!
21 | `) 22 | ) 23 | -------------------------------------------------------------------------------- /examples/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/woai3c/tiny-rendering-engine/401e437c8488c24ca96e22413710ad7166318c38/examples/example.png -------------------------------------------------------------------------------- /examples/layout.js: -------------------------------------------------------------------------------- 1 | const { HTMLParser, CSSParser, getLayoutTree, getStyleTree, Dimensions } = require('../dist/render-engine.js') 2 | 3 | function parseHTML(html) { 4 | const parser = new HTMLParser() 5 | return parser.parse(html) 6 | } 7 | 8 | function parseCSS(css) { 9 | const parser = new CSSParser() 10 | return parser.parse(css) 11 | } 12 | 13 | const domTree = parseHTML(` 14 | 15 | 16 |
test!
17 | 18 | 19 | `) 20 | 21 | const cssRules = parseCSS(` 22 | * { 23 | display: block; 24 | } 25 | 26 | div { 27 | font-size: 14px; 28 | position: relative; 29 | width: 400px; 30 | height: 400px; 31 | background: rgba(0, 0, 0, 1); 32 | margin-bottom: 20px; 33 | display: block; 34 | } 35 | 36 | .lightblue { 37 | font-size: 16px; 38 | display: block; 39 | } 40 | 41 | body { 42 | display: block; 43 | font-size: 88px; 44 | color: #000; 45 | } 46 | `) 47 | 48 | const dimensions = new Dimensions() 49 | dimensions.content.width = 800 50 | dimensions.content.height = 600 51 | console.log(JSON.stringify(getLayoutTree(getStyleTree(domTree, cssRules), dimensions), null, 4)) 52 | -------------------------------------------------------------------------------- /examples/painting.js: -------------------------------------------------------------------------------- 1 | const { HTMLParser, CSSParser, getLayoutTree, getStyleTree, Dimensions, painting } = require('../dist/render-engine.js') 2 | const { join } = require('path') 3 | 4 | function parseHTML(html) { 5 | const parser = new HTMLParser() 6 | return parser.parse(html) 7 | } 8 | 9 | function parseCSS(css) { 10 | const parser = new CSSParser() 11 | return parser.parse(css) 12 | } 13 | 14 | const domTree = parseHTML(` 15 | 16 | 17 |
18 |
test1!
19 |
20 |
foo
21 |
22 |
23 | 24 | 25 | `) 26 | 27 | const cssRules = parseCSS(` 28 | * { 29 | display: block; 30 | } 31 | 32 | div { 33 | font-size: 14px; 34 | width: 400px; 35 | background: #fff; 36 | margin-bottom: 20px; 37 | display: block; 38 | background: lightblue; 39 | } 40 | 41 | .lightblue { 42 | font-size: 16px; 43 | display: block; 44 | width: 200px; 45 | height: 200px; 46 | background: blue; 47 | border-color: green; 48 | border: 10px; 49 | } 50 | 51 | .foo { 52 | width: 100px; 53 | height: 100px; 54 | background: red; 55 | color: yellow; 56 | margin-left: 50px; 57 | } 58 | 59 | body { 60 | display: block; 61 | font-size: 88px; 62 | color: #000; 63 | } 64 | `) 65 | 66 | const dimensions = new Dimensions() 67 | dimensions.content.width = 1000 68 | dimensions.content.height = 800 69 | painting(getLayoutTree(getStyleTree(domTree, cssRules), dimensions), join(__dirname, './example.png')) 70 | -------------------------------------------------------------------------------- /examples/style.js: -------------------------------------------------------------------------------- 1 | const { HTMLParser, CSSParser, getStyleTree } = require('../dist/render-engine.js') 2 | 3 | function parseHTML(html) { 4 | const parser = new HTMLParser() 5 | return parser.parse(html) 6 | } 7 | 8 | function parseCSS(css) { 9 | const parser = new CSSParser() 10 | return parser.parse(css) 11 | } 12 | 13 | const domTree = parseHTML(` 14 | 15 | 16 |
test!
17 | 18 | 19 | `) 20 | 21 | const cssRules = parseCSS(` 22 | * { 23 | display: block; 24 | } 25 | 26 | div { 27 | font-size: 14px; 28 | position: relative; 29 | width: 100%; 30 | height: 100%; 31 | background: rgba(0, 0, 0, 1); 32 | margin-bottom: 20px; 33 | } 34 | 35 | .lightblue { 36 | font-size: 16px; 37 | } 38 | 39 | body { 40 | font-size: 88px; 41 | color: #000; 42 | } 43 | `) 44 | 45 | console.log(JSON.stringify(getStyleTree(domTree, cssRules), null, 4)) 46 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest/presets/default-esm', 4 | transform: { 5 | '^.+\\.tsx?$': [ 6 | 'ts-jest', 7 | { 8 | isolatedModules: true, 9 | tsconfig: 'tsconfig.json', 10 | useESM: true, 11 | }, 12 | ], 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tiny-rendering-engine", 3 | "version": "1.0.0", 4 | "description": "从零开始实现一个玩具版浏览器渲染引擎", 5 | "main": "dist/render-engine.js", 6 | "module": "dist/render-engine.mjs", 7 | "types": "dist/index.d.ts", 8 | "scripts": { 9 | "prepare": "husky install", 10 | "dev": "rimraf dist && rollup --config rollup.config.ts --configPlugin typescript --environment NODE_ENV:development", 11 | "build": "rimraf dist && rollup --config rollup.config.ts --configPlugin typescript && npm run type-generate", 12 | "type-generate": "tsc -p ./tsconfig.build.json && api-extractor run --config=./api-extractor.json && rimraf temp && node scripts/copyDTS.mjs", 13 | "test": "node --experimental-vm-modules --no-warnings node_modules/jest/bin/jest.js -c=jest.config.js --no-cache", 14 | "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", 15 | "type-check": "tsc --noEmit" 16 | }, 17 | "lint-staged": { 18 | "src/**/*.{ts,js}": [ 19 | "eslint --fix", 20 | "git add" 21 | ] 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/woai3c/tiny-rendering-engine.git" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/woai3c/tiny-rendering-engine/issues" 29 | }, 30 | "homepage": "https://github.com/woai3c/tiny-rendering-engine#readme", 31 | "devDependencies": { 32 | "@microsoft/api-extractor": "^7.36.4", 33 | "@rollup/plugin-commonjs": "^25.0.4", 34 | "@rollup/plugin-json": "^5.0.2", 35 | "@rollup/plugin-node-resolve": "^15.2.1", 36 | "@rollup/plugin-typescript": "^11.1.3", 37 | "@types/jest": "^29.5.4", 38 | "@types/node": "^18.17.12", 39 | "chalk": "^5.3.0", 40 | "eslint": "^8.48.0", 41 | "eslint-config-airbnb-vue3-ts": "^0.2.4", 42 | "husky": "^8.0.3", 43 | "jest": "^29.6.4", 44 | "lint-staged": "^13.3.0", 45 | "rimraf": "^6.0.1", 46 | "rollup": "^3.28.1", 47 | "ts-jest": "^29.2.4", 48 | "typescript": "^5.5.4" 49 | }, 50 | "dependencies": { 51 | "canvas": "^2.11.2" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import json from '@rollup/plugin-json' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import typescript from '@rollup/plugin-typescript' 4 | import type { OutputOptions, RollupWatchOptions } from 'rollup' 5 | import { watch } from 'rollup' 6 | 7 | function getOptions(output: OutputOptions | OutputOptions[]) { 8 | return { 9 | input: 'src/index.ts', 10 | output, 11 | plugins: [ 12 | resolve({ 13 | browser: true, 14 | }), 15 | typescript(), 16 | json({ 17 | compact: true, 18 | }), 19 | ], 20 | } as RollupWatchOptions 21 | } 22 | 23 | if (process.env.NODE_ENV === 'development') { 24 | const watcher = watch(getOptions(getOutput('umd'))) 25 | console.log('rollup is watching for file change...') 26 | 27 | watcher.on('event', (event) => { 28 | switch (event.code) { 29 | case 'START': 30 | console.log('rollup is rebuilding...') 31 | break 32 | case 'ERROR': 33 | console.log('error in rebuilding.') 34 | break 35 | case 'END': 36 | console.log('rebuild done.\n\n') 37 | } 38 | }) 39 | } 40 | 41 | const formats = ['es', 'umd'] 42 | 43 | function getOutput(format: 'es' | 'umd') { 44 | return { 45 | format, 46 | file: `dist/render-engine.${format === 'es' ? 'mjs' : 'js'}`, 47 | name: 'RenderEngine', 48 | } 49 | } 50 | 51 | export default getOptions(formats.map(getOutput)) 52 | -------------------------------------------------------------------------------- /scripts/copyDTS.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, readdirSync, appendFileSync } from 'fs' 2 | import { fileURLToPath } from 'url' 3 | import { dirname, resolve } from 'path' 4 | 5 | const __filename = fileURLToPath(import.meta.url) 6 | const __dirname = dirname(__filename) 7 | 8 | const files = readdirSync(getFilePath('../types')) 9 | let code = '' 10 | files.forEach((file) => { 11 | code += '\n' + readFileSync(getFilePath(`../types/${file}`)) + '\n' 12 | }) 13 | 14 | appendFileSync(getFilePath('../dist/index.d.ts'), code) 15 | 16 | function getFilePath(name) { 17 | return resolve(__dirname, name) 18 | } 19 | -------------------------------------------------------------------------------- /scripts/verifyCommitMsg.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | import chalk from 'chalk' 3 | import { readFileSync } from 'fs' 4 | 5 | const msgPath = process.argv[2] 6 | const msg = readFileSync(msgPath, 'utf-8').trim() 7 | 8 | const commitRE = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|release)(\(.+\))?: .{1,50}/ 9 | 10 | if (!commitRE.test(msg)) { 11 | console.log() 12 | console.error( 13 | ` ${chalk.bgRed.white(' ERROR ')} ${chalk.red('不合法的 commit 消息格式')}\n\n` 14 | + chalk.red(' 请使用正确的提交格式:\n\n') 15 | + ` ${chalk.green("feat: add 'comments' option")}\n` 16 | + ` ${chalk.green('fix: handle events on blur (close #28)')}\n\n` 17 | + chalk.red( 18 | ' 请查看 git commit 提交规范:https://github.com/woai3c/Front-end-articles/blob/master/git%20commit%20style.md。\n' 19 | ) 20 | ) 21 | 22 | process.exit(1) 23 | } 24 | -------------------------------------------------------------------------------- /src/CSSParser.ts: -------------------------------------------------------------------------------- 1 | import Parser from './Parser' 2 | 3 | export interface Rule { 4 | selectors: Selector[] 5 | declarations: Declaration[] 6 | } 7 | 8 | export interface Selector { 9 | tagName: string 10 | id: string 11 | class: string 12 | } 13 | 14 | export interface Declaration { 15 | name: string 16 | value: string | number 17 | } 18 | 19 | export default class CSSParser extends Parser { 20 | private identifierRE = /\w|-|_/ 21 | 22 | parse(rawText: string) { 23 | if (typeof rawText !== 'string') { 24 | throw Error('parameter 0 must be a string') 25 | } 26 | 27 | this.rawText = rawText.trim() 28 | this.len = this.rawText.length 29 | this.index = 0 30 | return this.parseRules() 31 | } 32 | 33 | private parseRules() { 34 | const rules: Rule[] = [] 35 | while (this.index < this.len) { 36 | this.removeSpaces() 37 | rules.push(this.parseRule()) 38 | this.index++ 39 | } 40 | 41 | return rules 42 | } 43 | 44 | private parseRule() { 45 | const rule: Rule = { 46 | selectors: [], 47 | declarations: [], 48 | } 49 | 50 | rule.selectors = this.parseSelectors() 51 | rule.declarations = this.parseDeclarations() 52 | 53 | return rule 54 | } 55 | 56 | private parseSelectors() { 57 | const selectors: Selector[] = [] 58 | const symbols = ['*', '.', '#'] 59 | while (this.index < this.len) { 60 | this.removeSpaces() 61 | const char = this.rawText[this.index] 62 | if (this.identifierRE.test(char) || symbols.includes(char)) { 63 | selectors.push(this.parseSelector()) 64 | } else if (char === ',') { 65 | this.removeSpaces() 66 | selectors.push(this.parseSelector()) 67 | } else if (char === '{') { 68 | this.index++ 69 | break 70 | } 71 | 72 | this.index++ 73 | } 74 | 75 | return selectors 76 | } 77 | 78 | private parseSelector() { 79 | const selector: Selector = { 80 | id: '', 81 | class: '', 82 | tagName: '', 83 | } 84 | 85 | switch (this.rawText[this.index]) { 86 | case '.': 87 | this.index++ 88 | selector.class = this.parseIdentifier() 89 | break 90 | case '#': 91 | this.index++ 92 | selector.id = this.parseIdentifier() 93 | break 94 | case '*': 95 | this.index++ 96 | selector.tagName = '*' 97 | break 98 | default: 99 | selector.tagName = this.parseIdentifier() 100 | } 101 | 102 | return selector 103 | } 104 | 105 | private parseDeclarations() { 106 | const declarations: Declaration[] = [] 107 | while (this.index < this.len && this.rawText[this.index] !== '}') { 108 | declarations.push(this.parseDeclaration()) 109 | } 110 | 111 | return declarations 112 | } 113 | 114 | private parseDeclaration() { 115 | const declaration: Declaration = { name: '', value: '' } 116 | this.removeSpaces() 117 | declaration.name = this.parseIdentifier() 118 | this.removeSpaces() 119 | 120 | while (this.index < this.len && this.rawText[this.index] !== ':') { 121 | this.index++ 122 | } 123 | 124 | this.index++ // clear : 125 | this.removeSpaces() 126 | declaration.value = this.parseValue() 127 | this.removeSpaces() 128 | 129 | return declaration 130 | } 131 | 132 | private parseIdentifier() { 133 | let result = '' 134 | while (this.index < this.len && this.identifierRE.test(this.rawText[this.index])) { 135 | result += this.rawText[this.index++] 136 | } 137 | 138 | this.sliceText() 139 | return result 140 | } 141 | 142 | private parseValue() { 143 | let result = '' 144 | while (this.index < this.len && this.rawText[this.index] !== ';') { 145 | result += this.rawText[this.index++] 146 | } 147 | 148 | this.index++ 149 | this.sliceText() 150 | return result.trim() 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/HTMLParser.ts: -------------------------------------------------------------------------------- 1 | import Parser from './Parser' 2 | 3 | export enum NodeType { 4 | Element = 1, 5 | Text = 3, 6 | } 7 | 8 | export interface Element { 9 | tagName: string 10 | attributes: Record 11 | children: Node[] 12 | nodeType: NodeType.Element 13 | } 14 | 15 | interface Text { 16 | nodeValue: string 17 | nodeType: NodeType.Text 18 | } 19 | 20 | export type Node = Element | Text 21 | 22 | export function element(tagName: string) { 23 | return { 24 | tagName, 25 | attributes: {}, 26 | children: [], 27 | nodeType: NodeType.Element, 28 | } as Element 29 | } 30 | 31 | export function text(data: string) { 32 | return { 33 | nodeValue: data, 34 | nodeType: NodeType.Text, 35 | } as Text 36 | } 37 | 38 | export default class HTMLParser extends Parser { 39 | private stack: string[] = [] 40 | 41 | parse(rawText: string) { 42 | if (typeof rawText !== 'string') { 43 | throw Error('parameter 0 must be a string') 44 | } 45 | 46 | this.rawText = rawText.trim() 47 | this.len = this.rawText.length 48 | this.index = 0 49 | this.stack = [] 50 | 51 | const root = element('root') 52 | while (this.index < this.len) { 53 | this.removeSpaces() 54 | if (this.rawText[this.index].startsWith('<')) { 55 | this.index++ 56 | this.parseElement(root) 57 | } else { 58 | this.parseText(root) 59 | } 60 | } 61 | 62 | if (root.children.length === 1) return root.children[0] 63 | return root.children 64 | } 65 | 66 | private parseElement(parent: Element) { 67 | const tag = this.parseTag() 68 | const ele = element(tag) 69 | 70 | this.stack.push(tag) 71 | 72 | parent.children.push(ele) 73 | this.parseAttrs(ele) 74 | 75 | while (this.index < this.len) { 76 | this.removeSpaces() 77 | if (this.rawText[this.index].startsWith('<')) { 78 | this.index++ 79 | this.removeSpaces() 80 | if (this.rawText[this.index].startsWith('/')) { 81 | this.index++ 82 | const startTag = this.stack[this.stack.length - 1] 83 | const endTag = this.parseTag() 84 | if (startTag !== endTag) { 85 | throw Error(`The end tagName ${endTag} does not match start tagName ${startTag}`) 86 | } 87 | 88 | this.stack.pop() 89 | while (this.index < this.len && this.rawText[this.index] !== '>') { 90 | this.index++ 91 | } 92 | 93 | break 94 | } else { 95 | this.parseElement(ele) 96 | } 97 | } else { 98 | this.parseText(ele) 99 | } 100 | } 101 | 102 | this.index++ 103 | } 104 | 105 | private parseTag() { 106 | let tag = '' 107 | 108 | this.removeSpaces() 109 | 110 | // get tag name 111 | while (this.index < this.len && this.rawText[this.index] !== ' ' && this.rawText[this.index] !== '>') { 112 | tag += this.rawText[this.index] 113 | this.index++ 114 | } 115 | 116 | this.sliceText() 117 | return tag 118 | } 119 | 120 | private parseText(parent: Element) { 121 | let str = '' 122 | while ( 123 | this.index < this.len 124 | && !(this.rawText[this.index] === '<' && /\w|\//.test(this.rawText[this.index + 1])) 125 | ) { 126 | str += this.rawText[this.index] 127 | this.index++ 128 | } 129 | 130 | this.sliceText() 131 | parent.children.push(text(removeExtraSpaces(str))) 132 | } 133 | 134 | private parseAttrs(ele: Element) { 135 | while (this.index < this.len && this.rawText[this.index] !== '>') { 136 | this.removeSpaces() 137 | this.parseAttr(ele) 138 | this.removeSpaces() 139 | } 140 | 141 | this.index++ 142 | } 143 | 144 | private parseAttr(ele: Element) { 145 | let attr = '' 146 | let value = '' 147 | while (this.index < this.len && this.rawText[this.index] !== '=' && this.rawText[this.index] !== '>') { 148 | attr += this.rawText[this.index++] 149 | } 150 | 151 | this.sliceText() 152 | attr = attr.trim() 153 | if (!attr.trim()) return 154 | 155 | this.index++ 156 | let startSymbol = '' 157 | if (this.rawText[this.index] === "'" || this.rawText[this.index] === '"') { 158 | startSymbol = this.rawText[this.index++] 159 | } 160 | 161 | while (this.index < this.len && this.rawText[this.index] !== startSymbol) { 162 | value += this.rawText[this.index++] 163 | } 164 | 165 | this.index++ 166 | ele.attributes[attr] = value.trim() 167 | this.sliceText() 168 | } 169 | } 170 | 171 | // a b c => a b c 删除字符之间多余的空格,只保留一个 172 | function removeExtraSpaces(str: string) { 173 | let index = 0 174 | let len = str.length 175 | let hasSpace = false 176 | let result = '' 177 | while (index < len) { 178 | if (str[index] === ' ' || str[index] === '\n') { 179 | if (!hasSpace) { 180 | hasSpace = true 181 | result += ' ' 182 | } 183 | } else { 184 | result += str[index] 185 | hasSpace = false 186 | } 187 | 188 | index++ 189 | } 190 | 191 | return result 192 | } 193 | -------------------------------------------------------------------------------- /src/Parser.ts: -------------------------------------------------------------------------------- 1 | export default class Parser { 2 | protected rawText = '' 3 | protected index = 0 4 | protected len = 0 5 | 6 | protected removeSpaces() { 7 | while (this.index < this.len && (this.rawText[this.index] === ' ' || this.rawText[this.index] === '\n')) { 8 | this.index++ 9 | } 10 | 11 | this.sliceText() 12 | } 13 | 14 | protected sliceText() { 15 | this.rawText = this.rawText.slice(this.index) 16 | this.len = this.rawText.length 17 | this.index = 0 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as HTMLParser } from './HTMLParser' 2 | export { default as CSSParser } from './CSSParser' 3 | export { getStyleTree } from './style' 4 | export { getLayoutTree, Dimensions } from './layout' 5 | export { default as painting } from './painting' 6 | -------------------------------------------------------------------------------- /src/layout/Dimensions.ts: -------------------------------------------------------------------------------- 1 | import Rect from './Rect' 2 | import type { EdgeSizes } from './type' 3 | 4 | export default class Dimensions { 5 | content: Rect 6 | padding: EdgeSizes 7 | border: EdgeSizes 8 | margin: EdgeSizes 9 | 10 | constructor() { 11 | const initValue = { 12 | top: 0, 13 | right: 0, 14 | bottom: 0, 15 | left: 0, 16 | } 17 | 18 | this.content = new Rect() 19 | 20 | this.padding = { ...initValue } 21 | this.border = { ...initValue } 22 | this.margin = { ...initValue } 23 | } 24 | 25 | paddingBox() { 26 | return this.content.expandedBy(this.padding) 27 | } 28 | 29 | borderBox() { 30 | return this.paddingBox().expandedBy(this.border) 31 | } 32 | 33 | marginBox() { 34 | return this.borderBox().expandedBy(this.margin) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/layout/LayoutBox.ts: -------------------------------------------------------------------------------- 1 | import type { StyleNode } from '../style' 2 | import { getDisplayValue, Display } from '../style' 3 | import Dimensions from './Dimensions' 4 | import { BoxType } from './type' 5 | 6 | export default class LayoutBox { 7 | dimensions: Dimensions 8 | boxType: BoxType 9 | children: LayoutBox[] 10 | styleNode: StyleNode | undefined 11 | 12 | constructor(styleNode?: StyleNode) { 13 | this.boxType = getBoxType(styleNode) 14 | this.dimensions = new Dimensions() 15 | this.children = [] 16 | this.styleNode = styleNode 17 | } 18 | 19 | layout(parentBlock: Dimensions) { 20 | if (this.boxType !== BoxType.InlineNode) { 21 | this.calculateBlockWidth(parentBlock) 22 | this.calculateBlockPosition(parentBlock) 23 | this.layoutBlockChildren() 24 | this.calculateBlockHeight() 25 | } 26 | } 27 | 28 | calculateBlockWidth(parentBlock: Dimensions) { 29 | // 初始值 30 | const styleValues = this.styleNode?.values || {} 31 | const parentWidth = parentBlock.content.width 32 | 33 | // 初始值为 auto 34 | let width = styleValues.width ?? 'auto' 35 | let marginLeft = styleValues['margin-left'] || styleValues.margin || 0 36 | let marginRight = styleValues['margin-right'] || styleValues.margin || 0 37 | 38 | let borderLeft = styleValues['border-left'] || styleValues.border || 0 39 | let borderRight = styleValues['border-right'] || styleValues.border || 0 40 | 41 | let paddingLeft = styleValues['padding-left'] || styleValues.padding || 0 42 | let paddingRight = styleValues['padding-right'] || styleValues.padding || 0 43 | 44 | let totalWidth = sum(width, marginLeft, marginRight, borderLeft, borderRight, paddingLeft, paddingRight) 45 | 46 | const isWidthAuto = width === 'auto' 47 | const isMarginLeftAuto = marginLeft === 'auto' 48 | const isMarginRightAuto = marginRight === 'auto' 49 | 50 | // 当前块的宽度如果超过了父元素宽度 51 | if (!isWidthAuto && totalWidth > parentWidth) { 52 | if (isMarginLeftAuto) { 53 | marginLeft = 0 54 | } 55 | 56 | if (isMarginRightAuto) { 57 | marginRight = 0 58 | } 59 | } 60 | 61 | // 根据父子元素宽度的差值,去调整当前元素的宽度 62 | const underflow = parentWidth - totalWidth 63 | 64 | // 如果三者都有值,则将差值填充到 marginRight 65 | if (!isWidthAuto && !isMarginLeftAuto && !isMarginRightAuto) { 66 | marginRight += underflow 67 | } else if (!isWidthAuto && !isMarginLeftAuto && isMarginRightAuto) { 68 | marginRight = underflow 69 | } else if (!isWidthAuto && isMarginLeftAuto && !isMarginRightAuto) { 70 | marginLeft = underflow 71 | } else if (isWidthAuto) { 72 | // 如果只有 width 是 auto,则将另外两个值设为 0 73 | if (isMarginLeftAuto) { 74 | marginLeft = 0 75 | } 76 | 77 | if (isMarginRightAuto) { 78 | marginRight = 0 79 | } 80 | 81 | if (underflow >= 0) { 82 | // 展开宽度,填充剩余空间,原来的宽度是 auto,作为 0 来计算的 83 | width = underflow 84 | } else { 85 | // 宽度不能为负数,所以需要调整 marginRight 来代替 86 | width = 0 87 | marginRight += underflow 88 | } 89 | } else if (!isWidthAuto && isMarginLeftAuto && isMarginRightAuto) { 90 | // 如果只有 marginLeft 和 marginRight 是 auto,则将两者设为 underflow 的一半 91 | marginLeft = underflow / 2 92 | marginRight = underflow / 2 93 | } 94 | 95 | const dimensions = this.dimensions 96 | dimensions.content.width = parseInt(width) 97 | 98 | dimensions.margin.left = parseInt(marginLeft) 99 | dimensions.margin.right = parseInt(marginRight) 100 | 101 | dimensions.border.left = parseInt(borderLeft) 102 | dimensions.border.right = parseInt(borderRight) 103 | 104 | dimensions.padding.left = parseInt(paddingLeft) 105 | dimensions.padding.right = parseInt(paddingRight) 106 | } 107 | 108 | calculateBlockPosition(parentBlock: Dimensions) { 109 | const styleValues = this.styleNode?.values || {} 110 | const { x, y, height } = parentBlock.content 111 | const dimensions = this.dimensions 112 | 113 | dimensions.margin.top = transformValueSafe(styleValues['margin-top'] || styleValues.margin || 0) 114 | dimensions.margin.bottom = transformValueSafe(styleValues['margin-bottom'] || styleValues.margin || 0) 115 | 116 | dimensions.border.top = transformValueSafe(styleValues['border-top'] || styleValues.border || 0) 117 | dimensions.border.bottom = transformValueSafe(styleValues['border-bottom'] || styleValues.border || 0) 118 | 119 | dimensions.padding.top = transformValueSafe(styleValues['padding-top'] || styleValues.padding || 0) 120 | dimensions.padding.bottom = transformValueSafe(styleValues['padding-bottom'] || styleValues.padding || 0) 121 | 122 | dimensions.content.x = x + dimensions.margin.left + dimensions.border.left + dimensions.padding.left 123 | dimensions.content.y = y + height + dimensions.margin.top + dimensions.border.top + dimensions.padding.top 124 | } 125 | 126 | layoutBlockChildren() { 127 | const { dimensions } = this 128 | for (const child of this.children) { 129 | child.layout(dimensions) 130 | dimensions.content.height += child.dimensions.marginBox().height 131 | } 132 | } 133 | 134 | calculateBlockHeight() { 135 | // 如果元素设置了 height,则使用 height,否则使用 layoutBlockChildren() 计算出来的高度 136 | const height = this.styleNode?.values.height 137 | if (height) { 138 | this.dimensions.content.height = parseInt(height) 139 | } 140 | } 141 | } 142 | 143 | function sum(...args: (string | number)[]) { 144 | return args.reduce((prev: number, cur: string | number) => { 145 | if (cur === 'auto') { 146 | return prev 147 | } 148 | 149 | return prev + parseInt(String(cur)) 150 | }, 0) as number 151 | } 152 | 153 | function transformValueSafe(val: number | string) { 154 | if (val === 'auto') return 0 155 | return parseInt(String(val)) 156 | } 157 | 158 | function getBoxType(styleNode?: StyleNode) { 159 | if (!styleNode) return BoxType.AnonymousBlock 160 | 161 | const display = getDisplayValue(styleNode) 162 | 163 | if (display === Display.Block) return BoxType.BlockNode 164 | return BoxType.InlineNode 165 | } 166 | -------------------------------------------------------------------------------- /src/layout/Rect.ts: -------------------------------------------------------------------------------- 1 | import type { EdgeSizes } from './type' 2 | 3 | export default class Rect { 4 | x: number 5 | y: number 6 | width: number 7 | height: number 8 | 9 | constructor() { 10 | this.x = 0 11 | this.y = 0 12 | this.width = 0 13 | this.height = 0 14 | } 15 | 16 | expandedBy(edge: EdgeSizes) { 17 | const rect = new Rect() 18 | rect.x = this.x - edge.left 19 | rect.y = this.y - edge.top 20 | rect.width = this.width + edge.left + edge.right 21 | rect.height = this.height + edge.top + edge.bottom 22 | 23 | return rect 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/layout/index.ts: -------------------------------------------------------------------------------- 1 | import type { StyleNode } from '../style' 2 | import { Display, getDisplayValue } from '../style' 3 | import Dimensions from './Dimensions' 4 | import LayoutBox from './LayoutBox' 5 | 6 | export { Dimensions } 7 | 8 | export function getLayoutTree(styleNode: StyleNode, parentBlock: Dimensions) { 9 | parentBlock.content.height = 0 10 | const root = buildLayoutTree(styleNode) 11 | root.layout(parentBlock) 12 | return root 13 | } 14 | 15 | function buildLayoutTree(styleNode: StyleNode) { 16 | if (getDisplayValue(styleNode) === Display.None) { 17 | throw new Error('Root node has display: none.') 18 | } 19 | 20 | const layoutBox = new LayoutBox(styleNode) 21 | 22 | let anonymousBlock: LayoutBox | undefined 23 | for (const child of styleNode.children) { 24 | const childDisplay = getDisplayValue(child) 25 | if (childDisplay === Display.None) continue 26 | 27 | if (childDisplay === Display.Block) { 28 | anonymousBlock = undefined 29 | layoutBox.children.push(buildLayoutTree(child)) 30 | } else { 31 | if (!anonymousBlock) { 32 | anonymousBlock = new LayoutBox() 33 | layoutBox.children.push(anonymousBlock) 34 | } 35 | 36 | anonymousBlock.children.push(buildLayoutTree(child)) 37 | } 38 | } 39 | 40 | return layoutBox 41 | } 42 | -------------------------------------------------------------------------------- /src/layout/type.ts: -------------------------------------------------------------------------------- 1 | export enum BoxType { 2 | BlockNode = 'BlockNode', 3 | InlineNode = 'InlineNode', 4 | AnonymousBlock = 'AnonymousBlock', 5 | } 6 | 7 | export interface EdgeSizes { 8 | top: number 9 | right: number 10 | bottom: number 11 | left: number 12 | } 13 | -------------------------------------------------------------------------------- /src/painting.ts: -------------------------------------------------------------------------------- 1 | import type LayoutBox from './layout/LayoutBox' 2 | import { createWriteStream } from 'fs' 3 | import type { Canvas, CanvasRenderingContext2D } from 'canvas' 4 | import { NodeType } from './HTMLParser' 5 | 6 | // 用 import 会报错 7 | const { createCanvas } = require('canvas') 8 | 9 | export default function painting(layoutBox: LayoutBox, outputPath = '') { 10 | const { x, y, width, height } = layoutBox.dimensions.content 11 | const canvas = createCanvas(width, height) as Canvas 12 | const ctx = canvas.getContext('2d') 13 | 14 | // 设置默认背景色 15 | ctx.fillStyle = '#fff' 16 | ctx.fillRect(x, y, width, height) 17 | 18 | renderLayoutBox(layoutBox, ctx) 19 | createPNG(canvas, outputPath) 20 | } 21 | 22 | function renderLayoutBox(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D, parent?: LayoutBox) { 23 | renderBackground(layoutBox, ctx) 24 | renderBorder(layoutBox, ctx) 25 | renderText(layoutBox, ctx, parent) 26 | for (const child of layoutBox.children) { 27 | renderLayoutBox(child, ctx, layoutBox) 28 | } 29 | } 30 | 31 | function renderBackground(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D) { 32 | const { width, height, x, y } = layoutBox.dimensions.borderBox() 33 | ctx.fillStyle = getStyleValue(layoutBox, 'background') 34 | ctx.fillRect(x, y, width, height) 35 | } 36 | 37 | function renderBorder(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D) { 38 | const { width, height, x, y } = layoutBox.dimensions.borderBox() 39 | const { left, top, right, bottom } = layoutBox.dimensions.border 40 | const borderColor = getStyleValue(layoutBox, 'border-color') 41 | if (!borderColor) return 42 | 43 | ctx.fillStyle = borderColor 44 | 45 | // left 46 | ctx.fillRect(x, y, left, height) 47 | // top 48 | ctx.fillRect(x, y, width, top) 49 | // right 50 | ctx.fillRect(x + width - right, y, right, height) 51 | // bottom 52 | ctx.fillRect(x, y + height - bottom, width, bottom) 53 | } 54 | 55 | function renderText(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D, parent?: LayoutBox) { 56 | if (layoutBox.styleNode?.node.nodeType === NodeType.Text) { 57 | // get AnonymousBlock x y 58 | const { x = 0, y = 0, width } = parent?.dimensions.content || {} 59 | const styles = layoutBox.styleNode?.values || {} 60 | const fontSize = styles['font-size'] || '14px' 61 | const fontFamily = styles['font-family'] || 'serif' 62 | const fontWeight = styles['font-weight'] || 'normal' 63 | const fontStyle = styles['font-style'] || 'normal' 64 | 65 | ctx.fillStyle = styles.color 66 | ctx.font = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}` 67 | ctx.fillText(layoutBox.styleNode?.node.nodeValue, x, y + parseInt(fontSize), width) 68 | } 69 | } 70 | 71 | function getStyleValue(layoutBox: LayoutBox, key: string) { 72 | return layoutBox.styleNode?.values[key] ?? '' 73 | } 74 | 75 | function createPNG(canvas: Canvas, outputPath: string) { 76 | canvas.createPNGStream().pipe(createWriteStream(outputPath)) 77 | } 78 | -------------------------------------------------------------------------------- /src/style.ts: -------------------------------------------------------------------------------- 1 | import type { Declaration, Rule, Selector } from './CSSParser' 2 | import type { Node, Element } from './HTMLParser' 3 | import { NodeType } from './HTMLParser' 4 | 5 | export enum Display { 6 | Inline = 'inline', 7 | Block = 'block', 8 | None = 'none', 9 | } 10 | export interface StyleNode { 11 | node: Node // DOM 节点 12 | values: AnyObject // style 属性值 13 | children: StyleNode[] 14 | } 15 | 16 | // 子元素可继承的属性,这里只写了两个,实际上还有很多 17 | const inheritableAttrs = ['color', 'font-size'] 18 | export function getStyleTree(eles: Node | Node[], cssRules: Rule[], parent?: StyleNode) { 19 | if (Array.isArray(eles)) { 20 | return eles.map((ele) => getStyleNode(ele, cssRules, parent)) 21 | } 22 | 23 | return getStyleNode(eles, cssRules, parent) 24 | } 25 | 26 | export function getDisplayValue(styleNode: StyleNode) { 27 | return styleNode.values?.display ?? Display.Inline 28 | } 29 | 30 | function getStyleNode(ele: Node, cssRules: Rule[], parent?: StyleNode) { 31 | const styleNode: StyleNode = { 32 | node: ele, 33 | values: getStyleValues(ele, cssRules, parent), 34 | children: [], 35 | } 36 | 37 | if (ele.nodeType === NodeType.Element) { 38 | // 合并内联样式 39 | if (ele.attributes.style) { 40 | styleNode.values = { ...styleNode.values, ...getInlineStyle(ele.attributes.style) } 41 | } 42 | 43 | styleNode.children = ele.children.map((e) => getStyleNode(e, cssRules, styleNode)) as unknown as StyleNode[] 44 | } 45 | 46 | return styleNode 47 | } 48 | 49 | function getStyleValues(ele: Node, cssRules: Rule[], parent?: StyleNode) { 50 | const inheritableAttrValue = getInheritableAttrValues(parent) 51 | 52 | // 文本节点继承父元素的可继承属性 53 | if (ele.nodeType === NodeType.Text) return inheritableAttrValue 54 | 55 | return cssRules.reduce((result: AnyObject, rule) => { 56 | if (isMatch(ele as Element, rule.selectors)) { 57 | result = { ...result, ...cssValueArrToObject(rule.declarations) } 58 | } 59 | 60 | return result 61 | }, inheritableAttrValue) 62 | } 63 | 64 | /** 65 | * 获取父元素可继承的属性值 66 | */ 67 | function getInheritableAttrValues(parent?: StyleNode) { 68 | if (!parent) return {} 69 | const keys = Object.keys(parent.values) 70 | return keys.reduce((result: AnyObject, key) => { 71 | if (inheritableAttrs.includes(key)) { 72 | result[key] = parent.values[key] 73 | } 74 | 75 | return result 76 | }, {}) 77 | } 78 | 79 | /** 80 | * css 选择器是否匹配元素 81 | */ 82 | function isMatch(ele: Element, selectors: Selector[]) { 83 | return selectors.some((selector) => { 84 | // 通配符 85 | if (selector.tagName === '*') return true 86 | if (selector.tagName === ele.tagName) return true 87 | if (ele.attributes.id === selector.id) return true 88 | 89 | if (ele.attributes.class) { 90 | const classes = ele.attributes.class.split(' ').filter(Boolean) 91 | const classes2 = selector.class.split(' ').filter(Boolean) 92 | for (const name of classes) { 93 | if (classes2.includes(name)) return true 94 | } 95 | } 96 | 97 | return false 98 | }) 99 | } 100 | 101 | function cssValueArrToObject(declarations: Declaration[]) { 102 | return declarations.reduce((result: AnyObject, declaration: Declaration) => { 103 | result[declaration.name] = declaration.value 104 | return result 105 | }, {}) 106 | } 107 | 108 | function getInlineStyle(str: string) { 109 | str = str.trim() 110 | if (!str) return {} 111 | const arr = str.split(';') 112 | if (!arr.length) return {} 113 | 114 | return arr.reduce((result: AnyObject, item: string) => { 115 | const data = item.split(':') 116 | if (data.length === 2) { 117 | result[data[0].trim()] = data[1].trim() 118 | } 119 | 120 | return result 121 | }, {}) 122 | } 123 | -------------------------------------------------------------------------------- /tests/CSSParser.spec.ts: -------------------------------------------------------------------------------- 1 | import CSSParser from '../src/CSSParser' 2 | 3 | describe('CSSParser test', () => { 4 | const classTemplate = `[ 5 | { 6 | "selectors": [ 7 | { 8 | "id": "", 9 | "class": "class-div", 10 | "tagName": "" 11 | }, 12 | { 13 | "id": "", 14 | "class": "class-div2", 15 | "tagName": "" 16 | } 17 | ], 18 | "declarations": [ 19 | { 20 | "name": "font-size", 21 | "value": "14px" 22 | } 23 | ] 24 | } 25 | ]` 26 | 27 | const classTemplate2 = `[ 28 | { 29 | "selectors": [ 30 | { 31 | "id": "", 32 | "class": "", 33 | "tagName": "div" 34 | }, 35 | { 36 | "id": "", 37 | "class": "", 38 | "tagName": "*" 39 | }, 40 | { 41 | "id": "", 42 | "class": "class-div", 43 | "tagName": "" 44 | }, 45 | { 46 | "id": "id-div", 47 | "class": "", 48 | "tagName": "" 49 | } 50 | ], 51 | "declarations": [ 52 | { 53 | "name": "font-size", 54 | "value": "14px" 55 | }, 56 | { 57 | "name": "position", 58 | "value": "relative" 59 | }, 60 | { 61 | "name": "width", 62 | "value": "100%" 63 | }, 64 | { 65 | "name": "height", 66 | "value": "100%" 67 | }, 68 | { 69 | "name": "background", 70 | "value": "rgba(0, 0, 0, 1)" 71 | }, 72 | { 73 | "name": "margin-bottom", 74 | "value": "20px" 75 | } 76 | ] 77 | }, 78 | { 79 | "selectors": [ 80 | { 81 | "id": "", 82 | "class": "", 83 | "tagName": "body" 84 | } 85 | ], 86 | "declarations": [ 87 | { 88 | "name": "font-size", 89 | "value": "88px" 90 | }, 91 | { 92 | "name": "color", 93 | "value": "#000" 94 | } 95 | ] 96 | } 97 | ]` 98 | 99 | test('parse css class', () => { 100 | const parser = new CSSParser() 101 | const parseResult = JSON.stringify( 102 | parser.parse(` 103 | .class-div, 104 | .class-div2 { 105 | font-size: 14px; 106 | } 107 | `), 108 | null, 109 | 4 110 | ) 111 | 112 | expect(classTemplate).toBe(parseResult) 113 | }) 114 | 115 | test('parse css all', () => { 116 | const parser = new CSSParser() 117 | 118 | const parseResult = JSON.stringify( 119 | parser.parse(` 120 | div, 121 | *, 122 | .class-div, 123 | #id-div { 124 | font-size: 14px; 125 | position: relative; 126 | width: 100%; 127 | height: 100%; 128 | background: rgba(0, 0, 0, 1); 129 | margin-bottom: 20px; 130 | } 131 | 132 | body { 133 | font-size: 88px; 134 | color: #000; 135 | } 136 | `), 137 | null, 138 | 4 139 | ) 140 | 141 | expect(classTemplate2).toBe(parseResult) 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /tests/HTMLParser.spec.ts: -------------------------------------------------------------------------------- 1 | import HTMLParser, { element, text } from '../src/HTMLParser' 2 | 3 | describe('HTMLParser test', () => { 4 | const htmlTemplate = `{ 5 | "tagName": "html", 6 | "attributes": {}, 7 | "children": [ 8 | { 9 | "tagName": "body", 10 | "attributes": {}, 11 | "children": [ 12 | { 13 | "tagName": "div", 14 | "attributes": {}, 15 | "children": [ 16 | { 17 | "nodeValue": "test!", 18 | "nodeType": 3 19 | } 20 | ], 21 | "nodeType": 1 22 | } 23 | ], 24 | "nodeType": 1 25 | } 26 | ], 27 | "nodeType": 1 28 | }` 29 | 30 | const htmlTemplate2 = `{ 31 | "tagName": "html", 32 | "attributes": {}, 33 | "children": [ 34 | { 35 | "tagName": "body", 36 | "attributes": {}, 37 | "children": [ 38 | { 39 | "tagName": "div", 40 | "attributes": {}, 41 | "children": [ 42 | { 43 | "nodeValue": "test test!", 44 | "nodeType": 3 45 | } 46 | ], 47 | "nodeType": 1 48 | } 49 | ], 50 | "nodeType": 1 51 | } 52 | ], 53 | "nodeType": 1 54 | }` 55 | 56 | test('parse html template', () => { 57 | const parser = new HTMLParser() 58 | const parseResult = JSON.stringify(parser.parse('
test!
'), null, 4) 59 | const parseResult2 = JSON.stringify( 60 | parser.parse(` 61 | 62 | 63 |
test test!
64 | 65 | 66 | `), 67 | null, 68 | 4 69 | ) 70 | 71 | expect(htmlTemplate).toBe(parseResult) 72 | expect(htmlTemplate2).toBe(parseResult2) 73 | }) 74 | 75 | test('parse html object', () => { 76 | const html = element('html') 77 | const body = element('body') 78 | const div = element('div') 79 | 80 | html.children.push(body) 81 | body.children.push(div) 82 | div.children.push(text('test!')) 83 | 84 | expect(htmlTemplate).toBe(JSON.stringify(html, null, 4)) 85 | }) 86 | 87 | const htmlTemplate3 = `{ 88 | "tagName": "div", 89 | "attributes": { 90 | "class": "lightblue test", 91 | "id": "div", 92 | "data-index": "1" 93 | }, 94 | "children": [ 95 | { 96 | "nodeValue": "test!", 97 | "nodeType": 3 98 | } 99 | ], 100 | "nodeType": 1 101 | }` 102 | 103 | test('parse html attributes', () => { 104 | const parser = new HTMLParser() 105 | const parseResult = JSON.stringify( 106 | parser.parse('
test!
'), 107 | null, 108 | 4 109 | ) 110 | 111 | expect(htmlTemplate3).toBe(parseResult) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /tests/layout.spec.ts: -------------------------------------------------------------------------------- 1 | import type { StyleNode } from '../src/style' 2 | import { getStyleTree } from '../src/style' 3 | import CSSParser from '../src/CSSParser' 4 | import HTMLParser from '../src/HTMLParser' 5 | import { getLayoutTree, Dimensions } from '../src/layout' 6 | 7 | describe('style tree test', () => { 8 | test('parse html template', () => { 9 | // eslint-disable-next-line quotes 10 | const layoutTemplate = `{"dimensions":{"content":{"x":0,"y":0,"width":800,"height":420},"padding":{"top":0,"right":0,"bottom":0,"left":0},"border":{"top":0,"right":0,"bottom":0,"left":0},"margin":{"top":0,"right":0,"bottom":0,"left":0}},"boxType":"BlockNode","children":[{"dimensions":{"content":{"x":0,"y":0,"width":800,"height":420},"padding":{"top":0,"right":0,"bottom":0,"left":0},"border":{"top":0,"right":0,"bottom":0,"left":0},"margin":{"top":0,"right":0,"bottom":0,"left":0}},"boxType":"BlockNode","children":[{"dimensions":{"content":{"x":0,"y":0,"width":400,"height":400},"padding":{"top":0,"right":0,"bottom":0,"left":0},"border":{"top":0,"right":0,"bottom":0,"left":0},"margin":{"top":0,"right":400,"bottom":20,"left":0}},"boxType":"BlockNode","children":[{"dimensions":{"content":{"x":0,"y":0,"width":400,"height":0},"padding":{"top":0,"right":0,"bottom":0,"left":0},"border":{"top":0,"right":0,"bottom":0,"left":0},"margin":{"top":0,"right":0,"bottom":0,"left":0}},"boxType":"AnonymousBlock","children":[{"dimensions":{"content":{"x":0,"y":0,"width":0,"height":0},"padding":{"top":0,"right":0,"bottom":0,"left":0},"border":{"top":0,"right":0,"bottom":0,"left":0},"margin":{"top":0,"right":0,"bottom":0,"left":0}},"boxType":"InlineNode","children":[],"styleNode":{"node":{"nodeValue":"test!","nodeType":3},"values":{"font-size":"16px","color":"red"},"children":[]}}]}],"styleNode":{"node":{"tagName":"div","attributes":{"class":"lightblue test"},"children":[{"nodeValue":"test!","nodeType":3}],"nodeType":1},"values":{"font-size":"16px","color":"red","display":"block","position":"relative","width":"400px","height":"400px","background":"rgba(0, 0, 0, 1)","margin-bottom":"20px"},"children":[{"node":{"nodeValue":"test!","nodeType":3},"values":{"font-size":"16px","color":"red"},"children":[]}]}}],"styleNode":{"node":{"tagName":"body","attributes":{"id":"body","data-index":"1","style":"color: red; background: yellow;"},"children":[{"tagName":"div","attributes":{"class":"lightblue test"},"children":[{"nodeValue":"test!","nodeType":3}],"nodeType":1}],"nodeType":1},"values":{"display":"block","font-size":"88px","color":"red","background":"yellow"},"children":[{"node":{"tagName":"div","attributes":{"class":"lightblue test"},"children":[{"nodeValue":"test!","nodeType":3}],"nodeType":1},"values":{"font-size":"16px","color":"red","display":"block","position":"relative","width":"400px","height":"400px","background":"rgba(0, 0, 0, 1)","margin-bottom":"20px"},"children":[{"node":{"nodeValue":"test!","nodeType":3},"values":{"font-size":"16px","color":"red"},"children":[]}]}]}}],"styleNode":{"node":{"tagName":"html","attributes":{},"children":[{"tagName":"body","attributes":{"id":"body","data-index":"1","style":"color: red; background: yellow;"},"children":[{"tagName":"div","attributes":{"class":"lightblue test"},"children":[{"nodeValue":"test!","nodeType":3}],"nodeType":1}],"nodeType":1}],"nodeType":1},"values":{"display":"block"},"children":[{"node":{"tagName":"body","attributes":{"id":"body","data-index":"1","style":"color: red; background: yellow;"},"children":[{"tagName":"div","attributes":{"class":"lightblue test"},"children":[{"nodeValue":"test!","nodeType":3}],"nodeType":1}],"nodeType":1},"values":{"display":"block","font-size":"88px","color":"red","background":"yellow"},"children":[{"node":{"tagName":"div","attributes":{"class":"lightblue test"},"children":[{"nodeValue":"test!","nodeType":3}],"nodeType":1},"values":{"font-size":"16px","color":"red","display":"block","position":"relative","width":"400px","height":"400px","background":"rgba(0, 0, 0, 1)","margin-bottom":"20px"},"children":[{"node":{"nodeValue":"test!","nodeType":3},"values":{"font-size":"16px","color":"red"},"children":[]}]}]}]}}` 11 | 12 | const htmlParser = new HTMLParser() 13 | const cssParser = new CSSParser() 14 | 15 | const domTree = htmlParser.parse(` 16 | 17 | 18 |
test!
19 | 20 | 21 | `) 22 | 23 | const cssRules = cssParser.parse(` 24 | * { 25 | display: block; 26 | } 27 | 28 | div { 29 | font-size: 14px; 30 | position: relative; 31 | width: 400px; 32 | height: 400px; 33 | background: rgba(0, 0, 0, 1); 34 | margin-bottom: 20px; 35 | display: block; 36 | } 37 | 38 | .lightblue { 39 | font-size: 16px; 40 | display: block; 41 | } 42 | 43 | body { 44 | display: block; 45 | font-size: 88px; 46 | color: #000; 47 | } 48 | `) 49 | 50 | const dimensions = new Dimensions() 51 | dimensions.content.width = 800 52 | dimensions.content.height = 600 53 | const parseResult = JSON.stringify(getLayoutTree(getStyleTree(domTree, cssRules) as StyleNode, dimensions)) 54 | 55 | expect(layoutTemplate).toBe(parseResult) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /tests/style.spec.ts: -------------------------------------------------------------------------------- 1 | import { getStyleTree } from '../src/style' 2 | import CSSParser from '../src/CSSParser' 3 | import HTMLParser from '../src/HTMLParser' 4 | 5 | describe('style tree test', () => { 6 | test('parse html template', () => { 7 | const styleTemplate = `{ 8 | "node": { 9 | "tagName": "html", 10 | "attributes": {}, 11 | "children": [ 12 | { 13 | "tagName": "body", 14 | "attributes": { 15 | "id": "body", 16 | "data-index": "1", 17 | "style": "color: red; background: yellow;" 18 | }, 19 | "children": [ 20 | { 21 | "tagName": "div", 22 | "attributes": { 23 | "class": "lightblue test" 24 | }, 25 | "children": [ 26 | { 27 | "nodeValue": "test!", 28 | "nodeType": 3 29 | } 30 | ], 31 | "nodeType": 1 32 | } 33 | ], 34 | "nodeType": 1 35 | } 36 | ], 37 | "nodeType": 1 38 | }, 39 | "values": { 40 | "display": "block" 41 | }, 42 | "children": [ 43 | { 44 | "node": { 45 | "tagName": "body", 46 | "attributes": { 47 | "id": "body", 48 | "data-index": "1", 49 | "style": "color: red; background: yellow;" 50 | }, 51 | "children": [ 52 | { 53 | "tagName": "div", 54 | "attributes": { 55 | "class": "lightblue test" 56 | }, 57 | "children": [ 58 | { 59 | "nodeValue": "test!", 60 | "nodeType": 3 61 | } 62 | ], 63 | "nodeType": 1 64 | } 65 | ], 66 | "nodeType": 1 67 | }, 68 | "values": { 69 | "display": "block", 70 | "font-size": "88px", 71 | "color": "red", 72 | "background": "yellow" 73 | }, 74 | "children": [ 75 | { 76 | "node": { 77 | "tagName": "div", 78 | "attributes": { 79 | "class": "lightblue test" 80 | }, 81 | "children": [ 82 | { 83 | "nodeValue": "test!", 84 | "nodeType": 3 85 | } 86 | ], 87 | "nodeType": 1 88 | }, 89 | "values": { 90 | "font-size": "16px", 91 | "color": "red", 92 | "display": "block", 93 | "position": "relative", 94 | "width": "100%", 95 | "height": "100%", 96 | "background": "rgba(0, 0, 0, 1)", 97 | "margin-bottom": "20px" 98 | }, 99 | "children": [ 100 | { 101 | "node": { 102 | "nodeValue": "test!", 103 | "nodeType": 3 104 | }, 105 | "values": { 106 | "font-size": "16px", 107 | "color": "red" 108 | }, 109 | "children": [] 110 | } 111 | ] 112 | } 113 | ] 114 | } 115 | ] 116 | }` 117 | 118 | const htmlParser = new HTMLParser() 119 | const cssParser = new CSSParser() 120 | 121 | const domTree = htmlParser.parse(` 122 | 123 | 124 |
test!
125 | 126 | 127 | `) 128 | 129 | const cssRules = cssParser.parse(` 130 | * { 131 | display: block; 132 | } 133 | 134 | div { 135 | font-size: 14px; 136 | position: relative; 137 | width: 100%; 138 | height: 100%; 139 | background: rgba(0, 0, 0, 1); 140 | margin-bottom: 20px; 141 | } 142 | 143 | .lightblue { 144 | font-size: 16px; 145 | } 146 | 147 | body { 148 | font-size: 88px; 149 | color: #000; 150 | } 151 | `) 152 | 153 | const parseResult = JSON.stringify(getStyleTree(domTree, cssRules), null, 4) 154 | 155 | expect(styleTemplate).toBe(parseResult) 156 | }) 157 | }) 158 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "emitDeclarationOnly": true, 6 | "outDir": "temp", 7 | "declarationDir": "temp" 8 | }, 9 | "exclude": [ 10 | "rollup.config.ts", 11 | "src/test.ts", 12 | ], 13 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "importHelpers": true, 9 | "experimentalDecorators": true, 10 | "strictFunctionTypes": false, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "isolatedModules": true, 14 | "allowSyntheticDefaultImports": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "sourceMap": false, 17 | "baseUrl": ".", 18 | "allowJs": false, 19 | "resolveJsonModule": true, 20 | "lib": ["dom", "esnext"], 21 | "incremental": true, 22 | "types": [ 23 | "node", 24 | ], 25 | "typeRoots": ["./node_modules/@types/", "./types/"] 26 | }, 27 | "include": [ 28 | "src/**/*", 29 | "./rollup.config.ts", 30 | "types/**/*" 31 | ], 32 | } 33 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | type AnyObject = Record 2 | --------------------------------------------------------------------------------