├── .eslintrc.json ├── .github └── FUNDING.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE.txt ├── dist └── main-require.cjs ├── fonts ├── Apache License.txt └── OpenSans-Regular.ttf ├── index.cjs ├── main-module.js ├── package-lock.json ├── package.json ├── readme.md ├── src ├── config.js ├── dom │ ├── Attr.js │ ├── CDATASection.js │ ├── CharacterData.js │ ├── Comment.js │ ├── CustomEvent.js │ ├── Document.js │ ├── DocumentFragment.js │ ├── DocumentType.js │ ├── Element.js │ ├── Event.js │ ├── EventTarget.js │ ├── Node.js │ ├── NodeFilter.js │ ├── Text.js │ ├── Window.js │ ├── html │ │ ├── HTMLElement.js │ │ ├── HTMLImageElement.js │ │ ├── HTMLLinkElement.js │ │ ├── HTMLParser.js │ │ └── HTMLScriptElement.js │ ├── mixins │ │ ├── ChildNode.js │ │ ├── NonDocumentTypeChildNode.js │ │ ├── NonElementParentNode.js │ │ ├── ParentNode.js │ │ └── elementAccess.js │ └── svg │ │ ├── SVGAnimatedLength.js │ │ ├── SVGCircleElement.js │ │ ├── SVGElement.js │ │ ├── SVGEllipseElement.js │ │ ├── SVGForeignObjectElement.js │ │ ├── SVGGraphicsElement.js │ │ ├── SVGImageElement.js │ │ ├── SVGLength.js │ │ ├── SVGLineElement.js │ │ ├── SVGMatrix.js │ │ ├── SVGPathElement.js │ │ ├── SVGPoint.js │ │ ├── SVGRectElement.js │ │ ├── SVGSVGElement.js │ │ └── SVGTextContentElement.js ├── factories.js ├── other │ ├── Box.js │ ├── CssQuery.js │ └── Point.js └── utils │ ├── NodeIterator.js │ ├── PointCloud.js │ ├── bboxUtils.js │ ├── defaults.js │ ├── mapUtils.js │ ├── namespaces.js │ ├── nodesToNode.js │ ├── objectCreationUtils.js │ ├── pathUtils.js │ ├── regex.js │ ├── strUtils.js │ ├── tagUtils.js │ └── textUtils.js └── test ├── 001-svg-dom.js ├── 002_escaped-text.js ├── 003-unsescape-bbox.js ├── 004-circle-length.js ├── 005-svg-length.js ├── 006-svg-rect-element.js ├── 007-append-prepend.js ├── 008-before-after-replaceWith-remove.js ├── 009-selectors.js ├── 010-bbox-text.js └── mocha.opts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "plugins": [ "sort-class-members" ], 4 | "rules": { 5 | "operator-linebreak": [ "error", "before" ], 6 | "object-curly-spacing": [ "error", "always" ], 7 | "array-bracket-spacing": [ "error", "always" ], 8 | "indent": [ "error", 2, { "flatTernaryExpressions": true } ], 9 | "padded-blocks": "off", 10 | "sort-class-members/sort-class-members": [ 2, { 11 | "order": [ 12 | "[static-properties]", 13 | "[properties]", 14 | "[conventional-private-properties]", 15 | "constructor", 16 | "[static-methods]", 17 | "[methods]", 18 | "[conventional-private-methods]", 19 | "[accessor-pairs]", 20 | "[getters]", 21 | "[setters]", 22 | "[everything-else]" 23 | ], 24 | "groups": { 25 | "constructor": [{ 26 | "name": "constructor", 27 | "type": "method", 28 | "sort": "alphabetical" 29 | }], 30 | "properties": [{ 31 | "type": "property", 32 | "sort": "alphabetical" 33 | }], 34 | "getters": [{ 35 | "kind": "get", 36 | "sort": "alphabetical" 37 | }], 38 | "setters": [{ 39 | "kind": "set", 40 | "sort": "alphabetical" 41 | }], 42 | "accessor-pairs": [{ 43 | "accessorPair": true, 44 | "sort": "alphabetical" 45 | }], 46 | "static-properties": [{ 47 | "type": "property", 48 | "static": true, 49 | "sort": "alphabetical" 50 | }], 51 | "conventional-private-properties": [{ 52 | "type": "property", 53 | "name": "/_.+/", 54 | "sort": "alphabetical" 55 | }], 56 | "arrow-function-properties": [{ 57 | "propertyType": "ArrowFunctionExpression", 58 | "sort": "alphabetical" 59 | }], 60 | "methods": [{ 61 | "type": "method", 62 | "sort": "alphabetical" 63 | }], 64 | "static-methods": [{ 65 | "type": "method", 66 | "static": true, 67 | "sort": "alphabetical" 68 | }], 69 | "async-methods": [{ 70 | "type": "method", 71 | "async": true, 72 | "sort": "alphabetical" 73 | }], 74 | "conventional-private-methods": [{ 75 | "type": "method", 76 | "name": "/_.+/", 77 | "sort": "alphabetical" 78 | }], 79 | "everything-else": [{ 80 | "sort": "alphabetical" 81 | }] 82 | } 83 | }] 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [fuzzyma] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | node_modules/ 4 | fonts/* 5 | !fonts/OpenSans-Regular.ttf 6 | !fonts/Apache License.txt 7 | spec/ 8 | /index.js 9 | /index.cjs 10 | .reify-cache 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "program": "${workspaceFolder}\\index.cjs" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.renderWhitespace": "all", 4 | "editor.formatOnSave": false, 5 | "eslint.enable": true, 6 | "eslint.quiet": true, 7 | "eslint.validate": [ 8 | "javascript" 9 | ], 10 | "editor.codeActionsOnSave": { 11 | "source.fixAll.eslint": "explicit" 12 | }, 13 | "eslint.alwaysShowStatus": true, 14 | "editor.renderControlCharacters": true, 15 | "editor.insertSpaces": true, 16 | "[javascript]": { 17 | "files.encoding": "utf8", 18 | "files.eol": "\n" 19 | }, 20 | } -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2023 Ulrich-Matthias Schäfer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /fonts/Apache License.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /fonts/OpenSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/svgdotjs/svgdom/22bcf20ae81b6134278690a1b452b44b6040f8bb/fonts/OpenSans-Regular.ttf -------------------------------------------------------------------------------- /index.cjs: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | // const a = require('.') 3 | const { SVG, registerWindow } = require('../svg.js/dist/svg.node.cjs') 4 | 5 | const main = async () => { 6 | const { createSVGWindow, config } = await import('./main-module.js') 7 | 8 | config 9 | .setFontDir('./fonts') 10 | .setFontFamilyMappings({ OpenSans: 'OpenSans-Regular.ttf' }) 11 | .preloadFonts() 12 | 13 | // fs.readFile('./squares.svg', 'utf8', (err, data) => { 14 | // if (err) { 15 | // console.error(err) 16 | // return 17 | // } 18 | // const window = createSVGWindow() 19 | // const document = window.document 20 | // registerWindow(window, document) 21 | // const svgDoc = SVG(document.documentElement) 22 | // svgDoc.svg(data) 23 | // const mygroup = svgDoc.findOne('#layer1') 24 | // console.log(mygroup.svg()) 25 | // console.log(mygroup.cx(), mygroup.cy()) 26 | // }) 27 | 28 | // const { createSVGWindow, setFontDir, setFontFamilyMappings } = require('./main-require.cjs') 29 | // const svgjs = require('../svg.js/dist/svg.node.js') 30 | 31 | // const { SVG, registerWindow } = svgjs 32 | 33 | const window = createSVGWindow() 34 | const document = window.document 35 | 36 | // setFontDir('./fonts') 37 | // setFontFamilyMappings({ 38 | // Calibri2: 'calibri.ttf', 39 | // Arial2: 'arial.ttf', 40 | // Comic2: 'comic.ttf', 41 | // Coop2: 'COOPBL.TTF', 42 | // Finale2: 'FinaleCopyistText.ttf', 43 | // Free2: 'FREESCPT.TTF', 44 | // Georgia2: 'georgia.ttf' 45 | // }) 46 | 47 | registerWindow(window, document) 48 | 49 | const canvas = SVG(document.documentElement) 50 | .size(2000, 1000) 51 | .viewbox(-300, -300, 2000, 1000) 52 | 53 | canvas.rect(100, 100).move(200, 100).size(200) 54 | 55 | console.log(canvas.svg()) 56 | 57 | } 58 | main() 59 | -------------------------------------------------------------------------------- /main-module.js: -------------------------------------------------------------------------------- 1 | import * as defaults from './src/utils/defaults.js' 2 | 3 | export * from './src/dom/Attr.js' 4 | export * from './src/dom/CharacterData.js' 5 | export * from './src/dom/Comment.js' 6 | export * from './src/dom/CustomEvent.js' 7 | export * from './src/dom/Document.js' 8 | export * from './src/dom/DocumentFragment.js' 9 | export * from './src/dom/Element.js' 10 | export * from './src/dom/Event.js' 11 | export * from './src/dom/EventTarget.js' 12 | export * from './src/dom/Node.js' 13 | export * from './src/dom/NodeFilter.js' 14 | export * from './src/dom/Text.js' 15 | export * from './src/dom/Window.js' 16 | export * from './src/dom/html/HTMLElement.js' 17 | export * from './src/dom/html/HTMLImageElement.js' 18 | export * from './src/dom/html/HTMLLinkElement.js' 19 | export * from './src/dom/html/HTMLParser.js' 20 | export * from './src/dom/html/HTMLScriptElement.js' 21 | export * from './src/dom/mixins/elementAccess.js' 22 | export * from './src/dom/mixins/ParentNode.js' 23 | export * from './src/dom/svg/SVGElement.js' 24 | export * from './src/dom/svg/SVGGraphicsElement.js' 25 | export * from './src/dom/svg/SVGMatrix.js' 26 | export * from './src/dom/svg/SVGPathElement.js' 27 | export * from './src/dom/svg/SVGPoint.js' 28 | export * from './src/dom/svg/SVGSVGElement.js' 29 | export * from './src/dom/svg/SVGTextContentElement.js' 30 | 31 | export * from './src/config.js' 32 | export * from './src/factories.js' 33 | export { defaults } 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svgdom", 3 | "version": "0.1.21", 4 | "description": "Straightforward DOM implementation for SVG, HTML and XML", 5 | "main": "./main-module.js", 6 | "exports": { 7 | "import": "./main-module.js", 8 | "require": "./main-module.js" 9 | }, 10 | "type": "module", 11 | "scripts": { 12 | "test": "mocha", 13 | "fix": "npx eslint ./ --fix", 14 | "lint": "npx eslint ./" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/svgdotjs/svgdom.git" 19 | }, 20 | "keywords": [ 21 | "svgjs", 22 | "dom", 23 | "xml", 24 | "xmldom", 25 | "svgdom", 26 | "nodejs" 27 | ], 28 | "author": "Ulrich-Matthias Schäfer", 29 | "license": "MIT", 30 | "funding": { 31 | "type": "github", 32 | "url": "https://github.com/sponsors/Fuzzyma" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/svgdotjs/svgdom/issues" 36 | }, 37 | "homepage": "https://github.com/svgdotjs/svgdom#readme", 38 | "dependencies": { 39 | "fontkit": "^2.0.4", 40 | "image-size": "^1.2.1", 41 | "sax": "^1.4.1" 42 | }, 43 | "devDependencies": { 44 | "@svgdotjs/svg.js": "^3.2.0", 45 | "@types/fontkit": "^2.0.8", 46 | "acorn": "^8.8.2", 47 | "circular-dependency-plugin": "^5.2.2", 48 | "eslint": "^8.33.0", 49 | "eslint-config-standard": "^17.0.0", 50 | "eslint-plugin-import": "^2.27.5", 51 | "eslint-plugin-node": "^11.1.0", 52 | "eslint-plugin-promise": "^6.1.1", 53 | "eslint-plugin-sort-class-members": "^1.16.0", 54 | "mocha": "^10.2.0", 55 | "reify": "^0.20.12", 56 | "webpack": "^5.75.0", 57 | "webpack-cli": "^5.0.1" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # svgdom 2 | 3 | > Straightforward DOM implementation to make SVG.js run headless on Node.js 4 | 5 | While this dom implementation was designed to run svg.js on node, it now is much more feature complete and can be used by anyone needing an xml, svg or html dom. 6 | 7 | ## Get started with svg.js v3.x 8 | 9 | *for older versions of svg.js checkout older versions of svgdom* 10 | 11 | ``` 12 | npm install @svgdotjs/svg.js svgdom 13 | ``` 14 | 15 | ```js 16 | import { createSVGWindow } from 'svgdom' 17 | import { SVG, registerWindow } from '@svgdotjs/svg.js' 18 | 19 | // returns a window with a document and an svg root node 20 | const window = createSVGWindow() 21 | const document = window.document 22 | 23 | // register window and document 24 | registerWindow(window, document) 25 | 26 | // create canvas 27 | const canvas = SVG(document.documentElement) 28 | 29 | // use svg.js as normal 30 | canvas.rect(100, 100).fill('yellow').move(50,50) 31 | 32 | // get your svg as string 33 | console.log(canvas.svg()) 34 | // or 35 | console.log(canvas.node.outerHTML) 36 | ``` 37 | 38 | ## Create an HTML Dom or XML Dom 39 | 40 | ```js 41 | // create HTML window with a document and an html root node 42 | import { createHTMLWindow } from 'svgdom' 43 | const window = createHTMLWindow() 44 | 45 | // create XML window with a document and a given xml root node 46 | import { createWindow } from 'svgdom' 47 | const window = createWindow(namespaceURI, rootNode) 48 | // e.g. createWindow('http://www.w3.org/1998/Math/MathML', 'math') 49 | ``` 50 | 51 | ## Use svgdom as cjs module 52 | 53 | svgdom is used best as esm module. However, if you still require cjs, you have to import the module via the async import function: 54 | 55 | ```js 56 | const main = async () => { 57 | const { createSVGWindow } = await import('svgdom') 58 | } 59 | main() 60 | ``` 61 | 62 | ## Fonts 63 | 64 | In order to calculate bounding boxes for text the font needs to be loaded first. `svgdom` loads `Open Sans-Regular` by default when no font file for the specified font was found. 65 | The following options must be set in order to load your own fonts: 66 | 67 | ```js 68 | import { config } from 'svgdom' 69 | config. 70 | // your font directory 71 | .setFontDir('./fonts') 72 | // map the font-family to the file 73 | .setFontFamilyMappings({'Arial': 'arial.ttf'}) 74 | // you can preload your fonts to avoid the loading delay 75 | // when the font is used the first time 76 | .preloadFonts() 77 | 78 | // Alternatively you can import the functions itself and use them 79 | const {setFontDir, setFontFamilyMappings, preloadFonts} = require('svgdom') 80 | setFontDir('./fonts') 81 | setFontFamilyMappings({'Arial': 'arial.ttf'}) 82 | preloadFonts() 83 | ``` 84 | 85 | ## Limitations 86 | Almost all functions of svg.js work properly with svgdom. However there are a few known limitations: 87 | 88 | - font properties like bold, italic... are only supported when you explicitely load that font e.g. 89 | ```js 90 | setFontFamilyMappings({'Arial-italic': 'arial_italic.ttf'}) 91 | ``` 92 | - `querySelector` only supports the following pseudo classes: 93 | - `first-child` 94 | - `last-child` 95 | - `nth-child` 96 | - `nth-last-child` 97 | - `first-of-type` 98 | - `last-of-type` 99 | - `nth-of-type` 100 | - `nth-last-of-type` 101 | - `only-child` 102 | - `only-of-type` 103 | - `root` 104 | - `not` 105 | - `matches` 106 | - `scope` 107 | - special chars in attribute values: `#` and `.` are allowed but things like `:` or `[]` will break the selector 108 | 109 | ## Using svgdom in your own projects 110 | 111 | Albeit this dom implementation aims to work with svgjs, it is of course possible to use it in your own projects. 112 | Keep in mind, that some functions are just not needed in svgjs and therefore not implemented or tested. 113 | If you need a certain feature don't hesistate to open an issue or submit a pull request. 114 | 115 | Last thing to say: **childNodes is an array!** (yet) 116 | 117 | [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=ulima.ums%40googlemail.com&lc=US&item_name=SVG.JS¤cy_code=EUR&bn=PP-DonationsBF%3Abtn_donate_74x21.png%3ANonHostedGuest) or [![Sponsor](https://img.shields.io/badge/Sponsor-svgdom-green.svg)](https://github.com/sponsors/Fuzzyma) 118 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import * as fontkit from 'fontkit' 3 | 4 | const _config = { fontFamilyMappings: {} } 5 | const fonts = {} 6 | 7 | export const setFontDir = function (dir) { 8 | _config.fontDir = dir 9 | return this 10 | } 11 | 12 | export const setFontFamilyMappings = function (map) { 13 | _config.fontFamilyMappings = map 14 | return this 15 | } 16 | 17 | // TODO: make async 18 | export const preloadFonts = () => { 19 | const map = _config.fontFamilyMappings 20 | 21 | for (const [ font, file ] of Object.entries(map)) { 22 | const filename = path.join(_config.fontDir, file) 23 | 24 | try { 25 | fonts[font] = fontkit.openSync(filename) 26 | } catch (e) { 27 | console.warn(`Could not load font file for ${font}`, e) 28 | } 29 | } 30 | return this 31 | } 32 | 33 | export const getConfig = () => _config 34 | export const getFonts = () => fonts 35 | 36 | export const config = { 37 | setFontDir, 38 | setFontFamilyMappings, 39 | preloadFonts, 40 | getConfig, 41 | getFonts 42 | } 43 | -------------------------------------------------------------------------------- /src/dom/Attr.js: -------------------------------------------------------------------------------- 1 | import { Node } from './Node.js' 2 | import { html } from '../utils/namespaces.js' 3 | 4 | export class Attr extends Node { 5 | constructor (name, props, ns) { 6 | super(name, { nodeValue: '', ...props }, ns) 7 | 8 | // Follow spec and lowercase nodeName for html 9 | this.nodeName = ns === html ? name.toLowerCase() : name 10 | this.nodeType = Node.ATTRIBUTE_NODE 11 | this.ownerElement = null 12 | } 13 | 14 | get value () { 15 | return this.nodeValue 16 | } 17 | 18 | set value (val) { 19 | this.nodeValue = val 20 | } 21 | 22 | get name () { 23 | return this.nodeName 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/dom/CDATASection.js: -------------------------------------------------------------------------------- 1 | import { Text } from './Text.js' 2 | 3 | export class CDATASection extends Text { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /src/dom/CharacterData.js: -------------------------------------------------------------------------------- 1 | import { Node } from './Node.js' 2 | import { mixin } from '../utils/objectCreationUtils.js' 3 | import { NonDocumentTypeChildNode } from './mixins/NonDocumentTypeChildNode.js' 4 | import { ChildNode } from './mixins/ChildNode.js' 5 | 6 | export class CharacterData extends Node { 7 | constructor (name, props) { 8 | super(name, props) 9 | 10 | this.data = this.nodeValue 11 | } 12 | 13 | appendData (data) { 14 | this.data += data 15 | } 16 | 17 | deleteData (offset, count) { 18 | this.data = this.data.slice(0, offset) + this.data.slice(0, offset + count) 19 | } 20 | 21 | insertData (offset, data) { 22 | this.data = this.data.slice(0, offset) + data + this.data.slice(offset) 23 | } 24 | 25 | replaceData (offset, count, data) { 26 | this.deleteData(offset, count) 27 | this.insertData(offset, data) 28 | } 29 | 30 | substringData (offset, count) { 31 | this.data = this.data.substr(offset, count) 32 | } 33 | 34 | get length () { 35 | return this.data.length 36 | } 37 | } 38 | 39 | mixin(NonDocumentTypeChildNode, CharacterData) 40 | mixin(ChildNode, CharacterData) 41 | -------------------------------------------------------------------------------- /src/dom/Comment.js: -------------------------------------------------------------------------------- 1 | import { CharacterData } from './CharacterData.js' 2 | import { Node } from './Node.js' 3 | export class Comment extends CharacterData { 4 | constructor (name, props) { 5 | super(name, props) 6 | this.nodeType = Node.COMMENT_NODE 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/dom/CustomEvent.js: -------------------------------------------------------------------------------- 1 | import { Event } from './Event.js' 2 | export class CustomEvent extends Event { 3 | constructor (name, props = {}) { 4 | super(name) 5 | this.detail = props.detail || null 6 | this.cancelable = props.cancelable || false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/dom/Document.js: -------------------------------------------------------------------------------- 1 | import { Node } from './Node.js' 2 | import { Comment } from './Comment.js' 3 | import { Text } from './Text.js' 4 | import { Attr } from './Attr.js' 5 | import { DocumentFragment } from './DocumentFragment.js' 6 | import { HTMLLinkElement } from './html/HTMLLinkElement.js' 7 | import { HTMLScriptElement } from './html/HTMLScriptElement.js' 8 | import { HTMLImageElement } from './html/HTMLImageElement.js' 9 | import { HTMLElement } from './html/HTMLElement.js' 10 | import { elementAccess } from './mixins/elementAccess.js' 11 | import { mixin } from '../utils/objectCreationUtils.js' 12 | import { SVGSVGElement } from './svg/SVGSVGElement.js' 13 | import { SVGPathElement } from './svg/SVGPathElement.js' 14 | import { SVGTextContentElement } from './svg/SVGTextContentElement.js' 15 | import { SVGGraphicsElement } from './svg/SVGGraphicsElement.js' 16 | import { ParentNode } from './mixins/ParentNode.js' 17 | import { svg, html } from '../utils/namespaces.js' 18 | import { DocumentType } from './DocumentType.js' 19 | import { NonElementParentNode } from './mixins/NonElementParentNode.js' 20 | import { SVGRectElement } from './svg/SVGRectElement.js' 21 | import { SVGCircleElement } from './svg/SVGCircleElement.js' 22 | import { SVGLineElement } from './svg/SVGLineElement.js' 23 | import { SVGEllipseElement } from './svg/SVGEllipseElement.js' 24 | import { SVGForeignObjectElement } from './svg/SVGForeignObjectElement.js' 25 | import { SVGImageElement } from './svg/SVGImageElement.js' 26 | 27 | function getChildByTagName (parent, name) { 28 | for (let child = parent.firstChild; child != null; child = child.nextSibling) { 29 | if (child.nodeType === Node.ELEMENT_NODE && child.nodeName === name) { 30 | return child 31 | } 32 | } 33 | return null 34 | } 35 | 36 | const getSVGElementForName = (name) => { 37 | switch (name.toLowerCase()) { 38 | case 'svg': 39 | return SVGSVGElement 40 | case 'path': 41 | return SVGPathElement 42 | case 'circle': 43 | return SVGCircleElement 44 | case 'ellipse': 45 | return SVGEllipseElement 46 | case 'line': 47 | return SVGLineElement 48 | case 'rect': 49 | return SVGRectElement 50 | case 'foreignObject': 51 | return SVGForeignObjectElement 52 | case 'image': 53 | return SVGImageElement 54 | case 'text': 55 | case 'tspan': 56 | case 'tref': 57 | case 'altglyph': 58 | case 'textpath': 59 | return SVGTextContentElement 60 | default: 61 | return SVGGraphicsElement 62 | } 63 | } 64 | 65 | const getHTMLElementForName = (name) => { 66 | switch (name.toLowerCase()) { 67 | case 'img': 68 | return HTMLImageElement 69 | case 'link': 70 | return HTMLLinkElement 71 | case 'script': 72 | return HTMLScriptElement 73 | default: 74 | return HTMLElement 75 | } 76 | } 77 | 78 | const getElementForNamespace = (ns, name) => { 79 | switch (ns) { 80 | case svg: 81 | return getSVGElementForName(name) 82 | case html: 83 | case null: 84 | case '': 85 | default: 86 | return getHTMLElementForName(name) 87 | } 88 | } 89 | 90 | // Feature/version pairs that DOMImplementation.hasFeature() returns true for. It returns false for anything else. 91 | const supportedFeatures = { 92 | xml: { '': true, '1.0': true, '2.0': true }, 93 | core: { '': true, '2.0': true }, 94 | html: { '': true, '1.0': true, '2.0': true }, 95 | xhtml: { '': true, '1.0': true, '2.0': true } // HTML 96 | } 97 | 98 | export const DOMImplementation = { 99 | hasFeature (feature, version) { 100 | const f = supportedFeatures[(feature || '').toLowerCase()] 101 | return (f && f[version || '']) || false 102 | }, 103 | 104 | createDocumentType (qualifiedName, publicId, systemId) { 105 | return new DocumentType(qualifiedName, { publicId, systemId, ownerDocument: this }) 106 | }, 107 | 108 | createDocument (namespace, qualifiedName, doctype) { 109 | const doc = new Document(namespace) 110 | if (doctype) { 111 | if (doctype.ownerDocument) { 112 | throw new Error('the object is in the wrong Document, a call to importNode is required') 113 | } 114 | doctype.ownerDocument = doc 115 | doc.appendChild(doctype) 116 | } 117 | if (qualifiedName) { 118 | doc.appendChild(doc.createElementNS(namespace, qualifiedName)) 119 | } 120 | return doc 121 | }, 122 | 123 | createHTMLDocument (titleText = '') { 124 | const d = new Document(html) 125 | const root = d.createElement('html') 126 | const head = d.createElement('head') 127 | const title = d.createElement('title') 128 | title.appendChild(d.createTextNode(titleText)) 129 | head.appendChild(title) 130 | root.appendChild(head) 131 | root.appendChild(d.createElement('body')) 132 | 133 | d.appendChild(root) 134 | return d 135 | } 136 | } 137 | 138 | export class Document extends Node { 139 | constructor (ns) { 140 | super('#document', {}, ns) 141 | this.nodeType = Node.DOCUMENT_NODE 142 | this.implementation = DOMImplementation 143 | this.defaultView = null 144 | } 145 | 146 | // https://dom.spec.whatwg.org/#dom-document-createattribute 147 | createAttribute (localName) { 148 | if (this.namespaceURI === html) { 149 | localName = localName.toLowerCase() 150 | } 151 | return this.createAttributeNS(null, localName, true) 152 | } 153 | 154 | createAttributeNS (ns, qualifiedName, local = false) { 155 | return new Attr(qualifiedName, { ownerDocument: this, local }, ns) 156 | } 157 | 158 | createComment (text) { 159 | return new Comment('#comment', { nodeValue: text, ownerDocument: this }) 160 | } 161 | 162 | createDocumentFragment (name) { 163 | return new DocumentFragment('#document-fragment', { ownerDocument: this }) 164 | } 165 | 166 | createElement (localName) { 167 | return this.createElementNS(this.namespaceURI, localName, true) 168 | } 169 | 170 | createElementNS (ns, qualifiedName, local = false) { 171 | const Element = getElementForNamespace(ns, qualifiedName) 172 | 173 | return new Element(qualifiedName, { 174 | ownerDocument: this, 175 | local 176 | }, ns) 177 | } 178 | 179 | createTextNode (text) { 180 | return new Text('#text', { nodeValue: text, ownerDocument: this }) 181 | } 182 | 183 | get compatMode () { 184 | return 'CSS1Compat' // always be in standards-mode 185 | } 186 | 187 | get body () { 188 | return getChildByTagName(this.documentElement, 'BODY') 189 | } 190 | 191 | get head () { 192 | return getChildByTagName(this.documentElement, 'HEAD') 193 | } 194 | 195 | get documentElement () { 196 | return this.lastChild 197 | } 198 | } 199 | 200 | mixin(elementAccess, Document) 201 | mixin(ParentNode, Document) 202 | mixin(NonElementParentNode, Document) 203 | -------------------------------------------------------------------------------- /src/dom/DocumentFragment.js: -------------------------------------------------------------------------------- 1 | import { Node } from './Node.js' 2 | import { mixin } from '../utils/objectCreationUtils.js' 3 | import { elementAccess } from './mixins/elementAccess.js' 4 | import { ParentNode } from './mixins/ParentNode.js' 5 | import { NonElementParentNode } from './mixins/NonElementParentNode.js' 6 | export class DocumentFragment extends Node { 7 | constructor (name, props) { 8 | super(name, props) 9 | this.nodeType = Node.DOCUMENT_FRAGMENT_NODE 10 | } 11 | } 12 | 13 | mixin(elementAccess, DocumentFragment) 14 | mixin(ParentNode, DocumentFragment) 15 | mixin(NonElementParentNode, DocumentFragment) 16 | -------------------------------------------------------------------------------- /src/dom/DocumentType.js: -------------------------------------------------------------------------------- 1 | import { Node } from './Node.js' 2 | import { mixin } from '../utils/objectCreationUtils.js' 3 | import { ChildNode } from './mixins/ChildNode.js' 4 | 5 | export class DocumentType extends Node { 6 | constructor (name, props) { 7 | super(name, props) 8 | 9 | this.nodeType = Node.DOCUMENT_TYPE_NODE 10 | this.name = name 11 | 12 | const { publicId, systemId } = props 13 | this.publicId = publicId || '' 14 | this.systemId = systemId || '' 15 | } 16 | } 17 | 18 | mixin(ChildNode, DocumentType) 19 | -------------------------------------------------------------------------------- /src/dom/Element.js: -------------------------------------------------------------------------------- 1 | import { Node } from './Node.js' 2 | 3 | import { ParentNode } from './mixins/ParentNode.js' 4 | import { elementAccess } from './mixins/elementAccess.js' 5 | import { HTMLParser } from './html/HTMLParser.js' 6 | import { DocumentFragment } from './DocumentFragment.js' 7 | import { mixin } from '../utils/objectCreationUtils.js' 8 | import { tag } from '../utils/tagUtils.js' 9 | import { cssToMap, mapToCss } from '../utils/mapUtils.js' 10 | import { hexToRGB, decamelize, htmlEntities, cdata, comment } from '../utils/strUtils.js' 11 | import { NonDocumentTypeChildNode } from './mixins/NonDocumentTypeChildNode.js' 12 | import { ChildNode } from './mixins/ChildNode.js' 13 | import { html, xml, xmlns } from '../utils/namespaces.js' 14 | 15 | const validateAndExtract = (ns, name) => { 16 | let prefix = null 17 | let localname = name 18 | 19 | if (!ns) ns = null 20 | 21 | if (name.includes(':')) { 22 | [ prefix, localname ] = name.split(':') 23 | } 24 | 25 | if (!ns && prefix) { 26 | throw new Error('Namespace Error') 27 | } 28 | 29 | if (prefix === 'xml' && ns !== xml) { 30 | throw new Error('Namespace Error') 31 | } 32 | 33 | if ((prefix === 'xmlns' || name === 'xmlns') && ns !== xmlns) { 34 | throw new Error('Namespace Error') 35 | } 36 | 37 | if (prefix !== 'xmlns' && name !== 'xmlns' && ns === xmlns) { 38 | throw new Error('Namespace Error') 39 | } 40 | 41 | return [ ns, prefix, localname ] 42 | } 43 | 44 | const getAttributeByNsAndLocalName = (el, ns, localName) => { 45 | if (!ns) ns = null 46 | return [ ...el.attrs ].find((node) => node.localName === localName && node.namespaceURI === ns) 47 | } 48 | 49 | const getAttributeByQualifiedName = (el, qualifiedName) => { 50 | if (el.namespaceURI === html && el.ownerDocument.namespaceURI === html) { 51 | qualifiedName = qualifiedName.toLowerCase() 52 | } 53 | 54 | return [ ...el.attrs ].find((node) => node.name === qualifiedName) 55 | } 56 | 57 | // This Proxy proxies all access to node.style to the css saved in the attribute 58 | const getStyleProxy = (node) => { 59 | 60 | return new Proxy(node, { 61 | get (target, key) { 62 | const styles = target.getAttribute('style') || '' 63 | const styleMap = cssToMap(styles) 64 | 65 | if (key === 'cssText') { 66 | return styles 67 | } 68 | 69 | if (key === 'setProperty') { 70 | return function (propertyName, value = '', priority = '') { 71 | node.style[propertyName] = value + (priority ? ` !${priority}` : '') 72 | } 73 | } 74 | 75 | if (key === 'getPropertyValue') { 76 | return function (propertyName) { 77 | return node.style[propertyName] ?? '' 78 | } 79 | } 80 | 81 | key = decamelize(key) 82 | if (!styleMap.has(key)) return '' 83 | 84 | return styleMap.get(key) 85 | }, 86 | set (target, key, value) { 87 | key = decamelize(key) 88 | 89 | if (key === 'css-text') { 90 | // ensure correct spacing and syntax by converting back and forth 91 | target.setAttribute('style', mapToCss(cssToMap(value))) 92 | return true 93 | } else { 94 | value = hexToRGB(value.toString()) 95 | const styles = target.getAttribute('style') || '' 96 | const styleMap = cssToMap(styles) 97 | styleMap.set(key, value) 98 | 99 | target.setAttribute('style', mapToCss(styleMap)) 100 | 101 | return true 102 | } 103 | } 104 | }) 105 | } 106 | 107 | // https://dom.spec.whatwg.org/#dom-element-setattributens 108 | export class Element extends Node { 109 | constructor (name, props, ns) { 110 | super(name, props, ns) 111 | 112 | this.style = getStyleProxy(this) 113 | this.tagName = this.nodeName 114 | } 115 | 116 | getAttribute (qualifiedName) { 117 | const attr = this.getAttributeNode(qualifiedName) 118 | return attr ? attr.value : null 119 | } 120 | 121 | getAttributeNode (qualifiedName) { 122 | return getAttributeByQualifiedName(this, qualifiedName) 123 | } 124 | 125 | getAttributeNodeNS (ns, localName) { 126 | return getAttributeByNsAndLocalName(this, ns, localName) 127 | } 128 | 129 | getAttributeNS (ns, localName) { 130 | const attr = this.getAttributeNodeNS(ns, localName) 131 | return attr ? attr.value : null 132 | } 133 | 134 | getBoundingClientRect () { 135 | throw new Error('Only implemented for SVG Elements') 136 | } 137 | 138 | hasAttribute (qualifiedName) { 139 | const attr = this.getAttributeNode(qualifiedName) 140 | return !!attr 141 | } 142 | 143 | hasAttributeNS (ns, localName) { 144 | const attr = this.getAttributeNodeNS(ns, localName) 145 | return !!attr 146 | } 147 | 148 | matches (query) { 149 | return this.matchWithScope(query, this) 150 | } 151 | 152 | removeAttribute (qualifiedName) { 153 | const attr = this.getAttributeNode(qualifiedName) 154 | if (attr) { 155 | this.removeAttributeNode(attr) 156 | } 157 | return attr 158 | } 159 | 160 | removeAttributeNode (node) { 161 | if (!this.attrs.delete(node)) throw new Error('Attribute cannot be removed because it was not found on the element') 162 | return node 163 | } 164 | 165 | // call is: d.removeAttributeNS('http://www.mozilla.org/ns/specialspace', 'align', 'center'); 166 | removeAttributeNS (ns, localName) { 167 | const attr = this.getAttributeNodeNS(ns, localName) 168 | if (attr) { 169 | this.removeAttributeNode(attr) 170 | } 171 | return attr 172 | } 173 | 174 | /* The setAttribute(qualifiedName, value) method, when invoked, must run these steps: 175 | 176 | If qualifiedName does not match the Name production in XML, then throw an "InvalidCharacterError" DOMException. 177 | 178 | If this is in the HTML namespace and its node document is an HTML document, then set qualifiedName to qualifiedName in ASCII lowercase. 179 | 180 | Let attribute be the first attribute in this’s attribute list whose qualified name is qualifiedName, and null otherwise. 181 | 182 | If attribute is null, create an attribute whose local name is qualifiedName, value is value, and node document is this’s node document, then append this attribute to this, and then return. 183 | 184 | Change attribute to value. 185 | */ 186 | setAttribute (qualifiedName, value) { 187 | // We have to do that here because we cannot check if `this` is in the correct namespace 188 | // when doing it in createAttribute 189 | if (this.namespaceURI === html && this.ownerDocument.namespaceURI === html) { 190 | qualifiedName = qualifiedName.toLowerCase() 191 | } 192 | 193 | let attr = this.getAttributeNode(qualifiedName) 194 | if (!attr) { 195 | // Because createAttribute lowercases the attribute in an html doc we have to use createAttributeNS 196 | attr = this.ownerDocument.createAttributeNS(null, qualifiedName, true) 197 | this.setAttributeNode(attr) 198 | } 199 | 200 | attr.value = value 201 | } 202 | 203 | /* 204 | Let namespace, prefix, and localName be the result of passing namespace and qualifiedName to validate and extract. 205 | 206 | Set an attribute value for this using localName, value, and also prefix and namespace. 207 | 208 | If prefix is not given, set it to null. 209 | If namespace is not given, set it to null. 210 | Let attribute be the result of getting an attribute given namespace, localName, and element. 211 | If attribute is null, create an attribute whose namespace is namespace, namespace prefix is prefix, local name is localName, value is value, and node document is element’s node document, then append this attribute to element, and then return. 212 | 213 | Change attribute to value. 214 | */ 215 | 216 | setAttributeNode (node) { 217 | this.attrs.add(node) 218 | node.ownerElement = this 219 | } 220 | 221 | // call is: d.setAttributeNS('http://www.mozilla.org/ns/specialspace', 'spec:align', 'center'); 222 | setAttributeNS (namespace, name, value) { 223 | 224 | // eslint-disable-next-line 225 | const [ ns, prefix, localName ] = validateAndExtract(namespace, name) 226 | 227 | let attr = this.getAttributeNodeNS(ns, localName) 228 | if (!attr) { 229 | attr = this.ownerDocument.createAttributeNS(ns, name) 230 | this.setAttributeNode(attr) // setAttributeNodeNS is a synonym of setAttributeNode 231 | } 232 | 233 | attr.value = value 234 | 235 | this.attrs.add(attr) 236 | } 237 | 238 | get attributes () { 239 | return [ ...this.attrs ] 240 | } 241 | 242 | get className () { 243 | return this.getAttribute('class') 244 | } 245 | 246 | set className (c) { 247 | this.setAttribute('class', c) 248 | } 249 | 250 | get id () { 251 | return this.getAttribute('id') || '' 252 | } 253 | 254 | set id (id) { 255 | this.setAttribute('id', id) 256 | } 257 | 258 | get innerHTML () { 259 | 260 | return this.childNodes.map(node => { 261 | if (node.nodeType === Node.TEXT_NODE) return htmlEntities(node.data) 262 | if (node.nodeType === Node.CDATA_SECTION_NODE) return cdata(node.data) 263 | if (node.nodeType === Node.COMMENT_NODE) return comment(node.data) 264 | return node.outerHTML 265 | }).join('') 266 | } 267 | 268 | set innerHTML (str) { 269 | while (this.firstChild) { 270 | this.removeChild(this.firstChild) 271 | } 272 | // The parser adds the html to this 273 | HTMLParser(str, this) 274 | } 275 | 276 | get outerHTML () { 277 | return tag(this) 278 | } 279 | 280 | set outerHTML (str) { 281 | const well = new DocumentFragment() 282 | HTMLParser(str, well) 283 | this.parentNode.insertBefore(well, this) 284 | this.parentNode.removeChild(this) 285 | } 286 | 287 | } 288 | 289 | mixin(ParentNode, Element) 290 | mixin(elementAccess, Element) 291 | mixin(NonDocumentTypeChildNode, Element) 292 | mixin(ChildNode, Element) 293 | -------------------------------------------------------------------------------- /src/dom/Event.js: -------------------------------------------------------------------------------- 1 | export class Event { 2 | constructor (type) { 3 | this.type = type 4 | this.cancelable = false 5 | this.defaultPrevented = false 6 | this.target = null 7 | } 8 | 9 | preventDefault () { 10 | if (this.cancelable) { 11 | this.defaultPrevented = true 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/dom/EventTarget.js: -------------------------------------------------------------------------------- 1 | const $ = Symbol('private properties') 2 | 3 | export class EventTarget { 4 | constructor () { 5 | this[$] = {} 6 | this[$].listeners = {} 7 | } 8 | 9 | addEventListener (type, callback) { 10 | if (!(type in this[$].listeners)) { 11 | this[$].listeners[type] = [] 12 | } 13 | this[$].listeners[type].push(callback) 14 | } 15 | 16 | dispatchEvent (event) { 17 | if (!(event.type in this[$].listeners)) { return true } 18 | 19 | var stack = this[$].listeners[event.type] 20 | event.target = this 21 | 22 | stack.forEach(function (el) { 23 | el(event) 24 | }) 25 | 26 | return !event.defaultPrevented 27 | } 28 | 29 | removeEventListener (type, callback) { 30 | if (!(type in this[$].listeners)) { 31 | return 32 | } 33 | 34 | var stack = this[$].listeners[type] 35 | for (var i = 0, il = stack.length; i < il; i++) { 36 | if (stack[i] === callback) { 37 | stack.splice(i, 1) 38 | return 39 | } 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/dom/Node.js: -------------------------------------------------------------------------------- 1 | import { extend, extendStatic } from '../utils/objectCreationUtils.js' 2 | 3 | import { EventTarget } from './EventTarget.js' 4 | import { cloneNode } from '../utils/tagUtils.js' 5 | import { html } from '../utils/namespaces.js' 6 | 7 | const nodeTypes = { 8 | ELEMENT_NODE: 1, 9 | ATTRIBUTE_NODE: 2, 10 | TEXT_NODE: 3, 11 | CDATA_SECTION_NODE: 4, 12 | ENTITY_REFERENCE_NODE: 5, 13 | ENTITY_NODE: 6, 14 | PROCESSING_INSTRUCTION_NODE: 7, 15 | COMMENT_NODE: 8, 16 | DOCUMENT_NODE: 9, 17 | DOCUMENT_TYPE_NODE: 10, 18 | DOCUMENT_FRAGMENT_NODE: 11, 19 | NOTATION_NODE: 12 20 | } 21 | 22 | export class Node extends EventTarget { 23 | constructor (name = '', props = {}, ns = null) { 24 | super() 25 | 26 | // If props.local is true, the element was Node was created with the non-namespace function 27 | // that means whatever was passed as name is the local name even though it might look like a prefix 28 | if (name.includes(':') && !props.local) { 29 | ;[ this.prefix, this.localName ] = name.split(':') 30 | } else { 31 | this.localName = name 32 | this.prefix = null 33 | } 34 | 35 | // Follow spec and uppercase nodeName for html 36 | this.nodeName = ns === html ? name.toUpperCase() : name 37 | 38 | this.namespaceURI = ns 39 | this.nodeType = Node.ELEMENT_NODE 40 | this.nodeValue = props.nodeValue != null ? props.nodeValue : null 41 | this.childNodes = [] 42 | 43 | this.attrs = props.attrs || new Set() 44 | 45 | this.ownerDocument = props.ownerDocument || null 46 | this.parentNode = null 47 | 48 | // this.namespaces = {} 49 | // if (this.prefix) { 50 | // this.namespaces[this.prefix] = ns 51 | // } else { 52 | // this.namespaces.default = ns 53 | // } 54 | 55 | if (props.childNodes) { 56 | for (let i = 0, il = props.childNodes.length; i < il; ++i) { 57 | this.appendChild(props.childNodes[i]) 58 | } 59 | } 60 | } 61 | 62 | appendChild (node) { 63 | return this.insertBefore(node) 64 | } 65 | 66 | cloneNode (deep = false) { 67 | const clone = cloneNode(this) 68 | 69 | if (deep) { 70 | this.childNodes.forEach(function (el) { 71 | const node = el.cloneNode(deep) 72 | clone.appendChild(node) 73 | }) 74 | } 75 | 76 | return clone 77 | } 78 | 79 | contains (node) { 80 | if (node === this) return false 81 | 82 | while (node.parentNode) { 83 | if (node === this) return true 84 | node = node.parentNode 85 | } 86 | return false 87 | } 88 | 89 | getRootNode () { 90 | if (!this.parentNode || this.nodeType === Node.DOCUMENT_NODE) return this 91 | return this.parentNode.getRootNode() 92 | } 93 | 94 | hasChildNodes () { 95 | return !!this.childNodes.length 96 | } 97 | 98 | insertBefore (node, before) { 99 | let index = this.childNodes.indexOf(before) 100 | if (index === -1) { 101 | index = this.childNodes.length 102 | } 103 | 104 | if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { 105 | let child 106 | let oldChild = before 107 | while ((child = node.childNodes.pop())) { 108 | this.insertBefore(child, oldChild) 109 | oldChild = child 110 | } 111 | return node 112 | } 113 | 114 | if (node.parentNode) { 115 | node.parentNode.removeChild(node) 116 | } 117 | 118 | node.parentNode = this 119 | // Object.setPrototypeOf(node.namespaces.prototype, this.namespaces.prototype) 120 | 121 | this.childNodes.splice(index, 0, node) 122 | return node 123 | } 124 | 125 | isDefaultNamespace (namespaceURI) { 126 | switch (this.nodeType) { 127 | case Node.ELEMENT_NODE: 128 | if (!this.prefix) { 129 | return this.namespaceURI === namespaceURI 130 | } 131 | 132 | if (this.hasAttribute('xmlns')) { 133 | return this.getAttribute('xmlns') 134 | } 135 | 136 | // EntityReferences may have to be skipped to get to it 137 | if (this.parentNode) { 138 | return this.parentNode.isDefaultNamespace(namespaceURI) 139 | } 140 | 141 | return false 142 | case Node.DOCUMENT_NODE: 143 | return this.documentElement.isDefaultNamespace(namespaceURI) 144 | case Node.ENTITY_NODE: 145 | case Node.NOTATION_NODE: 146 | case Node.DOCUMENT_TYPE_NODE: 147 | case Node.DOCUMENT_FRAGMENT_NODE: 148 | return false 149 | case Node.ATTRIBUTE_NODE: 150 | if (this.ownerElement) { 151 | return this.ownerElement.isDefaultNamespace(namespaceURI) 152 | } 153 | return false 154 | default: 155 | // EntityReferences may have to be skipped to get to it 156 | if (this.parentNode) { 157 | return this.parentNode.isDefaultNamespace(namespaceURI) 158 | } 159 | return false 160 | } 161 | } 162 | 163 | isEqualNode (node) { 164 | this.normalize() 165 | node.normalize() 166 | 167 | let bool = this.nodeName === node.nodeName 168 | bool = bool && this.localName === node.localName 169 | bool = bool && this.namespaceURI === node.namespaceURI 170 | bool = bool && this.prefix === node.prefix 171 | bool = bool && this.nodeValue === node.nodeValue 172 | 173 | bool = bool && this.childNodes.length === node.childNodes.length 174 | 175 | // dont check children recursively when the count doesnt event add up 176 | if (!bool) return false 177 | 178 | bool = bool && !this.childNodes.reduce((last, curr, index) => { 179 | return last && curr.isEqualNode(node.childNodes[index]) 180 | }, true) 181 | 182 | // FIXME: Use attr nodes 183 | /* bool = bool && ![ ...this.attrs.entries() ].reduce((last, curr, index) => { 184 | const [ key, val ] = node.attrs.entries() 185 | return last && curr[0] === key && curr[1] === val 186 | }, true) */ 187 | 188 | /* 189 | TODO: 190 | For two DocumentType nodes to be equal, the following conditions must also be satisfied: 191 | 192 | The following string attributes are equal: publicId, systemId, internalSubset. 193 | The entities NamedNodeMaps are equal. 194 | The notations NamedNodeMaps are equal. 195 | */ 196 | 197 | if (this.nodeType === Node.DOCUMENT_TYPE_NODE && node.nodeType === Node.DOCUMENT_TYPE_NODE) { 198 | bool = bool && this.publicId === node.publicId 199 | bool = bool && this.systemId === node.systemId 200 | bool = bool && this.internalSubset === node.internalSubset 201 | } 202 | 203 | return bool 204 | } 205 | 206 | isSameNode (node) { 207 | return this === node 208 | } 209 | 210 | lookupNamespacePrefix (namespaceURI, originalElement) { 211 | if (this.namespaceURI && this.namespaceURI === namespaceURI && this.prefix 212 | && originalElement.lookupNamespaceURI(this.prefix) === namespaceURI) { 213 | return this.prefix 214 | } 215 | 216 | for (const [ key, val ] of this.attrs.entries()) { 217 | if (!key.includes(':')) continue 218 | 219 | const [ attrPrefix, name ] = key.split(':') 220 | if (attrPrefix === 'xmlns' && val === namespaceURI && originalElement.lookupNamespaceURI(name) === namespaceURI) { 221 | return name 222 | } 223 | } 224 | 225 | // EntityReferences may have to be skipped to get to it 226 | if (this.parentNode) { 227 | return this.parentNode.lookupNamespacePrefix(namespaceURI, originalElement) 228 | } 229 | return null 230 | } 231 | 232 | lookupNamespaceURI (prefix) { 233 | switch (this.nodeType) { 234 | case Node.ELEMENT_NODE: 235 | if (this.namespaceURI != null && this.prefix === prefix) { 236 | // Note: prefix could be "null" in this case we are looking for default namespace 237 | return this.namespaceURI 238 | } 239 | 240 | for (const [ key, val ] of this.attrs.entries()) { 241 | if (!key.includes(':')) continue 242 | 243 | const [ attrPrefix, name ] = key.split(':') 244 | if (attrPrefix === 'xmlns' && name === prefix) { 245 | if (val != null) { 246 | return val 247 | } 248 | return null 249 | // FIXME: Look up if prefix or attrPrefix 250 | } else if (name === 'xmlns' && prefix == null) { 251 | if (val != null) { 252 | return val 253 | } 254 | return null 255 | } 256 | } 257 | 258 | // EntityReferences may have to be skipped to get to it 259 | if (this.parentNode) { 260 | return this.parentNode.lookupNamespaceURI(prefix) 261 | } 262 | return null 263 | case Node.DOCUMENT_NODE: 264 | return this.documentElement.lookupNamespaceURI(prefix) 265 | case Node.ENTITY_NODE: 266 | case Node.NOTATION_NODE: 267 | case Node.DOCUMENT_TYPE_NODE: 268 | case Node.DOCUMENT_FRAGMENT_NODE: 269 | return null 270 | case Node.ATTRIBUTE_NODE: 271 | if (this.ownerElement) { 272 | return this.ownerElement.lookupNamespaceURI(prefix) 273 | } 274 | return null 275 | default: 276 | // EntityReferences may have to be skipped to get to it 277 | if (this.parentNode) { 278 | return this.parentNode.lookupNamespaceURI(prefix) 279 | } 280 | return null 281 | } 282 | } 283 | 284 | lookupPrefix (namespaceURI) { 285 | if (!namespaceURI) { 286 | return null 287 | } 288 | 289 | const type = this.nodeType 290 | 291 | switch (type) { 292 | case Node.ELEMENT_NODE: 293 | return this.lookupNamespacePrefix(namespaceURI, this) 294 | case Node.DOCUMENT_NODE: 295 | return this.documentElement.lookupNamespacePrefix(namespaceURI) 296 | case Node.ENTITY_NODE : 297 | case Node.NOTATION_NODE: 298 | case Node.DOCUMENT_FRAGMENT_NODE: 299 | case Node.DOCUMENT_TYPE_NODE: 300 | return null // type is unknown 301 | case Node.ATTRIBUTE_NODE: 302 | if (this.ownerElement) { 303 | return this.ownerElement.lookupNamespacePrefix(namespaceURI) 304 | } 305 | return null 306 | default: 307 | // EntityReferences may have to be skipped to get to it 308 | if (this.parentNode) { 309 | return this.parentNode.lookupNamespacePrefix(namespaceURI) 310 | } 311 | return null 312 | } 313 | } 314 | 315 | normalize () { 316 | const childNodes = [] 317 | for (const node of this.childNodes) { 318 | const last = childNodes.shift() 319 | if (!last) { 320 | if (node.data) { 321 | childNodes.unshift(node) 322 | } 323 | continue 324 | } 325 | 326 | if (node.nodeType === Node.TEXT_NODE) { 327 | if (!node.data) { 328 | childNodes.unshift(last) 329 | continue 330 | } 331 | 332 | if (last.nodeType === Node.TEXT_NODE) { 333 | const merged = this.ownerDocument.createTextNode(last.data + node.data) 334 | childNodes.push(merged) 335 | continue 336 | } 337 | 338 | childNodes.push(last, node) 339 | } 340 | } 341 | 342 | childNodes.forEach(node => { 343 | node.parentNode = this 344 | }) 345 | this.childNodes = childNodes 346 | // this.childNodes = this.childNodes.forEach((textNodes, node) => { 347 | // // FIXME: If first node is an empty textnode, what do we do? -> spec 348 | // if (!textNodes) return [ node ] 349 | // var last = textNodes.pop() 350 | 351 | // if (node.nodeType === Node.TEXT_NODE) { 352 | // if (!node.data) return textNodes 353 | 354 | // if (last.nodeType === Node.TEXT_NODE) { 355 | // const merged = this.ownerDocument.createTextNode(last.data + ' ' + node.data) 356 | // textNodes.push(merged) 357 | // return textNodes.concat(merged) 358 | // } 359 | // } else { 360 | // textNodes.push(last, node) 361 | // } 362 | 363 | // return textNodes 364 | // }, null) 365 | } 366 | 367 | removeChild (node) { 368 | 369 | node.parentNode = null 370 | // Object.setPrototypeOf(node, null) 371 | const index = this.childNodes.indexOf(node) 372 | if (index === -1) return node 373 | this.childNodes.splice(index, 1) 374 | return node 375 | } 376 | 377 | replaceChild (newChild, oldChild) { 378 | const before = oldChild.nextSibling 379 | this.removeChild(oldChild) 380 | this.insertBefore(newChild, before) 381 | return oldChild 382 | } 383 | 384 | get nextSibling () { 385 | const child = this.parentNode && this.parentNode.childNodes[this.parentNode.childNodes.indexOf(this) + 1] 386 | return child || null 387 | } 388 | 389 | get previousSibling () { 390 | const child = this.parentNode && this.parentNode.childNodes[this.parentNode.childNodes.indexOf(this) - 1] 391 | return child || null 392 | } 393 | 394 | get textContent () { 395 | if (this.nodeType === Node.TEXT_NODE) return this.data 396 | if (this.nodeType === Node.CDATA_SECTION_NODE) return this.data 397 | if (this.nodeType === Node.COMMENT_NODE) return this.data 398 | 399 | return this.childNodes.reduce(function (last, current) { 400 | return last + current.textContent 401 | }, '') 402 | } 403 | 404 | set textContent (text) { 405 | if (this.nodeType === Node.TEXT_NODE || this.nodeType === Node.CDATA_SECTION_NODE || this.nodeType === Node.COMMENT_NODE) { 406 | this.data = text 407 | return 408 | } 409 | this.childNodes = [] 410 | this.appendChild(this.ownerDocument.createTextNode(text)) 411 | } 412 | 413 | get lastChild () { 414 | return this.childNodes[this.childNodes.length - 1] || null 415 | } 416 | 417 | get firstChild () { 418 | return this.childNodes[0] || null 419 | } 420 | } 421 | 422 | extendStatic(Node, nodeTypes) 423 | extend(Node, nodeTypes) 424 | -------------------------------------------------------------------------------- /src/dom/NodeFilter.js: -------------------------------------------------------------------------------- 1 | import { extendStatic } from '../utils/objectCreationUtils.js' 2 | 3 | export class NodeFilter { 4 | acceptNode () { 5 | return NodeFilter.FILTER_ACCEPT 6 | } 7 | } 8 | 9 | extendStatic(NodeFilter, { 10 | FILTER_ACCEPT: 1, 11 | FILTER_REJECT: 2, 12 | FILTER_IGNORE: 4, 13 | SHOW_ALL: -1, 14 | SHOW_ELEMENT: 1, 15 | SHOW_TEXT: 4, 16 | SHOW_ENTITY_REFERENCE: 16, 17 | SHOW_ENTITY: 32, 18 | SHOW_PROCESSING_INSTRUCTION: 64, 19 | SHOW_COMMENT: 128, 20 | SHOW_DOCUMENT: 256, 21 | SHOW_DOCUMENT_TYPE: 512, 22 | SHOW_DOCUMENT_FRAGMENT: 1024, 23 | SHOW_NOTATION: 2048 24 | }) 25 | -------------------------------------------------------------------------------- /src/dom/Text.js: -------------------------------------------------------------------------------- 1 | import { CharacterData } from './CharacterData.js' 2 | import { Node } from './Node.js' 3 | 4 | export class Text extends CharacterData { 5 | constructor (name, props) { 6 | super(name, props) 7 | this.nodeType = Node.TEXT_NODE 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/dom/Window.js: -------------------------------------------------------------------------------- 1 | import { extend } from '../utils/objectCreationUtils.js' 2 | import { EventTarget } from './EventTarget.js' 3 | import { Node } from './Node.js' 4 | import { Document } from './Document.js' 5 | import { DocumentFragment } from './DocumentFragment.js' 6 | import { Text } from './Text.js' 7 | import { CustomEvent } from './CustomEvent.js' 8 | import { Event } from './Event.js' 9 | import { Element } from './Element.js' 10 | import { Attr } from './Attr.js' 11 | import { HTMLImageElement } from './html/HTMLImageElement.js' 12 | import { HTMLLinkElement } from './html/HTMLLinkElement.js' 13 | import { HTMLScriptElement } from './html/HTMLScriptElement.js' 14 | import { HTMLElement } from './html/HTMLElement.js' 15 | import { SVGPoint } from './svg/SVGPoint.js' 16 | import { SVGMatrix } from './svg/SVGMatrix.js' 17 | import { SVGElement } from './svg/SVGElement.js' 18 | import { SVGSVGElement } from './svg/SVGSVGElement.js' 19 | import { SVGPathElement } from './svg/SVGPathElement.js' 20 | import { SVGGraphicsElement } from './svg/SVGGraphicsElement.js' 21 | import { SVGTextContentElement } from './svg/SVGTextContentElement.js' 22 | import { camelCase } from '../utils/strUtils.js' 23 | import * as defaults from '../utils/defaults.js' 24 | 25 | export class Window extends EventTarget { 26 | constructor () { 27 | super() 28 | this.document = new Document() 29 | this.document.defaultView = this 30 | this.self = this 31 | const doc = this.document 32 | this.Image = class { 33 | constructor (width, height) { 34 | const img = doc.createElement('img') 35 | if (width != null) img.setAttribute('width', width) 36 | if (height != null) img.setAttribute('height', height) 37 | return img 38 | } 39 | } 40 | } 41 | 42 | getComputedStyle (node) { 43 | return { 44 | // FIXME: Currently this function treats every given attr 45 | // as inheritable from its parents which is ofc not always true 46 | // but good enough for svg.js 47 | getPropertyValue (attr) { 48 | let value 49 | let cur = node 50 | 51 | do { 52 | value = cur.style[attr] || cur.getAttribute(attr) 53 | } while ( 54 | value == null 55 | && (cur = cur.parentNode) 56 | && cur.nodeType === 1 57 | ) 58 | 59 | return value || defaults[camelCase(attr)] || null 60 | } 61 | } 62 | } 63 | } 64 | 65 | let lastTime = 0 66 | const requestAnimationFrame = callback => { 67 | const now = new globalThis.Date().getTime() 68 | const timeToCall = Math.max(0, 16 - (now - lastTime)) 69 | return globalThis.setTimeout(() => { 70 | lastTime = now + timeToCall 71 | callback(lastTime) 72 | }, timeToCall) 73 | } 74 | 75 | const nowOffset = globalThis.Date.now() 76 | const performance = { 77 | now: () => Date.now() - nowOffset 78 | } 79 | 80 | const winProps = { 81 | Window, 82 | Document, 83 | DocumentFragment, 84 | Node, 85 | EventTarget, 86 | Text, 87 | Attr, 88 | Element, 89 | CustomEvent, 90 | Event, 91 | HTMLElement, 92 | HTMLLinkElement, 93 | HTMLScriptElement, 94 | HTMLImageElement, 95 | // Image: HTMLImageElement, // is set on construction 96 | SVGMatrix, 97 | SVGPoint, 98 | SVGElement, 99 | SVGSVGElement, 100 | SVGPathElement, 101 | SVGGraphicsElement, 102 | SVGTextContentElement, 103 | setTimeout: globalThis.setTimeout, 104 | clearTimeout: globalThis.clearTimeout, 105 | pageXOffset: 0, 106 | pageYOffset: 0, 107 | Date: globalThis.Date, 108 | requestAnimationFrame, 109 | cancelAnimationFrame: globalThis.clearTimeout, 110 | performance 111 | } 112 | 113 | extend(Window, winProps) 114 | -------------------------------------------------------------------------------- /src/dom/html/HTMLElement.js: -------------------------------------------------------------------------------- 1 | import { Element } from '../Element.js' 2 | 3 | export class HTMLElement extends Element {} 4 | -------------------------------------------------------------------------------- /src/dom/html/HTMLImageElement.js: -------------------------------------------------------------------------------- 1 | import sizeOf from 'image-size' 2 | import { Event } from '../Event.js' 3 | import { HTMLElement } from './HTMLElement.js' 4 | // import { getFileBufferFromURL } from '../../utils/fileUrlToBuffer.js' 5 | // import path from 'path' 6 | 7 | export class HTMLImageElement extends HTMLElement { 8 | constructor (...args) { 9 | super(...args) 10 | this.naturalWidth = 0 11 | this.naturalHeight = 0 12 | this.complete = false 13 | } 14 | } 15 | 16 | Object.defineProperties(HTMLImageElement.prototype, { 17 | src: { 18 | get () { 19 | return this.getAttribute('src') 20 | }, 21 | set (val) { 22 | this.setAttribute('src', val) 23 | // const url = path.resolve(this.ownerDocument.defaultView.location, val) 24 | // getFileBufferFromURL(url, (buffer) => { 25 | sizeOf(val, (err, size) => { 26 | if (err) { 27 | this.dispatchEvent(new Event('error')) 28 | return 29 | } 30 | this.naturalWidth = size.width 31 | this.naturalHeight = size.height 32 | this.complete = true 33 | this.dispatchEvent(new Event('load')) 34 | }) 35 | // }) 36 | } 37 | }, 38 | height: { 39 | get () { 40 | return this.getAttribute('height') || this.naturalHeight 41 | }, 42 | set (val) { 43 | this.setAttribute('height', val) 44 | } 45 | }, 46 | width: { 47 | get () { 48 | return this.getAttribute('width') || this.naturalWidth 49 | }, 50 | set (val) { 51 | this.setAttribute('width', val) 52 | } 53 | } 54 | }) 55 | -------------------------------------------------------------------------------- /src/dom/html/HTMLLinkElement.js: -------------------------------------------------------------------------------- 1 | import { HTMLElement } from './HTMLElement.js' 2 | 3 | export class HTMLLinkElement extends HTMLElement {} 4 | 5 | Object.defineProperties(HTMLLinkElement.prototype, { 6 | href: { 7 | get () { 8 | return this.getAttribute('href') 9 | }, 10 | set (val) { 11 | this.setAttribute('href', val) 12 | } 13 | }, 14 | rel: { 15 | get () { 16 | return this.getAttribute('rel') 17 | }, 18 | set (val) { 19 | this.setAttribute('rel', val) 20 | } 21 | }, 22 | type: { 23 | get () { 24 | return this.getAttribute('type') 25 | }, 26 | set (val) { 27 | this.setAttribute('type', val) 28 | } 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /src/dom/html/HTMLParser.js: -------------------------------------------------------------------------------- 1 | import sax from 'sax' 2 | 3 | // TODO: Its an XMLParser not HTMLParser!! 4 | export const HTMLParser = function (str, el) { 5 | let currentTag = el 6 | // const namespaces = { xmlns: el.getAttribute('xmlns') } 7 | let document = el.ownerDocument 8 | let cdata = null 9 | 10 | // sax expects a root element but we also missuse it to parse fragments 11 | if (el.nodeType !== el.DOCUMENT_NODE) { 12 | str = '' + str + '' 13 | } else { 14 | document = el 15 | } 16 | 17 | const parser = sax.parser(true, { 18 | // lowercase: true, 19 | xmlns: true, 20 | strictEntities: true 21 | }) 22 | 23 | parser.onerror = (e) => { 24 | throw e 25 | } 26 | 27 | parser.ondoctype = () => { 28 | if (currentTag !== document) { 29 | throw new Error('Doctype can only be appended to document') 30 | } 31 | currentTag.appendChild(document.implementation.createDocumentType()) 32 | } 33 | 34 | parser.ontext = (str) => currentTag.appendChild(document.createTextNode(str)) 35 | parser.oncomment = (str) => currentTag.appendChild(document.createComment(str)) 36 | 37 | // parser.onopennamespace = ns => { 38 | // namespaces[ns.prefix] = ns.uri 39 | // } 40 | // parser.onclosenamespace = ns => { 41 | // delete namespaces[ns.prefix] 42 | // } 43 | 44 | parser.onopentag = node => { 45 | if (node.name === 'svgdom:wrapper') return 46 | 47 | const attrs = node.attributes 48 | 49 | const uri = node.uri || currentTag.lookupNamespaceURI(node.prefix || null) 50 | 51 | const newElement = document.createElementNS(uri, node.name) 52 | 53 | for (const [ name, node ] of Object.entries(attrs)) { 54 | newElement.setAttributeNS(node.uri, name, node.value) 55 | } 56 | 57 | currentTag.appendChild(newElement) 58 | currentTag = newElement 59 | } 60 | 61 | parser.onclosetag = tagName => { 62 | if (tagName === 'svgdom:wrapper') return 63 | 64 | currentTag = currentTag.parentNode 65 | } 66 | 67 | parser.onopencdata = () => { 68 | cdata = document.createCDATASection('') 69 | } 70 | 71 | parser.oncdata = (str) => { 72 | cdata.appendData(str) 73 | } 74 | 75 | parser.onclosecdata = () => { 76 | currentTag.appendChild(cdata) 77 | } 78 | 79 | parser.write(str) 80 | } 81 | -------------------------------------------------------------------------------- /src/dom/html/HTMLScriptElement.js: -------------------------------------------------------------------------------- 1 | 2 | import { HTMLElement } from './HTMLElement.js' 3 | export class HTMLScriptElement extends HTMLElement {} 4 | 5 | Object.defineProperties(HTMLScriptElement.prototype, { 6 | src: { 7 | get () { 8 | return this.getAttribute('src') 9 | }, 10 | set (val) { 11 | this.setAttribute('src', val) 12 | } 13 | }, 14 | type: { 15 | get () { 16 | return this.getAttribute('type') 17 | }, 18 | set (val) { 19 | this.setAttribute('type', val) 20 | } 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /src/dom/mixins/ChildNode.js: -------------------------------------------------------------------------------- 1 | import { nodesToNode } from '../../utils/nodesToNode.js' 2 | 3 | // https://dom.spec.whatwg.org/#interface-childnode 4 | // Todo: check if this is contained in nodes or siblings are contained (viablePreviousSibling, viableNextSibling) 5 | export const ChildNode = { 6 | before (...nodes) { 7 | if (!this.parentNode) return 8 | const node = nodesToNode(nodes, this.ownerDocument) 9 | this.parentNode.insertBefore(node, this) 10 | }, 11 | after (...nodes) { 12 | if (!this.parentNode) return 13 | const node = nodesToNode(nodes, this.ownerDocument) 14 | this.parentNode.insertBefore(node, this.nextSibling) 15 | }, 16 | replaceWith (...nodes) { 17 | if (!this.parentNode) return 18 | const next = this.nextSibling 19 | const node = nodesToNode(nodes, this.ownerDocument) 20 | this.parentNode.insertBefore(node, next) 21 | this.remove() 22 | }, 23 | remove () { 24 | if (!this.parentNode) return 25 | this.parentNode.removeChild(this) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/dom/mixins/NonDocumentTypeChildNode.js: -------------------------------------------------------------------------------- 1 | export const NonDocumentTypeChildNode = { 2 | 3 | } 4 | 5 | Object.defineProperties(NonDocumentTypeChildNode, { 6 | previousElementSibling: { 7 | get () { 8 | let node 9 | while ((node = this.previousSibling)) { 10 | if (node.nodeType === node.ELEMENT_NODE) { 11 | return node 12 | } 13 | } 14 | return null 15 | } 16 | }, 17 | 18 | nextElementSibling: { 19 | get () { 20 | let node 21 | while ((node = this.nextSibling)) { 22 | if (node.nodeType === node.ELEMENT_NODE) { 23 | return node 24 | } 25 | } 26 | return null 27 | } 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /src/dom/mixins/NonElementParentNode.js: -------------------------------------------------------------------------------- 1 | import { NodeIterator } from '../../utils/NodeIterator.js' 2 | import { NodeFilter } from '../NodeFilter.js' 3 | 4 | // https://dom.spec.whatwg.org/#interface-nonelementparentnode 5 | export const NonElementParentNode = { 6 | getElementById (id) { 7 | const iter = new NodeIterator(this, NodeFilter.SHOW_ELEMENT, (node) => id === node.id ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_IGNORE, false) 8 | for (const node of iter) { 9 | return node 10 | } 11 | return null 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/dom/mixins/ParentNode.js: -------------------------------------------------------------------------------- 1 | import { CssQuery } from '../../other/CssQuery.js' 2 | import { NodeIterator } from '../../utils/NodeIterator.js' 3 | import { NodeFilter } from '../NodeFilter.js' 4 | import { nodesToNode } from '../../utils/nodesToNode.js' 5 | 6 | // https://dom.spec.whatwg.org/#parentnode 7 | const ParentNode = { 8 | matchWithScope (query, scope) { 9 | return new CssQuery(query).matches(this, scope) 10 | }, 11 | 12 | query (query, scope, single = false) { 13 | 14 | const iter = new NodeIterator(scope, NodeFilter.SHOW_ELEMENT, (node) => node.matchWithScope(query, scope) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_IGNORE, false) 15 | 16 | const nodes = [] 17 | for (const node of iter) { 18 | nodes.push(node) 19 | if (single) return nodes 20 | } 21 | 22 | return nodes 23 | }, 24 | 25 | querySelectorAll (query) { 26 | return this.query(query, this) 27 | }, 28 | 29 | querySelector (query) { 30 | return this.query(query, this, true)[0] || null 31 | }, 32 | 33 | closest (query) { 34 | const cssQuery = new CssQuery(query) 35 | let node = this 36 | while (node) { 37 | if (cssQuery.matches(node, this)) { 38 | return node 39 | } 40 | node = node.parentNode 41 | } 42 | return null 43 | }, 44 | 45 | prepend (...nodes) { 46 | const node = nodesToNode(nodes, this.ownerDocument) 47 | 48 | this.insertBefore(node, this.firstChild) 49 | }, 50 | 51 | append (...nodes) { 52 | const node = nodesToNode(nodes, this.ownerDocument) 53 | this.appendChild(node) 54 | }, 55 | 56 | replaceChildren (...nodes) { 57 | while (this.firstChild) { 58 | this.removeChild(this.firstChild) 59 | } 60 | this.append(...nodes) 61 | } 62 | } 63 | 64 | Object.defineProperties(ParentNode, { 65 | children: { 66 | get () { 67 | return this.childNodes.filter(function (node) { return node.nodeType === node.ELEMENT_NODE }) 68 | } 69 | }, 70 | firstElementChild: { 71 | get () { 72 | for (const node of this.childNodes) { 73 | if (node && node.nodeType === node.ELEMENT_NODE) { 74 | return node 75 | } 76 | } 77 | return null 78 | } 79 | }, 80 | lastElementChild: { 81 | get () { 82 | for (const node of this.childNodes.slice().reverse()) { 83 | if (node && node.nodeType === node.ELEMENT_NODE) { 84 | return node 85 | } 86 | } 87 | return null 88 | } 89 | }, 90 | childElementCount: { 91 | get () { 92 | return this.children.length 93 | } 94 | } 95 | }) 96 | 97 | export { ParentNode } 98 | -------------------------------------------------------------------------------- /src/dom/mixins/elementAccess.js: -------------------------------------------------------------------------------- 1 | import { NodeFilter } from '../NodeFilter.js' 2 | import { NodeIterator } from '../../utils/NodeIterator.js' 3 | 4 | const hasClass = (node, name) => { 5 | const classList = node.className.split(/\s+/) 6 | return classList.includes(name) 7 | } 8 | 9 | const elementAccess = { 10 | getElementsByTagName (name) { 11 | // const document = this.ownerDocument 12 | const iter = new NodeIterator(this, NodeFilter.SHOW_ELEMENT, (node) => node.nodeName === name ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_IGNORE, false) 13 | // const iter = document.createNodeIterator(this, 1, (node) => node.nodeName === name ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_IGNORE) 14 | return [ ...iter ] 15 | }, 16 | 17 | getElementsByTagNameNS (ns, name) { 18 | // const document = this.ownerDocument 19 | const iter = new NodeIterator(this, NodeFilter.SHOW_ELEMENT, (node) => node.isNamespace(ns) && node.nodeName === name ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_IGNORE, false) 20 | // const iter = document.createNodeIterator(this, 1, (node) => node.isNamespace(ns) && node.nodeName === name ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_IGNORE) 21 | return [ ...iter ] 22 | }, 23 | 24 | getElementsByClassName (name) { 25 | // const document = this.ownerDocument 26 | const iter = new NodeIterator(this, NodeFilter.SHOW_ELEMENT, (node) => hasClass(node, name) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_IGNORE, false) 27 | // const iter = document.createNodeIterator(this, 1, (node) => hasClass(node, name) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_IGNORE) 28 | return [ ...iter ] 29 | } 30 | } 31 | 32 | export { elementAccess } 33 | -------------------------------------------------------------------------------- /src/dom/svg/SVGAnimatedLength.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { SVGLength } from './SVGLength.js' 3 | 4 | export class SVGAnimatedLength { 5 | baseVal 6 | 7 | constructor(element, attributeName) { 8 | this.baseVal = new SVGLength(element, attributeName) 9 | } 10 | 11 | get animVal() { 12 | throw new Error('animVal is not implemented') 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/dom/svg/SVGCircleElement.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { SVGAnimatedLength } from './SVGAnimatedLength.js' 3 | import { SVGGraphicsElement } from './SVGGraphicsElement.js' 4 | 5 | export class SVGCircleElement extends SVGGraphicsElement { 6 | cx = new SVGAnimatedLength(this, 'cx') 7 | cy = new SVGAnimatedLength(this, 'cy') 8 | r = new SVGAnimatedLength(this, 'r') 9 | } 10 | -------------------------------------------------------------------------------- /src/dom/svg/SVGElement.js: -------------------------------------------------------------------------------- 1 | import { Element } from '../Element.js' 2 | export class SVGElement extends Element { 3 | get ownerSVGElement () { 4 | let parent = this 5 | while ((parent = parent.parentNode)) { 6 | if ('svg' == parent.nodeName) { 7 | return parent 8 | } 9 | } 10 | return null 11 | } 12 | 13 | get viewportElement () { 14 | let parent = this 15 | while ((parent = parent.parentNode)) { 16 | // TODO: and others 17 | if ([ 'svg', 'symbol' ].includes(parent.nodeName)) { 18 | return parent 19 | } 20 | } 21 | return null 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/dom/svg/SVGEllipseElement.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { SVGAnimatedLength } from './SVGAnimatedLength.js' 3 | import { SVGGraphicsElement } from './SVGGraphicsElement.js' 4 | 5 | export class SVGEllipseElement extends SVGGraphicsElement { 6 | cx = new SVGAnimatedLength(this, 'cx') 7 | cy = new SVGAnimatedLength(this, 'cy') 8 | rx = new SVGAnimatedLength(this, 'rx') 9 | ry = new SVGAnimatedLength(this, 'ry') 10 | } 11 | -------------------------------------------------------------------------------- /src/dom/svg/SVGForeignObjectElement.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { SVGAnimatedLength } from './SVGAnimatedLength.js' 4 | import { SVGGraphicsElement } from './SVGGraphicsElement.js' 5 | 6 | export class SVGForeignObjectElement extends SVGGraphicsElement { 7 | x = new SVGAnimatedLength(this, 'x') 8 | y = new SVGAnimatedLength(this, 'y') 9 | width = new SVGAnimatedLength(this, 'width') 10 | height = new SVGAnimatedLength(this, 'height') 11 | } 12 | -------------------------------------------------------------------------------- /src/dom/svg/SVGGraphicsElement.js: -------------------------------------------------------------------------------- 1 | import { SVGElement } from './SVGElement.js' 2 | import { getSegments } from '../../utils/bboxUtils.js' 3 | import * as regex from '../../utils/regex.js' 4 | import { SVGMatrix } from './SVGMatrix.js' 5 | 6 | // Map matrix array to object 7 | function arrayToMatrix (a) { 8 | return { a: a[0], b: a[1], c: a[2], d: a[3], e: a[4], f: a[5] } 9 | } 10 | 11 | export class SVGGraphicsElement extends SVGElement { 12 | // TODO: https://www.w3.org/TR/SVG2/coords.html#ComputingAViewportsTransform 13 | generateViewBoxMatrix () { 14 | // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox 15 | if (![ 'marker', 'symbol', 'pattern', 'svg', 'view' ].includes(this.nodeName)) { 16 | return new SVGMatrix() 17 | } 18 | 19 | let view = (this.getAttribute('viewBox') || '').split(regex.delimiter).map(parseFloat).filter(el => !isNaN(el)) 20 | const width = parseFloat(this.getAttribute('width')) || 0 21 | const height = parseFloat(this.getAttribute('height')) || 0 22 | const x = parseFloat(this.getAttribute('x')) || 0 23 | const y = parseFloat(this.getAttribute('y')) || 0 24 | 25 | // TODO: If no width and height is given, width and height of the outer svg element is used 26 | if (!width || !height) { 27 | return new SVGMatrix().translate(x, y) 28 | } 29 | 30 | if (view.length !== 4) { 31 | view = [ 0, 0, width, height ] 32 | } 33 | 34 | // first apply x and y if nested, then viewbox scale, then viewBox move 35 | return new SVGMatrix().translate(x, y).scale(width / view[2], height / view[3]).translate(-view[0], -view[1]) 36 | } 37 | 38 | getBBox () { 39 | return getSegments(this).bbox() 40 | } 41 | 42 | // TODO: This method actually exists on all Elements 43 | getBoundingClientRect () { 44 | // The bounding client rect takes the screen ctm of the element 45 | // and converts the bounding box with it 46 | 47 | // however, normal bounding consists of: 48 | // - all children transformed 49 | // - the viewbox of the element if available 50 | 51 | // The boundingClientRect is not affected by its own viewbox 52 | // So we apply only our own transformations and parents screenCTM 53 | 54 | let m = this.matrixify() 55 | 56 | if (this.parentNode && this.parentNode.nodeName !== '#document') { 57 | m = this.parentNode.getScreenCTM().multiply(m) 58 | } 59 | 60 | // let m = this.getScreenCTM() 61 | 62 | // There are a few extra rules regarding rbox and the element 63 | // Namely this is: 64 | // BBox is calculated as normal for container elements 65 | // Rbox is calculated with the width and height of the 66 | // This could be also true for symbols so this is a: 67 | // Todo: ... 68 | return getSegments(this, false, true).transform(m).bbox() 69 | } 70 | 71 | getCTM () { 72 | let m = this.matrixify() 73 | 74 | let node = this 75 | while ((node = node.parentNode)) { 76 | if ([ 'svg', 'symbol', 'image', 'pattern', 'marker' ].indexOf(node.nodeName) > -1) break 77 | m = m.multiply(node.matrixify()) 78 | if (node.nodeName === '#document') return this.getScreenCTM() 79 | } 80 | 81 | return node.generateViewBoxMatrix().multiply(m) 82 | } 83 | 84 | getInnerMatrix () { 85 | let m = this.matrixify() 86 | 87 | if ([ 'svg', 'symbol', 'image', 'pattern', 'marker' ].indexOf(this.nodeName) > -1) { 88 | m = this.generateViewBoxMatrix().multiply(m) 89 | } 90 | return m 91 | } 92 | 93 | getScreenCTM () { 94 | // ref: https://bugzilla.mozilla.org/show_bug.cgi?id=1344537 95 | // We follow Chromes behavior and include the viewbox in the screenCTM 96 | const m = this.getInnerMatrix() 97 | 98 | // TODO: We have to loop until document, however html elements dont have getScreenCTM implemented 99 | // they also dont have a transform attribute. Therefore we need a different way of figuring out their (css) transform 100 | if (this.parentNode && this.parentNode instanceof SVGGraphicsElement) { 101 | return this.parentNode.getScreenCTM().multiply(m) 102 | } 103 | 104 | return m 105 | } 106 | 107 | matrixify () { 108 | const matrix = (this.getAttribute('transform') || '').trim() 109 | // split transformations 110 | .split(regex.transforms).slice(0, -1).map(function (str) { 111 | // generate key => value pairs 112 | const kv = str.trim().split('(') 113 | return [ kv[0].trim(), kv[1].split(regex.delimiter).map(function (str) { return parseFloat(str.trim()) }) ] 114 | }) 115 | // merge every transformation into one matrix 116 | .reduce(function (matrix, transform) { 117 | 118 | if (transform[0] === 'matrix') return matrix.multiply(arrayToMatrix(transform[1])) 119 | return matrix[transform[0]].apply(matrix, transform[1]) 120 | 121 | }, new SVGMatrix()) 122 | 123 | return matrix 124 | } 125 | 126 | get transform () { 127 | throw new Error('Not implemented') 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /src/dom/svg/SVGImageElement.js: -------------------------------------------------------------------------------- 1 | import { SVGAnimatedLength } from './SVGAnimatedLength.js' 2 | import { SVGGraphicsElement } from './SVGGraphicsElement.js' 3 | 4 | export class SVGImageElement extends SVGGraphicsElement { 5 | x = new SVGAnimatedLength(this, 'x') 6 | y = new SVGAnimatedLength(this, 'y') 7 | width = new SVGAnimatedLength(this, 'width') 8 | height = new SVGAnimatedLength(this, 'height') 9 | } 10 | -------------------------------------------------------------------------------- /src/dom/svg/SVGLength.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // @ts-ignore 3 | import { extendStatic } from '../../utils/objectCreationUtils.js' 4 | 5 | const unitTypes = { 6 | SVG_LENGTHTYPE_UNKNOWN: 0, 7 | SVG_LENGTHTYPE_NUMBER: 1, 8 | SVG_LENGTHTYPE_PERCENTAGE: 2, 9 | SVG_LENGTHTYPE_EMS: 3, 10 | SVG_LENGTHTYPE_EXS: 4, 11 | SVG_LENGTHTYPE_PX: 5, 12 | SVG_LENGTHTYPE_CM: 6, 13 | SVG_LENGTHTYPE_MM: 7, 14 | SVG_LENGTHTYPE_IN: 8, 15 | SVG_LENGTHTYPE_PT: 9, 16 | SVG_LENGTHTYPE_PC: 10, 17 | } 18 | 19 | const unitByString = { 20 | ['']: unitTypes.SVG_LENGTHTYPE_NUMBER, 21 | ['%']: unitTypes.SVG_LENGTHTYPE_PERCENTAGE, 22 | ['em']: unitTypes.SVG_LENGTHTYPE_EMS, 23 | ['ex']: unitTypes.SVG_LENGTHTYPE_EXS, 24 | ['px']: unitTypes.SVG_LENGTHTYPE_PX, 25 | ['cm']: unitTypes.SVG_LENGTHTYPE_CM, 26 | ['mm']: unitTypes.SVG_LENGTHTYPE_MM, 27 | ['in']: unitTypes.SVG_LENGTHTYPE_IN, 28 | ['pt']: unitTypes.SVG_LENGTHTYPE_PT, 29 | ['pc']: unitTypes.SVG_LENGTHTYPE_PC, 30 | } 31 | 32 | const unitStringByConstant = new Map( 33 | Object.entries(unitByString).map(([unitString, unitConstant]) => [ 34 | unitConstant, 35 | unitString, 36 | ]) 37 | ) 38 | 39 | const unitFactors = new Map([ 40 | [unitTypes.SVG_LENGTHTYPE_NUMBER, 1], 41 | [unitTypes.SVG_LENGTHTYPE_PERCENTAGE, NaN], 42 | [unitTypes.SVG_LENGTHTYPE_EMS, NaN], 43 | [unitTypes.SVG_LENGTHTYPE_EXS, NaN], 44 | [unitTypes.SVG_LENGTHTYPE_PX, 1], 45 | [unitTypes.SVG_LENGTHTYPE_CM, 6], 46 | [unitTypes.SVG_LENGTHTYPE_MM, 96 / 25.4], 47 | [unitTypes.SVG_LENGTHTYPE_IN, 96], 48 | [unitTypes.SVG_LENGTHTYPE_PT, 4 / 3], 49 | [unitTypes.SVG_LENGTHTYPE_PC, 16], 50 | ]) 51 | 52 | const valuePattern = /^\s*([+-]?[0-9]*[.]?[0-9]+(?:e[+-]?[0-9]+)?)(em|ex|px|in|cm|mm|pt|pc|%)?\s*$/i; 53 | 54 | export class SVGLength { 55 | element 56 | attributeName 57 | 58 | /** 59 | * @param {Element} element 60 | * @param {string} attributeName 61 | */ 62 | constructor(element, attributeName) { 63 | this.element = element 64 | this.attributeName = attributeName 65 | } 66 | 67 | get unitType() { 68 | return parseValue(this.element.getAttribute(this.attributeName))[1] 69 | } 70 | 71 | get value() { 72 | const [value, unit] = parseValue( 73 | this.element.getAttribute(this.attributeName) 74 | ) 75 | return value * getUnitFactor(unit) 76 | } 77 | 78 | set value(value) { 79 | const unitFactor = getUnitFactor(this.unitType) 80 | this.element.setAttribute( 81 | this.attributeName, 82 | value / unitFactor + unitString(this) 83 | ) 84 | } 85 | 86 | get valueInSpecifiedUnits() { 87 | return parseValue(this.element.getAttribute(this.attributeName))[0] 88 | } 89 | 90 | set valueInSpecifiedUnits(value) { 91 | this.element.setAttribute(this.attributeName, value + unitString(this)) 92 | } 93 | 94 | get valueAsString() { 95 | // Do not simply use getAttribute() as this function has to return a string 96 | // that is a valid representation of the used value. 97 | return this.valueInSpecifiedUnits + unitString(this) 98 | } 99 | 100 | set valueAsString(valueString) { 101 | const [value, unit] = parseValue(valueString, false) 102 | const unitString = unitStringByConstant.get(unit) || '' 103 | this.element.setAttribute(this.attributeName, value + unitString) 104 | } 105 | } 106 | 107 | /** 108 | * @param {string|null} valueString 109 | * @param {boolean} fallback If set to `false` causes an error to be thrown if 110 | * `valueString` can not be parsed properly. Otherwise the returned value falls 111 | * back to 0 and the unit falls back to `SVG_LENGTHTYPE_NUMBER`. 112 | * @return {[number, number]} Value and unit. For unknown units, if the 113 | * attribute is not of the correct format or if the attribute is not present on 114 | * the element, value 0 and unit SVG_LENGTHTYPE_NUMBER are returned. 115 | */ 116 | function parseValue(valueString, fallback = true) { 117 | const [, rawValue, rawUnit] = (valueString || '').match(valuePattern) || [] 118 | const unit = unitByString[(rawUnit || '').toLowerCase()] 119 | if (rawValue !== undefined && unit !== undefined) { 120 | return [parseFloat(rawValue), unit] 121 | } 122 | if (fallback) { 123 | // For unknown units or unparsable attributes, browsers fall back to value 0 124 | return [0, unitTypes.SVG_LENGTHTYPE_NUMBER] 125 | } 126 | throw new Error('An invalid or illegal string was specified') 127 | } 128 | 129 | /** 130 | * @param {number} unit Unit constant 131 | */ 132 | function getUnitFactor(unit) { 133 | const unitFactor = unitFactors.get(unit) 134 | if (unitFactor === undefined) { 135 | throw new Error(unitFactor + ' is not a known unit constant') 136 | } 137 | if (isNaN(unitFactor)) { 138 | throw new Error(`Unit ${unitStringByConstant.get(unit)} is not supported`) 139 | } 140 | return unitFactor 141 | } 142 | 143 | /** 144 | * @param {SVGLength} svgLength 145 | * @return {string} 146 | */ 147 | function unitString(svgLength) { 148 | return unitStringByConstant.get(svgLength.unitType) || '' 149 | } 150 | 151 | extendStatic(SVGLength, unitTypes) 152 | -------------------------------------------------------------------------------- /src/dom/svg/SVGLineElement.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { SVGAnimatedLength } from './SVGAnimatedLength.js' 3 | import { SVGGraphicsElement } from './SVGGraphicsElement.js' 4 | 5 | export class SVGLineElement extends SVGGraphicsElement { 6 | x1 = new SVGAnimatedLength(this, 'x1') 7 | y1 = new SVGAnimatedLength(this, 'y1') 8 | x2 = new SVGAnimatedLength(this, 'x2') 9 | y2 = new SVGAnimatedLength(this, 'y2') 10 | } 11 | -------------------------------------------------------------------------------- /src/dom/svg/SVGMatrix.js: -------------------------------------------------------------------------------- 1 | const radians = function (d) { 2 | return d % 360 * Math.PI / 180 3 | } 4 | 5 | export function matrixFactory (a, b, c, d, e, f) { 6 | var r = new SVGMatrix() 7 | r.a = a 8 | r.b = b 9 | r.c = c 10 | r.d = d 11 | r.e = e 12 | r.f = f 13 | return r 14 | } 15 | 16 | export class SVGMatrix { 17 | constructor () { 18 | this.a = this.d = 1 19 | this.b = this.c = this.e = this.f = 0 20 | } 21 | 22 | inverse () { 23 | // Get the current parameters out of the matrix 24 | var a = this.a 25 | var b = this.b 26 | var c = this.c 27 | var d = this.d 28 | var e = this.e 29 | var f = this.f 30 | 31 | // Invert the 2x2 matrix in the top left 32 | var det = a * d - b * c 33 | if (!det) throw new Error('Cannot invert ' + this) 34 | 35 | // Calculate the top 2x2 matrix 36 | var na = d / det 37 | var nb = -b / det 38 | var nc = -c / det 39 | var nd = a / det 40 | 41 | // Apply the inverted matrix to the top right 42 | var ne = -(na * e + nc * f) 43 | var nf = -(nb * e + nd * f) 44 | 45 | // Construct the inverted matrix 46 | this.a = na 47 | this.b = nb 48 | this.c = nc 49 | this.d = nd 50 | this.e = ne 51 | this.f = nf 52 | 53 | return this 54 | } 55 | 56 | multiply (m) { 57 | var r = new SVGMatrix() 58 | r.a = this.a * m.a + this.c * m.b + this.e * 0 59 | r.b = this.b * m.a + this.d * m.b + this.f * 0 60 | r.c = this.a * m.c + this.c * m.d + this.e * 0 61 | r.d = this.b * m.c + this.d * m.d + this.f * 0 62 | r.e = this.a * m.e + this.c * m.f + this.e * 1 63 | r.f = this.b * m.e + this.d * m.f + this.f * 1 64 | return r 65 | } 66 | 67 | rotate (r, x, y) { 68 | r = r % 360 * Math.PI / 180 69 | return this.multiply(matrixFactory( 70 | Math.cos(r), 71 | Math.sin(r), 72 | -Math.sin(r), 73 | Math.cos(r), 74 | x ? -Math.cos(r) * x + Math.sin(r) * y + x : 0, 75 | y ? -Math.sin(r) * x - Math.cos(r) * y + y : 0 76 | )) 77 | } 78 | 79 | scale (scaleX, scaleY = scaleX) { 80 | return this.multiply(matrixFactory(scaleX, 0, 0, scaleY, 0, 0)) 81 | } 82 | 83 | skew (x, y) { 84 | return this.multiply(matrixFactory(1, Math.tan(radians(y)), Math.tan(radians(x)), 1, 0, 0)) 85 | } 86 | 87 | skewX (x) { 88 | return this.skew(x, 0) 89 | } 90 | 91 | skewY (y) { 92 | return this.skew(0, y) 93 | } 94 | 95 | toString () { 96 | return 'SVGMatrix' 97 | } 98 | 99 | translate (x = 0, y = 0) { 100 | return this.multiply(matrixFactory(1, 0, 0, 1, x, y)) 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /src/dom/svg/SVGPathElement.js: -------------------------------------------------------------------------------- 1 | import { SVGGraphicsElement } from './SVGGraphicsElement.js' 2 | import * as pathUtils from '../../utils/pathUtils.js' 3 | 4 | export class SVGPathElement extends SVGGraphicsElement { 5 | getPointAtLength (len) { 6 | return pathUtils.pointAtLength(this.getAttribute('d'), len) 7 | } 8 | 9 | getTotalLength () { 10 | return pathUtils.length(this.getAttribute('d')) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/dom/svg/SVGPoint.js: -------------------------------------------------------------------------------- 1 | export class SVGPoint { 2 | constructor () { 3 | this.x = 0 4 | this.y = 0 5 | } 6 | 7 | matrixTransform (m) { 8 | var r = new SVGPoint() 9 | r.x = m.a * this.x + m.c * this.y + m.e * 1 10 | r.y = m.b * this.x + m.d * this.y + m.f * 1 11 | return r 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/dom/svg/SVGRectElement.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { SVGGraphicsElement } from './SVGGraphicsElement.js' 3 | import { SVGAnimatedLength } from './SVGAnimatedLength.js' 4 | 5 | export class SVGRectElement extends SVGGraphicsElement { 6 | x = new SVGAnimatedLength(this, 'x') 7 | y = new SVGAnimatedLength(this, 'y') 8 | width = new SVGAnimatedLength(this, 'width') 9 | height = new SVGAnimatedLength(this, 'height') 10 | rx = new SVGAnimatedLength(this, 'rx') 11 | ry = new SVGAnimatedLength(this, 'ry') 12 | } 13 | -------------------------------------------------------------------------------- /src/dom/svg/SVGSVGElement.js: -------------------------------------------------------------------------------- 1 | import { SVGGraphicsElement } from './SVGGraphicsElement.js' 2 | import { Box } from '../../other/Box.js' 3 | import { SVGMatrix } from './SVGMatrix.js' 4 | import { SVGPoint } from './SVGPoint.js' 5 | 6 | export class SVGSVGElement extends SVGGraphicsElement { 7 | createSVGMatrix () { 8 | return new SVGMatrix() 9 | } 10 | 11 | createSVGPoint () { 12 | return new SVGPoint() 13 | } 14 | 15 | createSVGRect () { 16 | return new Box() 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/dom/svg/SVGTextContentElement.js: -------------------------------------------------------------------------------- 1 | import { SVGAnimatedLength } from './SVGAnimatedLength.js' 2 | import { SVGGraphicsElement } from './SVGGraphicsElement.js' 3 | 4 | export class SVGTextContentElement extends SVGGraphicsElement { 5 | textWidth = new SVGAnimatedLength(this, 'textWidth') 6 | 7 | getComputedTextLength () { 8 | return this.getBBox().width 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/factories.js: -------------------------------------------------------------------------------- 1 | import { Window } from './dom/Window.js' 2 | import { DOMImplementation } from './dom/Document.js' 3 | import * as namespaces from './utils/namespaces.js' 4 | 5 | const { createDocument, createHTMLDocument } = DOMImplementation 6 | 7 | const createWindow = (...args) => { 8 | const window = new Window() 9 | const document = createDocument(...args) 10 | window.document = document 11 | document.defaultView = window 12 | return window 13 | } 14 | 15 | const createHTMLWindow = (title) => { 16 | const window = new Window() 17 | const document = DOMImplementation.createHTMLDocument(title) 18 | window.document = document 19 | document.defaultView = window 20 | return window 21 | } 22 | 23 | const createSVGWindow = () => { 24 | return createWindow(namespaces.svg, 'svg') 25 | } 26 | 27 | const createSVGDocument = () => { 28 | return createDocument(namespaces.svg, 'svg') 29 | } 30 | 31 | export { 32 | createDocument, 33 | createHTMLDocument, 34 | createSVGDocument, 35 | createWindow, 36 | createHTMLWindow, 37 | createSVGWindow 38 | } 39 | -------------------------------------------------------------------------------- /src/other/Box.js: -------------------------------------------------------------------------------- 1 | import * as regex from '../utils/regex.js' 2 | import { Point } from './Point.js' 3 | 4 | export class Box { 5 | constructor (source) { 6 | var base = [ 0, 0, 0, 0 ] 7 | source = typeof source === 'string' ? source.split(regex.delimiter).map(parseFloat) 8 | : Array.isArray(source) ? source 9 | : typeof source === 'object' ? [ 10 | source.left != null ? source.left : source.x, 11 | source.top != null ? source.top : source.y, 12 | source.width, 13 | source.height 14 | ] 15 | : arguments.length === 4 ? [].slice.call(arguments) 16 | : base 17 | 18 | this.x = this.left = source[0] 19 | this.y = this.top = source[1] 20 | this.width = source[2] 21 | this.height = source[3] 22 | this.right = this.left + this.width 23 | this.bottom = this.top + this.height 24 | } 25 | 26 | // Merge rect box with another, return a new instance 27 | merge (box) { 28 | if (box instanceof NoBox) return new Box(this) 29 | 30 | var x = Math.min(this.x, box.x) 31 | var y = Math.min(this.y, box.y) 32 | 33 | return new Box( 34 | x, y, 35 | Math.max(this.x + this.width, box.x + box.width) - x, 36 | Math.max(this.y + this.height, box.y + box.height) - y 37 | ) 38 | } 39 | 40 | transform (m) { 41 | var xMin = Infinity 42 | var xMax = -Infinity 43 | var yMin = Infinity 44 | var yMax = -Infinity 45 | 46 | var pts = [ 47 | new Point(this.x, this.y), 48 | new Point(this.x + this.width, this.y), 49 | new Point(this.x, this.y + this.height), 50 | new Point(this.x + this.width, this.y + this.height) 51 | ] 52 | 53 | pts.forEach(function (p) { 54 | p = p.transform(m) 55 | xMin = Math.min(xMin, p.x) 56 | xMax = Math.max(xMax, p.x) 57 | yMin = Math.min(yMin, p.y) 58 | yMax = Math.max(yMax, p.y) 59 | }) 60 | 61 | return new Box( 62 | xMin, yMin, 63 | xMax - xMin, 64 | yMax - yMin 65 | ) 66 | } 67 | } 68 | 69 | export class NoBox extends Box { 70 | // NoBox has no valid values so it cant be merged 71 | merge (box) { 72 | return box instanceof NoBox ? new NoBox() : new Box(box) 73 | } 74 | 75 | transform (m) { 76 | return new NoBox() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/other/CssQuery.js: -------------------------------------------------------------------------------- 1 | import { removeQuotes, splitNotInBrackets } from '../utils/strUtils.js' 2 | import * as regex from '../utils/regex.js' 3 | import { html } from '../utils/namespaces.js' 4 | 5 | export class CssQuery { 6 | constructor (query) { 7 | if (CssQuery.cache.has(query)) { 8 | this.queries = CssQuery.cache.get(query) 9 | return 10 | } 11 | 12 | let queries = splitNotInBrackets(query, ',') 13 | 14 | queries = queries.map(query => { 15 | 16 | let roundBrackets = 0 17 | let squareBrackets = 0 18 | 19 | // this is the same as above but easier 20 | query = query.replace(/[()[\]>~+]/g, function (ch) { 21 | if (ch === '(') ++roundBrackets 22 | else if (ch === ')') --roundBrackets 23 | else if (ch === '[') ++squareBrackets 24 | else if (ch === ']') --squareBrackets 25 | 26 | if ('()[]'.indexOf(ch) > -1) return ch 27 | if (squareBrackets || roundBrackets) return ch 28 | 29 | return ' ' + ch + ' ' 30 | }) 31 | 32 | // split at space and remove empty results 33 | query = splitNotInBrackets(query, ' ').filter(el => !!el.length) 34 | 35 | const pairs = [] 36 | 37 | let relation = '%' 38 | 39 | // generate querynode relation tuples 40 | for (let i = 0, il = query.length; i < il; ++i) { 41 | 42 | if ('>~+%'.indexOf(query[i]) > -1) { 43 | relation = query[i] 44 | continue 45 | } 46 | 47 | pairs.push([ relation, query[i] ]) 48 | relation = '%' 49 | 50 | } 51 | 52 | return pairs 53 | 54 | }) 55 | 56 | this.queries = queries 57 | 58 | // to prevent memory leaks we have to manage our cache. 59 | // we delete everything which is older than 50 entries 60 | if (CssQuery.cacheKeys.length > 50) { 61 | CssQuery.cache.delete(CssQuery.cacheKeys.shift()) 62 | } 63 | CssQuery.cache.set(query, queries) 64 | CssQuery.cacheKeys.push(query) 65 | 66 | } 67 | 68 | matches (node, scope) { 69 | for (let i = this.queries.length; i--;) { 70 | if (this.matchHelper(this.queries[i], node, scope)) { 71 | return true 72 | } 73 | } 74 | return false 75 | } 76 | 77 | matchHelper (query, node, scope) { 78 | query = query.slice() 79 | const last = query.pop() 80 | 81 | if (!new CssQueryNode(last[1]).matches(node, scope)) { return false } 82 | 83 | if (!query.length) return true 84 | 85 | if (last[0] === ',') return true 86 | 87 | if (last[0] === '+') { 88 | return !!node.previousSibling && this.matchHelper(query, node.previousSibling, scope) 89 | } 90 | 91 | if (last[0] === '>') { 92 | return !!node.parentNode && this.matchHelper(query, node.parentNode, scope) 93 | } 94 | 95 | if (last[0] === '~') { 96 | while ((node = node.previousSibling)) { 97 | if (this.matchHelper(query, node, scope)) { return true } 98 | } 99 | return false 100 | } 101 | 102 | if (last[0] === '%') { 103 | while ((node = node.parentNode)) { 104 | if (this.matchHelper(query, node, scope)) { return true } 105 | } 106 | return false 107 | } 108 | 109 | } 110 | } 111 | 112 | CssQuery.cache = new Map() 113 | CssQuery.cacheKeys = [] 114 | 115 | // check if [node] is the [nth] child of [arr] where nth can also be a formula 116 | const nth = (node, arr, nth) => { 117 | 118 | if (nth === 'even') nth = '2n' 119 | else if (nth === 'odd') nth = '2n+1' 120 | 121 | // check for eval chars 122 | if (/[^\d\-n+*/]+/.test(nth)) return false 123 | 124 | nth = nth.replace('n', '*n') 125 | 126 | // eval nth to get the index 127 | for (var i, n = 0, nl = arr.length; n < nl; ++n) { 128 | /* eslint no-eval: off */ 129 | i = eval(nth) 130 | 131 | if (i > nl) break 132 | if (arr[i - 1] === node) return true 133 | } 134 | 135 | return false 136 | } 137 | 138 | const lower = a => a.toLowerCase() 139 | 140 | // checks if a and b are equal. Is insensitive when i is true 141 | const eq = (a, b, i) => i ? lower(a) === lower(b) : a === b 142 | 143 | // [i] (prebound) is true if insensitive matching is required 144 | // [a] (prebound) is the value the attr is compared to 145 | // [b] (passed) is the value of the attribute 146 | const attributeMatcher = { 147 | '=': (i, a, b) => eq(a, b, i), 148 | '~=': (i, a, b) => b.split(regex.delimiter).filter(el => eq(el, a, i)).length > 0, 149 | '|=': (i, a, b) => eq(b.split(regex.delimiter)[0], a, i), 150 | '^=': (i, a, b) => i ? lower(b).startsWith(lower(a)) : b.startsWith(a), 151 | '$=': (i, a, b) => i ? lower(b).endsWith(lower(a)) : b.endsWith(a), 152 | '*=': (i, a, b) => i ? lower(b).includes(lower(a)) : b.includes(a), 153 | '*': (i, a, b) => b != null 154 | } 155 | 156 | const getAttributeValue = (prefix, name, node) => { 157 | if (!prefix || prefix === '*') { 158 | return node.getAttribute(name) 159 | } 160 | return node.getAttribute(prefix + ':' + name) 161 | } 162 | 163 | // [a] (prebound) [a]rgument of the pseudo selector 164 | // [n] (passed) [n]ode 165 | // [s] (passed) [s]cope - the element this query is scoped to 166 | const pseudoMatcher = { 167 | 'first-child': (a, n) => n.parentNode && n.parentNode.firstChild === n, 168 | 'last-child': (a, n) => n.parentNode && n.parentNode.lastChild === n, 169 | 'nth-child': (a, n) => n.parentNode && nth(n, n.parentNode.childNodes, a), 170 | 'nth-last-child': (a, n) => n.parentNode && nth(n, n.parentNode.childNodes.slice().reverse(), a), 171 | 'first-of-type': (a, n) => n.parentNode && n.parentNode.childNodes.filter(el => el.nodeName === n.nodeName)[0] === n, 172 | 'last-of-type': (a, n) => n.parentNode && n.parentNode.childNodes.filter(el => el.nodeName === n.nodeName).pop() === n, 173 | 'nth-of-type': (a, n) => n.parentNode && nth(n, n.parentNode.childNodes.filter(el => el.nodeName === n.nodeName), a), 174 | 'nth-last-of-type': (a, n) => n.parentNode && nth(n, n.parentNode.childNodes.filter(el => el.nodeName === n.nodeName).reverse(), a), 175 | 'only-child': (a, n) => n.parentNode && n.parentNode.childNodes.length === 1, 176 | 'only-of-type': (a, n) => n.parentNode && n.parentNode.childNodes.filter(el => el.nodeName === n.nodeName).length === 1, 177 | root: (a, n) => n.ownerDocument.documentElement === n, 178 | not: (a, n, s) => !(new CssQuery(a)).matches(n, s), 179 | matches: (a, n, s) => (new CssQuery(a)).matches(n, s), 180 | scope: (a, n, s) => n === s 181 | } 182 | 183 | export class CssQueryNode { 184 | constructor (node) { 185 | this.tag = '' 186 | this.id = '' 187 | this.classList = [] 188 | this.attrs = [] 189 | this.pseudo = [] 190 | 191 | // match the tag name 192 | let matches = node.match(/^[\w-]+|^\*/) 193 | if (matches) { 194 | this.tag = matches[0] 195 | node = node.slice(this.tag.length) 196 | } 197 | 198 | // match pseudo classes 199 | while ((matches = /:([\w-]+)(?:\((.+)\))?/g.exec(node))) { 200 | this.pseudo.push(pseudoMatcher[matches[1]].bind(this, removeQuotes(matches[2] || ''))) 201 | node = node.slice(0, matches.index) + node.slice(matches.index + matches[0].length) 202 | } 203 | 204 | // match attributes 205 | while ((matches = /\[([\w-*]+\|)?([\w-]+)(([=^~$|*]+)(.+?)( +[iI])?)?\]/g.exec(node))) { 206 | const prefix = matches[1] ? matches[1].split('|')[0] : null 207 | this.attrs.push({ 208 | name: matches[2], 209 | getValue: getAttributeValue.bind(this, prefix, matches[2]), 210 | matcher: attributeMatcher[matches[4] || '*'].bind( 211 | this, 212 | !!matches[6], // case insensitive yes/no 213 | removeQuotes((matches[5] || '').trim()) // attribute value 214 | ) 215 | }) 216 | node = node.slice(0, matches.index) + node.slice(matches.index + matches[0].length) 217 | } 218 | 219 | // match the id 220 | matches = node.match(/#([\w-]+)/) 221 | if (matches) { 222 | this.id = matches[1] 223 | node = node.slice(0, matches.index) + node.slice(matches.index + matches[0].length) 224 | } 225 | 226 | // match classes 227 | while ((matches = /\.([\w-]+)/g.exec(node))) { 228 | this.classList.push(matches[1]) 229 | node = node.slice(0, matches.index) + node.slice(matches.index + matches[0].length) 230 | } 231 | } 232 | 233 | matches (node, scope) { 234 | let i 235 | 236 | if (node.nodeType !== 1) return false 237 | 238 | // Always this extra code for html -.- 239 | if (node.namespaceURI === html) { 240 | this.tag = this.tag.toUpperCase() 241 | } 242 | 243 | if (this.tag && this.tag !== node.nodeName && this.tag !== '*') { return false } 244 | 245 | if (this.id && this.id !== node.id) { 246 | return false 247 | } 248 | 249 | const classList = (node.getAttribute('class') || '').split(regex.delimiter).filter(el => !!el.length) 250 | if (this.classList.filter(className => classList.indexOf(className) < 0).length) { 251 | return false 252 | } 253 | 254 | for (i = this.attrs.length; i--;) { 255 | const attrValue = this.attrs[i].getValue(node) 256 | if (attrValue === null || !this.attrs[i].matcher(attrValue)) { 257 | return false 258 | } 259 | } 260 | 261 | for (i = this.pseudo.length; i--;) { 262 | if (!this.pseudo[i](node, scope)) { 263 | return false 264 | } 265 | } 266 | 267 | return true 268 | } 269 | 270 | } 271 | -------------------------------------------------------------------------------- /src/other/Point.js: -------------------------------------------------------------------------------- 1 | import { SVGPoint } from '../dom/svg/SVGPoint.js' 2 | 3 | export class Point { 4 | // Initialize 5 | constructor (x, y) { 6 | const base = { x: 0, y: 0 } 7 | 8 | // ensure source as object 9 | const source = Array.isArray(x) 10 | ? { x: x[0], y: x[1] } 11 | : typeof x === 'object' 12 | ? { x: x.x, y: x.y } 13 | : x != null 14 | ? { x: x, y: (y != null ? y : x) } 15 | : base // If y has no value, then x is used has its value 16 | 17 | // merge source 18 | this.x = source.x 19 | this.y = source.y 20 | } 21 | 22 | abs () { 23 | return Math.sqrt(this.absQuad()) 24 | } 25 | 26 | absQuad () { 27 | return this.x * this.x + this.y * this.y 28 | } 29 | 30 | add (x, y) { 31 | const p = new Point(x, y) 32 | return new Point(this.x + p.x, this.y + p.y) 33 | } 34 | 35 | angleTo (p) { 36 | let sign = Math.sign(this.x * p.y - this.y * p.x) 37 | sign = sign || 1 38 | return sign * Math.acos(Math.round((this.dot(p) / (this.abs() * p.abs())) * 1000000) / 1000000) 39 | } 40 | 41 | // Clone point 42 | clone () { 43 | return new Point(this) 44 | } 45 | 46 | closeTo (p, eta = 0.00001) { 47 | return this.equals(p) || (Math.abs(this.x - p.x) < eta && Math.abs(this.y - p.y) < eta) 48 | } 49 | 50 | div (factor) { 51 | return new Point(this.x / factor, this.y / factor) 52 | } 53 | 54 | dot (p) { 55 | return this.x * p.x + this.y * p.y 56 | } 57 | 58 | equals (p) { 59 | return this.x === p.x && this.y === p.y 60 | } 61 | 62 | mul (factor) { 63 | return new Point(this.x * factor, this.y * factor) 64 | } 65 | 66 | // Convert to native SVGPoint 67 | native () { 68 | // create new point 69 | const point = new SVGPoint() 70 | 71 | // update with current values 72 | point.x = this.x 73 | point.y = this.y 74 | 75 | return point 76 | } 77 | 78 | normal () { 79 | return new Point(this.y, -this.x) 80 | } 81 | 82 | normalize () { 83 | const abs = this.abs() 84 | if (!abs) throw new Error('Can\'t normalize vector of zero length') 85 | return this.div(abs) 86 | } 87 | 88 | reflectAt (p) { 89 | return p.add(p.sub(this)) 90 | } 91 | 92 | sub (x, y) { 93 | const p = new Point(x, y) 94 | return new Point(this.x - p.x, this.y - p.y) 95 | } 96 | 97 | toArray () { 98 | return [ this.x, this.y ] 99 | } 100 | 101 | toPath () { 102 | return [ 'M', this.x, this.y ].join(' ') 103 | } 104 | 105 | // transform point with matrix 106 | transform (matrix) { 107 | return new Point(this.native().matrixTransform(matrix)) 108 | } 109 | 110 | transformO (matrix) { 111 | const { x, y } = this.native().matrixTransform(matrix) 112 | this.x = x 113 | this.y = y 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /src/utils/NodeIterator.js: -------------------------------------------------------------------------------- 1 | import { NodeFilter } from '../dom/NodeFilter.js' 2 | 3 | const showThisNode = (whatToShow, node) => { 4 | if (whatToShow === NodeFilter.SHOW_ALL) return true 5 | if (whatToShow & NodeFilter.SHOW_ELEMENT && node.nodeType === node.ELEMENT_NODE) return true 6 | if (whatToShow & NodeFilter.SHOW_TEXT && node.nodeType === node.TEXT_NODE) return true 7 | if (whatToShow & NodeFilter.SHOW_ENTITY_REFERENCE && node.nodeType === node.ENTITY_REFERENCE_NODE) return true 8 | if (whatToShow & NodeFilter.SHOW_ENTITY && node.nodeType === node.ENTITY_NODE) return true 9 | if (whatToShow & NodeFilter.SHOW_PROCESSING_INSTRUCTION && node.nodeType === node.PROCESSING_INSTRUCTION_NODE) return true 10 | if (whatToShow & NodeFilter.SHOW_COMMENT && node.nodeType === node.COMMENT_NODE) return true 11 | if (whatToShow & NodeFilter.SHOW_DOCUMENT && node.nodeType === node.DOCUMENT_NODE) return true 12 | if (whatToShow & NodeFilter.SHOW_DOCUMENT_TYPE && node.nodeType === node.DOCUMENT_TYPE_NODE) return true 13 | if (whatToShow & NodeFilter.SHOW_DOCUMENT_FRAGMENT && node.nodeType === node.DOCUMENT_FRAGMENT_NODE) return true 14 | if (whatToShow & NodeFilter.SHOW_NOTATION && node.nodeType === node.NOTATION_NODE) return true 15 | return false 16 | } 17 | 18 | export class NodeIterator { 19 | constructor (root, whatToShow = NodeFilter.SHOW_ALL, filter = () => NodeFilter.FILTER_ACCEPT, includeParent = true) { 20 | this.root = includeParent ? { childNodes: [ root ] } : root 21 | this.whatToShow = whatToShow 22 | this.filter = filter 23 | } 24 | 25 | * [Symbol.iterator] () { 26 | const nodes = this.root.childNodes 27 | 28 | for (const node of nodes) { 29 | if (!showThisNode(this.whatToShow, node)) continue 30 | 31 | const filterRet = this.filter(node) 32 | 33 | if (filterRet === NodeFilter.FILTER_REJECT) continue 34 | if (filterRet === NodeFilter.FILTER_ACCEPT) { 35 | yield node 36 | } 37 | 38 | yield * new NodeIterator(node, this.whatToShow, this.filter, false) 39 | } 40 | 41 | return this 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/PointCloud.js: -------------------------------------------------------------------------------- 1 | import { Box, NoBox } from '../other/Box.js' 2 | 3 | export class PointCloud extends Array { 4 | constructor (...args) { 5 | if (args.length === 1 && typeof args[0] === 'number') { 6 | super(args.shift()) 7 | } else { 8 | super() 9 | } 10 | 11 | // except multiple point arrays as input and merge them into one 12 | args.reduce((last, curr) => { 13 | last.push(...curr) 14 | return this 15 | }, this) 16 | } 17 | 18 | bbox () { 19 | if (!this.length) { 20 | return new NoBox() 21 | } 22 | 23 | let xMin = Infinity 24 | let xMax = -Infinity 25 | let yMin = Infinity 26 | let yMax = -Infinity 27 | 28 | this.forEach(function (p) { 29 | xMin = Math.min(xMin, p.x) 30 | xMax = Math.max(xMax, p.x) 31 | yMin = Math.min(yMin, p.y) 32 | yMax = Math.max(yMax, p.y) 33 | }) 34 | 35 | return new Box( 36 | xMin, yMin, 37 | xMax - xMin, 38 | yMax - yMin 39 | ) 40 | } 41 | 42 | merge (cloud) { 43 | return new PointCloud(this, cloud) 44 | } 45 | 46 | transform (m) { 47 | return new PointCloud(this.map((p) => p.transform(m))) 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/bboxUtils.js: -------------------------------------------------------------------------------- 1 | import * as pathUtils from './pathUtils.js' 2 | import * as regex from './regex.js' 3 | import * as textUtils from './textUtils.js' 4 | import { NoBox } from '../other/Box.js' 5 | import { NodeIterator } from './NodeIterator.js' 6 | import { NodeFilter } from '../dom/NodeFilter.js' 7 | 8 | const applyTransformation = (segments, node, applyTransformations) => { 9 | if (node.matrixify && applyTransformations) { 10 | return segments.transform(node.matrixify()) 11 | } 12 | return segments 13 | } 14 | 15 | export const getSegments = (node, applyTransformations, rbox = false) => { 16 | const segments = getPathSegments(node, rbox) 17 | return applyTransformation(segments, node, applyTransformations) 18 | } 19 | 20 | const getPathSegments = (node, rbox) => { 21 | if (node.nodeType !== 1) return new pathUtils.PathSegmentArray() 22 | 23 | switch (node.nodeName) { 24 | case 'rect': 25 | case 'image': 26 | case 'pattern': 27 | case 'mask': 28 | case 'foreignObject': 29 | // Create Path from rect and create PointCloud from Path 30 | return pathUtils.getPathSegments(pathUtils.pathFrom.rect(node)) 31 | case 'svg': 32 | case 'symbol': 33 | // return pathUtils.getPathSegments(pathUtils.pathFrom.rect(node)) 34 | if (rbox) { 35 | return pathUtils.getPathSegments(pathUtils.pathFrom.rect(node)) 36 | } 37 | // ATTENTION: FALL THROUGH 38 | // Because normal bbox is calculated by the content of the element and not its width and height 39 | // eslint-disable-next-line 40 | case 'g': 41 | case 'clipPath': 42 | case 'a': 43 | case 'marker': 44 | // Iterate trough all children and get the point cloud of each 45 | // Then transform it with viewbox matrix if needed 46 | return node.childNodes.reduce((segments, child) => { 47 | if (!child.matrixify) return segments 48 | return segments.merge(getSegments(child, true).transform(child.generateViewBoxMatrix())) 49 | }, new pathUtils.PathSegmentArray()) 50 | case 'circle': 51 | return pathUtils.getPathSegments(pathUtils.pathFrom.circle(node)) 52 | case 'ellipse': 53 | return pathUtils.getPathSegments(pathUtils.pathFrom.ellipse(node)) 54 | case 'line': 55 | return pathUtils.getPathSegments(pathUtils.pathFrom.line(node)) 56 | case 'polyline': 57 | case 'polygon': 58 | return pathUtils.getPathSegments(pathUtils.pathFrom.polyline(node)) 59 | case 'path': 60 | case 'glyph': 61 | case 'missing-glyph': 62 | return pathUtils.getPathSegments(node.getAttribute('d')) 63 | case 'use': { 64 | // Get reference from element 65 | const ref = node.getAttribute('href') || node.getAttribute('xlink:href') 66 | // Get the actual referenced Node 67 | const refNode = node.getRootNode().querySelector(ref) 68 | // Get the BBox of the referenced element and apply the viewbox of 69 | // TODO: Do we need to apply the transformations of the element? 70 | // Check bbox of transformed element which is reused with 71 | return getSegments(refNode).transform(node.generateViewBoxMatrix()) 72 | } 73 | case 'tspan': 74 | case 'text': 75 | case 'altGlyph': { 76 | const box = getTextBBox(node) 77 | 78 | if (box instanceof NoBox) { 79 | return new pathUtils.PathSegmentArray() 80 | } 81 | 82 | return pathUtils.getPathSegments(pathUtils.pathFrom.box(box)) 83 | } 84 | default: 85 | return new pathUtils.PathSegmentArray() 86 | } 87 | } 88 | 89 | const getTextBBox = (node) => { 90 | const textRoot = findTextRoot(node) 91 | const boxes = getTextBBoxes(node, textRoot) 92 | return boxes.filter(isNotEmptyBox).reduce((last, curr) => last.merge(curr), new NoBox()) 93 | } 94 | 95 | const findTextRoot = (node) => { 96 | while (node.parentNode) { 97 | if ((node.nodeName === 'text' && node.parentNode.nodeName === 'text') 98 | || ((node.nodeName === 'tspan' || node.nodeName === 'textPath') && [ 'tspan', 'text', 'textPath' ].includes(node.parentNode.nodeName))) { 99 | node = node.parentNode 100 | } else { 101 | break 102 | } 103 | } 104 | 105 | return node 106 | } 107 | 108 | // This function takes a node of which the bbox needs to be calculated 109 | // In order to position the box correctly, we need to know were the parent and were the siblings *before* our node are 110 | // Thats why a textRoot is passed which is the most outer textElement needed to calculate all boxes 111 | // When the iterator hits the element we need the bbox of, it is terminated and this function is called again 112 | // only for the substree of our node and without textRoor but instead pos, dx and dy are known 113 | const getTextBBoxes = function (target, textRoot = target, pos = { x: 0, y: 0 }, dx = [ 0 ], dy = [ 0 ], boxes = []) { 114 | 115 | // Create NodeIterator. Only show elemnts and text and skip descriptive elements 116 | // TODO: make an instanceof check for DescriptiveElement instead of testing one by one 117 | // Only title is skipped atm 118 | const iter = new NodeIterator(textRoot, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, (node) => { 119 | if (node.nodeName === 'title') return NodeFilter.FILTER_IGNORE 120 | return NodeFilter.FILTER_ACCEPT 121 | }) 122 | 123 | // Iterate trough all nodes top to bottom, left to right 124 | for (const node of iter) { 125 | 126 | // If we hit our target, we gathered all positional information we need to move the bbox to the correct spot 127 | if (node === target && node !== textRoot) { 128 | return getTextBBoxes(node, node, pos, dx, dy) 129 | } 130 | 131 | // Traverse trough this node updating positions and add boxes 132 | getPositionDetailsFor(node, pos, dx, dy, boxes) 133 | } 134 | 135 | return boxes 136 | } 137 | 138 | const isNotEmptyBox = box => box.x !== 0 || box.y !== 0 || box.width !== 0 || box.height !== 0 139 | 140 | // This function either updates pos, dx and dy (when its an element) or calculates the boxes for text with the passed arguments 141 | // All arguments are passed by reference so dont overwrite them (treat them as const!) 142 | // TODO: Break this into two functions? 143 | const getPositionDetailsFor = (node, pos, dx, dy, boxes) => { 144 | if (node.nodeType === node.ELEMENT_NODE) { 145 | const x = parseFloat(node.getAttribute('x')) 146 | const y = parseFloat(node.getAttribute('y')) 147 | 148 | pos.x = isNaN(x) ? pos.x : x 149 | pos.y = isNaN(y) ? pos.y : y 150 | 151 | const dx0 = (node.getAttribute('dx') || '').split(regex.delimiter).filter(num => num !== '').map(parseFloat) 152 | const dy0 = (node.getAttribute('dy') || '').split(regex.delimiter).filter(num => num !== '').map(parseFloat) 153 | 154 | // TODO: eventually replace only as much values as we have text chars (node.textContent.length) because we could end up adding to much 155 | // replace initial values with node values if present 156 | dx.splice(0, dx0.length, ...dx0) 157 | dy.splice(0, dy0.length, ...dy0) 158 | } else { 159 | // get text data 160 | const data = node.data 161 | 162 | let j = 0 163 | const jl = data.length 164 | const details = getFontDetails(node) 165 | 166 | // if it is more than one dx/dy single letters are moved by the amount (https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dx) 167 | if (dy.length || dx.length) { 168 | for (;j < jl; j++) { 169 | // Calculate a box for a single letter 170 | boxes.push(textUtils.textBBox(data.substr(j, 1), pos.x, pos.y, details)) 171 | 172 | // Add the next position to current one 173 | pos.x += dx.shift() || 0 174 | pos.y += dy.shift() || 0 175 | 176 | if (!dy.length && !dx.length) break 177 | } 178 | } 179 | 180 | // in case it was only one dx/dy or no more dx/dy move the rest of the text 181 | boxes.push(textUtils.textBBox(data.substr(j), pos.x, pos.y, details)) 182 | pos.x += boxes[boxes.length - 1].width 183 | } 184 | } 185 | 186 | /* 187 | // this function is passing dx and dy values by references. Dont assign new values to it 188 | const textIterator = function (node, pos = { x: 0, y: 0 }, dx = [ 0 ], dy = [ 0 ]) { 189 | 190 | var x = parseFloat(node.getAttribute('x')) 191 | var y = parseFloat(node.getAttribute('y')) 192 | 193 | pos.x = isNaN(x) ? pos.x : x 194 | pos.y = isNaN(y) ? pos.y : y 195 | 196 | var dx0 = (node.getAttribute('dx') || '').split(regex.delimiter).filter(num => num !== '').map(parseFloat) 197 | var dy0 = (node.getAttribute('dy') || '').split(regex.delimiter).filter(num => num !== '').map(parseFloat) 198 | var boxes = [] 199 | var data = '' 200 | 201 | // TODO: eventually replace only as much values as we have text chars (node.textContent.length) because we could end up adding to much 202 | // replace initial values with node values if present 203 | dx.splice(0, dx0.length, ...dx0) 204 | dy.splice(0, dy0.length, ...dy0) 205 | 206 | var i = 0 207 | var il = node.childNodes.length 208 | 209 | // iterate through all children 210 | for (; i < il; ++i) { 211 | 212 | // shift next child 213 | pos.x += dx.shift() || 0 214 | pos.y += dy.shift() || 0 215 | 216 | // text 217 | if (node.childNodes[i].nodeType === node.TEXT_NODE) { 218 | 219 | // get text data 220 | data = node.childNodes[i].data 221 | 222 | let j = 0 223 | const jl = data.length 224 | 225 | // if it is more than one dx/dy single letters are moved by the amount (https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dx) 226 | if (dy.length || dx.length) { 227 | for (;j < jl; j++) { 228 | boxes.push(textUtils.textBBox(data.substr(j, 1), pos.x, pos.y, getFontDetails(node))) 229 | 230 | pos.x += dx.shift() || 0 231 | pos.y += dy.shift() || 0 232 | 233 | if (!dy.length && !dx.length) break 234 | } 235 | } 236 | 237 | // in case it was only one dx/dy or no more dx/dy move the rest of the text 238 | 239 | boxes.push(textUtils.textBBox(data.substr(j), pos.x, pos.y, getFontDetails(node))) 240 | pos.x += boxes[boxes.length - 1].width 241 | 242 | // element 243 | } else { 244 | // in case of element, recursively call function again with new start values 245 | boxes = boxes.concat(textIterator(node.childNodes[i], pos, dx, dy)) 246 | } 247 | } 248 | 249 | return boxes 250 | } */ 251 | 252 | const getFontDetails = (node) => { 253 | if (node.nodeType === node.TEXT_NODE) node = node.parentNode 254 | 255 | let fontSize = null 256 | let fontFamily = null 257 | let textAnchor = null 258 | let dominantBaseline = null 259 | 260 | const textContentElements = [ 261 | 'text', 262 | 'tspan', 263 | 'tref', 264 | 'textPath', 265 | 'altGlyph', 266 | 'g' 267 | ] 268 | 269 | do { 270 | // TODO: stop on 271 | if (!fontSize) { fontSize = node.style.fontSize || node.getAttribute('font-size') } 272 | if (!fontFamily) { fontFamily = node.style.fontFamily || node.getAttribute('font-family') } 273 | if (!textAnchor) { textAnchor = node.style.textAnchor || node.getAttribute('text-anchor') } 274 | if (!dominantBaseline) { dominantBaseline = node.style.dominantBaseline || node.getAttribute('dominant-baseline') } 275 | // TODO: check for alignment-baseline in tspan, tref, textPath, altGlyph 276 | // TODO: alignment-adjust, baseline-shift 277 | /* 278 | if(!alignmentBaseline) 279 | alignmentBaseline = this.style.alignmentBaseline || this.getAttribute('alignment-baseline') 280 | */ 281 | 282 | } while ( 283 | (node = node.parentNode) 284 | && node.nodeType === node.ELEMENT_NODE 285 | && (textContentElements.includes(node.nodeName)) 286 | ) 287 | 288 | return { 289 | fontFamily, 290 | fontSize, 291 | textAnchor: textAnchor || 'start', 292 | // TODO: use central for writing-mode === horizontal https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dominant-baseline 293 | dominantBaseline: dominantBaseline || 'alphabetical' 294 | // fontFamilyMappings: this.ownerDocument.fontFamilyMappings, 295 | // fontDir: this.ownerDocument.fontDir, 296 | // preloaded: this.ownerDocument._preloaded 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /src/utils/defaults.js: -------------------------------------------------------------------------------- 1 | import { join, dirname } from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | 4 | const fileDirname = dirname(fileURLToPath(import.meta.url)) 5 | 6 | export const fontSize = 16 7 | export const fontFamily = 'sans-serif' 8 | export const fontDir = join(fileDirname, '../../', 'fonts/') 9 | export const fontFamilyMappings = { 10 | 'sans-serif': 'OpenSans-Regular.ttf', 11 | 'Open Sans': 'OpenSans-Regular.ttf' 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/mapUtils.js: -------------------------------------------------------------------------------- 1 | import { decamelize } from '../utils/strUtils.js' 2 | 3 | export const objectToMap = function (obj) { 4 | if (obj instanceof Map) return new Map(obj) 5 | return Object.keys(obj).reduce((map, key) => map.set(key, obj[key]), new Map()) 6 | } 7 | 8 | export const mapToObject = function (map) { 9 | var obj = {} 10 | map.forEach(function (value, key) { 11 | obj[key] = value 12 | }) 13 | return obj 14 | } 15 | 16 | export const mapMap = function (map, cb) { 17 | var arr = [] 18 | map.forEach(function (value, key) { 19 | arr.push(cb(value, key)) 20 | }) 21 | return arr 22 | } 23 | 24 | export const mapToCss = function (myMap) { 25 | return mapMap(myMap, function (value, key) { 26 | if (!value) return false 27 | return decamelize(key) + ': ' + value 28 | }).filter(function (el) { return !!el }).join('; ') + ';' || null 29 | } 30 | 31 | export const cssToMap = function (css) { 32 | return new Map(css.split(/\s*;\s*/).filter(function (el) { return !!el }).map(function (el) { 33 | return el.split(/\s*:\s*/) 34 | })) 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/namespaces.js: -------------------------------------------------------------------------------- 1 | 2 | export const svg = 'http://www.w3.org/2000/svg' 3 | export const xlink = 'http://www.w3.org/1999/xlink' 4 | export const html = 'http://www.w3.org/1999/xhtml' 5 | export const mathml = 'http://www.w3.org/1998/Math/MathML' 6 | export const xml = 'http://www.w3.org/XML/1998/namespace' 7 | export const xmlns = 'http://www.w3.org/2000/xmlns/' 8 | -------------------------------------------------------------------------------- /src/utils/nodesToNode.js: -------------------------------------------------------------------------------- 1 | export const nodesToNode = (nodes, document) => { 2 | nodes = nodes.map((node) => { 3 | if (typeof node === 'string') { 4 | return document.createTextNode(node) 5 | } 6 | return node 7 | }) 8 | if (nodes.length === 1) { return nodes[0] } 9 | const node = document.createDocumentFragment() 10 | nodes.forEach(node.appendChild, node) 11 | return node 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/objectCreationUtils.js: -------------------------------------------------------------------------------- 1 | export const extend = (...modules) => { 2 | var methods, key, i 3 | 4 | // Get object with extensions 5 | methods = modules.pop() 6 | 7 | for (i = modules.length - 1; i >= 0; i--) { 8 | for (key in methods) { modules[i].prototype[key] = methods[key] } 9 | } 10 | } 11 | 12 | export const extendStatic = (...modules) => { 13 | var methods, key, i 14 | 15 | // Get object with extensions 16 | methods = modules.pop() 17 | 18 | for (i = modules.length - 1; i >= 0; i--) { 19 | for (key in methods) { modules[i][key] = methods[key] } 20 | } 21 | } 22 | 23 | // TODO: refactor so that it takes a class 24 | export const mixin = (mixin, _class) => { 25 | const descriptors = Object.getOwnPropertyDescriptors(mixin) 26 | // const all = Object.getOwnPropertyNames(mixin) 27 | 28 | // const propNames = Object.keys(descriptors) 29 | // const methodNames = all.filter(p => !propNames.includes(p)) 30 | 31 | // for (const method of methodNames) { 32 | // _class.prototype[method] = mixin[method] 33 | // } 34 | 35 | Object.defineProperties(_class.prototype, descriptors) 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/pathUtils.js: -------------------------------------------------------------------------------- 1 | import { Box, NoBox } from '../other/Box.js' 2 | import { Point } from '../other/Point.js' 3 | import * as regex from './regex.js' 4 | // TODO: use own matrix implementation 5 | import { matrixFactory } from './../dom/svg/SVGMatrix.js' 6 | import { PointCloud } from './PointCloud.js' 7 | 8 | const pathHandlers = { 9 | M (c, p, r, p0) { 10 | p.x = p0.x = c[0] 11 | p.y = p0.y = c[1] 12 | 13 | return new Move(p) 14 | }, 15 | L (c, p) { 16 | const ret = new Line(p.x, p.y, c[0], c[1])// .offset(o) 17 | p.x = c[0] 18 | p.y = c[1] 19 | return ret 20 | }, 21 | H (c, p) { 22 | return pathHandlers.L([ c[0], p.y ], p) 23 | }, 24 | V (c, p) { 25 | return pathHandlers.L([ p.x, c[0] ], p) 26 | }, 27 | Q (c, p, r) { 28 | const ret = Cubic.fromQuad(p, new Point(c[0], c[1]), new Point(c[2], c[3]))// .offset(o) 29 | p.x = c[2] 30 | p.y = c[3] 31 | 32 | const reflect = new Point(c[0], c[1]).reflectAt(p) 33 | r.x = reflect.x 34 | r.y = reflect.y 35 | 36 | return ret 37 | }, 38 | T (c, p, r, p0, reflectionIsPossible) { 39 | if (reflectionIsPossible) { c = [ r.x, r.y ].concat(c) } else { c = [ p.x, p.y ].concat(c) } 40 | return pathHandlers.Q(c, p, r) 41 | }, 42 | C (c, p, r) { 43 | const ret = new Cubic(p, new Point(c[0], c[1]), new Point(c[2], c[3]), new Point(c[4], c[5]))// .offset(o) 44 | p.x = c[4] 45 | p.y = c[5] 46 | const reflect = new Point(c[2], c[3]).reflectAt(p) 47 | r.x = reflect.x 48 | r.y = reflect.y 49 | return ret 50 | }, 51 | S (c, p, r, p0, reflectionIsPossible) { 52 | // reflection makes only sense if this command was preceeded by another beziere command (QTSC) 53 | if (reflectionIsPossible) { c = [ r.x, r.y ].concat(c) } else { c = [ p.x, p.y ].concat(c) } 54 | return pathHandlers.C(c, p, r) 55 | }, 56 | Z (c, p, r, p0) { 57 | // FIXME: The behavior of Z depends on the command before 58 | return pathHandlers.L([ p0.x, p0.y ], p) 59 | }, 60 | A (c, p, _r) { 61 | const ret = new Arc(p, new Point(c[5], c[6]), c[0], c[1], c[2], c[3], c[4]) 62 | p.x = c[5] 63 | p.y = c[6] 64 | return ret 65 | } 66 | } 67 | 68 | const mlhvqtcsa = 'mlhvqtcsaz'.split('') 69 | 70 | for (let i = 0, il = mlhvqtcsa.length; i < il; ++i) { 71 | pathHandlers[mlhvqtcsa[i]] = (function (i) { 72 | return function (c, p, r, p0, reflectionIsPossible) { 73 | if (i === 'H') c[0] = c[0] + p.x 74 | else if (i === 'V') c[0] = c[0] + p.y 75 | else if (i === 'A') { 76 | c[5] = c[5] + p.x 77 | c[6] = c[6] + p.y 78 | } else { 79 | for (let j = 0, jl = c.length; j < jl; ++j) { 80 | c[j] = c[j] + (j % 2 ? p.y : p.x) 81 | } 82 | } 83 | 84 | return pathHandlers[i](c, p, r, p0, reflectionIsPossible) 85 | } 86 | })(mlhvqtcsa[i].toUpperCase()) 87 | } 88 | 89 | function pathRegReplace (a, b, c, d) { 90 | return c + d.replace(regex.dots, ' .') 91 | } 92 | 93 | function isBeziere (obj) { 94 | return obj instanceof Cubic 95 | } 96 | 97 | export const pathParser = (array) => { 98 | 99 | if (!array) return [] 100 | 101 | // prepare for parsing 102 | const paramCnt = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0 } 103 | 104 | array = array 105 | .replace(regex.numbersWithDots, pathRegReplace) // convert 45.123.123 to 45.123 .123 106 | .replace(regex.pathLetters, ' $& ') // put some room between letters and numbers 107 | .replace(regex.hyphen, '$1 -') // add space before hyphen 108 | .trim() // trim 109 | .split(regex.delimiter) // split into array 110 | 111 | // array now is an array containing all parts of a path e.g. ['M', '0', '0', 'L', '30', '30' ...] 112 | const arr = [] 113 | const p = new Point() 114 | const p0 = new Point() 115 | const r = new Point() 116 | let index = 0 117 | const len = array.length 118 | let s 119 | 120 | do { 121 | // Test if we have a path letter 122 | if (regex.isPathLetter.test(array[index])) { 123 | s = array[index] 124 | ++index 125 | // If last letter was a move command and we got no new, it defaults to [L]ine 126 | } else if (s === 'M') { 127 | s = 'L' 128 | } else if (s === 'm') { 129 | s = 'l' 130 | } 131 | 132 | arr.push( 133 | pathHandlers[s].call(null, 134 | array.slice(index, (index = index + paramCnt[s.toUpperCase()])).map(parseFloat), 135 | p, r, p0, 136 | isBeziere(arr[arr.length - 1]) 137 | ) 138 | ) 139 | 140 | } while (len > index) 141 | 142 | return arr 143 | } 144 | 145 | class Move { 146 | constructor (p) { 147 | this.p1 = p.clone() 148 | } 149 | 150 | // FIXME: Use pointcloud 151 | bbox () { 152 | const p = this.p1 153 | return new Box(p.x, p.y, 0, 0) 154 | } 155 | 156 | getCloud () { 157 | return new PointCloud([ this.p1 ]) 158 | } 159 | 160 | length () { return 0 } 161 | 162 | toPath () { 163 | return [ 'M', this.p1.x, this.p1.y ].join(' ') 164 | } 165 | 166 | toPathFragment () { 167 | return [ 'M', this.p1.x, this.p1.y ] 168 | } 169 | 170 | transform (matrix) { 171 | this.p1.transformO(matrix) 172 | return this 173 | } 174 | } 175 | 176 | export class Arc { 177 | constructor (p1, p2, rx, ry, φ, arc, sweep) { 178 | // https://www.w3.org/TR/SVG/implnote.html#ArcCorrectionOutOfRangeRadii 179 | if (!rx || !ry) return new Line(p1, p2) 180 | 181 | rx = Math.abs(rx) 182 | ry = Math.abs(ry) 183 | 184 | this.p1 = p1.clone() 185 | this.p2 = p2.clone() 186 | this.arc = arc ? 1 : 0 187 | this.sweep = sweep ? 1 : 0 188 | 189 | // Calculate cos and sin of angle phi 190 | const cosφ = Math.cos(φ / 180 * Math.PI) 191 | const sinφ = Math.sin(φ / 180 * Math.PI) 192 | 193 | // https://www.w3.org/TR/SVG/implnote.html#ArcConversionEndpointToCenter 194 | // (eq. 5.1) 195 | const p1_ = new Point( 196 | (p1.x - p2.x) / 2, 197 | (p1.y - p2.y) / 2 198 | ).transform(matrixFactory( 199 | cosφ, -sinφ, sinφ, cosφ, 0, 0 200 | )) 201 | 202 | // (eq. 6.2) 203 | // Make sure the radius fit with the arc and correct if neccessary 204 | const ratio = (p1_.x ** 2 / rx ** 2) + (p1_.y ** 2 / ry ** 2) 205 | 206 | // (eq. 6.3) 207 | if (ratio > 1) { 208 | rx = Math.sqrt(ratio) * rx 209 | ry = Math.sqrt(ratio) * ry 210 | } 211 | 212 | // (eq. 5.2) 213 | const rxQuad = rx ** 2 214 | const ryQuad = ry ** 2 215 | 216 | const divisor1 = rxQuad * p1_.y ** 2 217 | const divisor2 = ryQuad * p1_.x ** 2 218 | const dividend = (rxQuad * ryQuad - divisor1 - divisor2) 219 | 220 | let c_ 221 | if (Math.abs(dividend) < 1e-15) { 222 | c_ = new Point(0, 0) 223 | } else { 224 | c_ = new Point( 225 | rx * p1_.y / ry, 226 | -ry * p1_.x / rx 227 | ).mul(Math.sqrt( 228 | dividend / (divisor1 + divisor2) 229 | )) 230 | } 231 | 232 | if (this.arc === this.sweep) c_ = c_.mul(-1) 233 | 234 | // (eq. 5.3) 235 | const c = c_.transform(matrixFactory( 236 | cosφ, sinφ, -sinφ, cosφ, 0, 0 237 | )).add(new Point( 238 | (p1.x + p2.x) / 2, 239 | (p1.y + p2.y) / 2 240 | )) 241 | 242 | const anglePoint = new Point( 243 | (p1_.x - c_.x) / rx, 244 | (p1_.y - c_.y) / ry 245 | ) 246 | 247 | /* For eq. 5.4 see angleTo function */ 248 | 249 | // (eq. 5.5) 250 | const θ = new Point(1, 0).angleTo(anglePoint) 251 | 252 | // (eq. 5.6) 253 | let Δθ = anglePoint.angleTo(new Point( 254 | (-p1_.x - c_.x) / rx, 255 | (-p1_.y - c_.y) / ry 256 | )) 257 | 258 | Δθ = (Δθ % (2 * Math.PI)) 259 | 260 | if (!sweep && Δθ > 0) Δθ -= 2 * Math.PI 261 | if (sweep && Δθ < 0) Δθ += 2 * Math.PI 262 | 263 | this.c = c 264 | this.theta = θ * 180 / Math.PI 265 | this.theta2 = (θ + Δθ) * 180 / Math.PI 266 | 267 | this.delta = Δθ * 180 / Math.PI 268 | this.rx = rx 269 | this.ry = ry 270 | this.phi = φ 271 | this.cosφ = cosφ 272 | this.sinφ = sinφ 273 | } 274 | 275 | static fromCenterForm (c, rx, ry, φ, θ, Δθ) { 276 | const cosφ = Math.cos(φ / 180 * Math.PI) 277 | const sinφ = Math.sin(φ / 180 * Math.PI) 278 | const m = matrixFactory(cosφ, sinφ, -sinφ, cosφ, 0, 0) 279 | 280 | const p1 = new Point( 281 | rx * Math.cos(θ / 180 * Math.PI), 282 | ry * Math.sin(θ / 180 * Math.PI) 283 | ).transform(m).add(c) 284 | 285 | const p2 = new Point( 286 | rx * Math.cos((θ + Δθ) / 180 * Math.PI), 287 | ry * Math.sin((θ + Δθ) / 180 * Math.PI) 288 | ).transform(m).add(c) 289 | 290 | const arc = Math.abs(Δθ) > 180 ? 1 : 0 291 | const sweep = Δθ > 0 ? 1 : 0 292 | 293 | return new Arc(p1, p2, rx, ry, φ, arc, sweep) 294 | } 295 | 296 | bbox () { 297 | const cloud = this.getCloud() 298 | return cloud.bbox() 299 | } 300 | 301 | clone () { 302 | return new Arc(this.p1, this.p2, this.rx, this.ry, this.phi, this.arc, this.sweep) 303 | } 304 | 305 | getCloud () { 306 | if (this.p1.equals(this.p2)) return new PointCloud([ this.p1 ]) 307 | 308 | // arc could be rotated. the min and max values then dont lie on multiples of 90 degress but are shifted by the rotation angle 309 | // so we first calculate our 0/90 degree angle 310 | let θ01 = Math.atan(-this.sinφ / this.cosφ * this.ry / this.rx) * 180 / Math.PI 311 | let θ02 = Math.atan(this.cosφ / this.sinφ * this.ry / this.rx) * 180 / Math.PI 312 | let θ1 = this.theta 313 | let θ2 = this.theta2 314 | 315 | if (θ1 < 0 || θ2 < 0) { 316 | θ1 += 360 317 | θ2 += 360 318 | } 319 | 320 | if (θ2 < θ1) { 321 | const temp = θ1 322 | θ1 = θ2 323 | θ2 = temp 324 | 325 | } 326 | 327 | while (θ01 - 90 > θ01) θ01 -= 90 328 | while (θ01 < θ1) θ01 += 90 329 | while (θ02 - 90 > θ02) θ02 -= 90 330 | while (θ02 < θ1) θ02 += 90 331 | 332 | const angleToTest = [ θ01, θ02, (θ01 + 90), (θ02 + 90), (θ01 + 180), (θ02 + 180), (θ01 + 270), (θ02 + 270) ] 333 | 334 | const points = angleToTest.filter(function (angle) { 335 | return (angle > θ1 && angle < θ2) 336 | }).map(function (angle) { 337 | while (this.theta < angle) angle -= 360 338 | return this.pointAt(((angle - this.theta) % 360) / (this.delta)) // TODO: replace that call with pointAtAngle 339 | }.bind(this)).concat(this.p1, this.p2) 340 | 341 | return new PointCloud(points) 342 | } 343 | 344 | length () { 345 | if (this.p1.equals(this.p2)) return 0 346 | 347 | const length = this.p2.sub(this.p1).abs() 348 | 349 | const ret = this.splitAt(0.5) 350 | const len1 = ret[0].p2.sub(ret[0].p1).abs() 351 | const len2 = ret[1].p2.sub(ret[1].p1).abs() 352 | 353 | if (len1 + len2 - length < 0.00001) { 354 | return len1 + len2 355 | } 356 | 357 | return ret[0].length() + ret[1].length() 358 | } 359 | 360 | pointAt (t) { 361 | if (this.p1.equals(this.p2)) return this.p1.clone() 362 | 363 | const tInAngle = (this.theta + t * this.delta) / 180 * Math.PI 364 | const sinθ = Math.sin(tInAngle) 365 | const cosθ = Math.cos(tInAngle) 366 | 367 | return new Point( 368 | this.cosφ * this.rx * cosθ - this.sinφ * this.ry * sinθ + this.c.x, 369 | this.sinφ * this.ry * cosθ + this.cosφ * this.rx * sinθ + this.c.y 370 | ) 371 | } 372 | 373 | splitAt (t) { 374 | const absDelta = Math.abs(this.delta) 375 | const delta1 = absDelta * t 376 | const delta2 = absDelta * (1 - t) 377 | 378 | const pointAtT = this.pointAt(t) 379 | 380 | return [ 381 | new Arc(this.p1, pointAtT, this.rx, this.ry, this.phi, delta1 > 180, this.sweep), 382 | new Arc(pointAtT, this.p2, this.rx, this.ry, this.phi, delta2 > 180, this.sweep) 383 | ] 384 | } 385 | 386 | toPath () { 387 | return [ 'M', this.p1.x, this.p1.y, 'A', this.rx, this.ry, this.phi, this.arc, this.sweep, this.p2.x, this.p2.y ].join(' ') 388 | } 389 | 390 | toPathFragment () { 391 | return [ 'A', this.rx, this.ry, this.phi, this.arc, this.sweep, this.p2.x, this.p2.y ] 392 | } 393 | 394 | toString () { 395 | return `p1: ${this.p1.x.toFixed(4)} ${this.p1.y.toFixed(4)}, p2: ${this.p2.x.toFixed(4)} ${this.p2.y.toFixed(4)}, c: ${this.c.x.toFixed(4)} ${this.c.y.toFixed(4)} theta: ${this.theta.toFixed(4)}, theta2: ${this.theta2.toFixed(4)}, delta: ${this.delta.toFixed(4)}, large: ${this.arc}, sweep: ${this.sweep}` 396 | } 397 | 398 | transform (matrix) { 399 | return new Arc(this.p1.transform(matrix), this.p2.transform(matrix), this.rx, this.ry, this.phi, this.arc, this.sweep) 400 | } 401 | } 402 | 403 | class Cubic { 404 | constructor (p1, c1, c2, p2) { 405 | if (p1 instanceof Point) { 406 | this.p1 = new Point(p1) 407 | this.c1 = new Point(c1) 408 | this.c2 = new Point(c2) 409 | this.p2 = new Point(p2) 410 | } else { 411 | this.p1 = new Point(p1.p1) 412 | this.c1 = new Point(p1.c1) 413 | this.c2 = new Point(p1.c2) 414 | this.p2 = new Point(p1.p2) 415 | } 416 | } 417 | 418 | static fromQuad (p1, c, p2) { 419 | const c1 = p1.mul(1 / 3).add(c.mul(2 / 3)) 420 | const c2 = c.mul(2 / 3).add(p2.mul(1 / 3)) 421 | return new Cubic(p1, c1, c2, p2) 422 | } 423 | 424 | bbox () { 425 | return this.getCloud().bbox() 426 | } 427 | 428 | findRoots () { 429 | return this.findRootsX().concat(this.findRootsY()) 430 | } 431 | 432 | findRootsX () { 433 | return this.findRootsXY(this.p1.x, this.c1.x, this.c2.x, this.p2.x) 434 | } 435 | 436 | findRootsXY (p1, p2, p3, p4) { 437 | const a = 3 * (-p1 + 3 * p2 - 3 * p3 + p4) 438 | const b = 6 * (p1 - 2 * p2 + p3) 439 | const c = 3 * (p2 - p1) 440 | 441 | if (a === 0) return [ -c / b ].filter(function (el) { return el > 0 && el < 1 }) 442 | 443 | if (b * b - 4 * a * c < 0) return [] 444 | if (b * b - 4 * a * c === 0) return [ Math.round((-b / (2 * a)) * 100000) / 100000 ].filter(function (el) { return el > 0 && el < 1 }) 445 | 446 | return [ 447 | Math.round((-b + Math.sqrt(b * b - 4 * a * c)) / (2 * a) * 100000) / 100000, 448 | Math.round((-b - Math.sqrt(b * b - 4 * a * c)) / (2 * a) * 100000) / 100000 449 | ].filter(function (el) { return el > 0 && el < 1 }) 450 | } 451 | 452 | findRootsY () { 453 | return this.findRootsXY(this.p1.y, this.c1.y, this.c2.y, this.p2.y) 454 | } 455 | 456 | flatness () { 457 | let ux = Math.pow(3 * this.c1.x - 2 * this.p1.x - this.p2.x, 2) 458 | let uy = Math.pow(3 * this.c1.y - 2 * this.p1.y - this.p2.y, 2) 459 | const vx = Math.pow(3 * this.c2.x - 2 * this.p2.x - this.p1.x, 2) 460 | const vy = Math.pow(3 * this.c2.y - 2 * this.p2.y - this.p1.y, 2) 461 | 462 | if (ux < vx) { ux = vx } 463 | if (uy < vy) { uy = vy } 464 | 465 | return ux + uy 466 | } 467 | 468 | getCloud () { 469 | const points = this.findRoots() 470 | .filter(root => root !== 0 && root !== 1) 471 | .map(root => this.pointAt(root)) 472 | .concat(this.p1, this.p2) 473 | 474 | return new PointCloud(points) 475 | } 476 | 477 | length () { 478 | return this.lengthAt() 479 | } 480 | 481 | lengthAt (t = 1) { 482 | const curves = this.splitAt(t)[0].makeFlat(t) 483 | 484 | let length = 0 485 | for (let i = 0, len = curves.length; i < len; ++i) { 486 | length += curves[i].p2.sub(curves[i].p1).abs() 487 | } 488 | 489 | return length 490 | } 491 | 492 | makeFlat (t) { 493 | if (this.flatness() > 0.15) { 494 | return this.splitAt(0.5) 495 | .map(function (el) { return el.makeFlat(t * 0.5) }) 496 | .reduce(function (last, current) { return last.concat(current) }, []) 497 | } else { 498 | this.t_value = t 499 | return [ this ] 500 | } 501 | } 502 | 503 | pointAt (t) { 504 | return new Point( 505 | (1 - t) * (1 - t) * (1 - t) * this.p1.x + 3 * (1 - t) * (1 - t) * t * this.c1.x + 3 * (1 - t) * t * t * this.c2.x + t * t * t * this.p2.x, 506 | (1 - t) * (1 - t) * (1 - t) * this.p1.y + 3 * (1 - t) * (1 - t) * t * this.c1.y + 3 * (1 - t) * t * t * this.c2.y + t * t * t * this.p2.y 507 | ) 508 | } 509 | 510 | splitAt (z) { 511 | const x = this.splitAtScalar(z, 'x') 512 | const y = this.splitAtScalar(z, 'y') 513 | 514 | const a = new Cubic( 515 | new Point(x[0][0], y[0][0]), 516 | new Point(x[0][1], y[0][1]), 517 | new Point(x[0][2], y[0][2]), 518 | new Point(x[0][3], y[0][3]) 519 | ) 520 | 521 | const b = new Cubic( 522 | new Point(x[1][0], y[1][0]), 523 | new Point(x[1][1], y[1][1]), 524 | new Point(x[1][2], y[1][2]), 525 | new Point(x[1][3], y[1][3]) 526 | ) 527 | 528 | return [ a, b ] 529 | } 530 | 531 | splitAtScalar (z, p) { 532 | const p1 = this.p1[p] 533 | const p2 = this.c1[p] 534 | const p3 = this.c2[p] 535 | const p4 = this.p2[p] 536 | 537 | const t = z * z * z * p4 - 3 * z * z * (z - 1) * p3 + 3 * z * (z - 1) * (z - 1) * p2 - (z - 1) * (z - 1) * (z - 1) * p1 538 | 539 | return [ 540 | [ 541 | p1, 542 | z * p2 - (z - 1) * p1, 543 | z * z * p3 - 2 * z * (z - 1) * p2 + (z - 1) * (z - 1) * p1, 544 | t 545 | ], 546 | [ 547 | t, 548 | z * z * p4 - 2 * z * (z - 1) * p3 + (z - 1) * (z - 1) * p2, 549 | z * p4 - (z - 1) * p3, 550 | p4 551 | ] 552 | ] 553 | } 554 | 555 | toPath () { 556 | return [ 'M', this.p1.x, this.p1.y ].concat(this.toPathFragment()).join(' ') 557 | } 558 | 559 | toPathFragment () { 560 | return [ 'C', this.c1.x, this.c1.y, this.c2.x, this.c2.y, this.p2.x, this.p2.y ] 561 | } 562 | 563 | transform (matrix) { 564 | this.p1.transformO(matrix) 565 | this.c1.transformO(matrix) 566 | this.c2.transformO(matrix) 567 | this.p2.transformO(matrix) 568 | return this 569 | } 570 | } 571 | 572 | class Line { 573 | constructor (x1, y1, x2, y2) { 574 | if (x1 instanceof Object) { 575 | this.p1 = new Point(x1) 576 | this.p2 = new Point(y1) 577 | } else { 578 | this.p1 = new Point(x1, y1) 579 | this.p2 = new Point(x2, y2) 580 | } 581 | } 582 | 583 | bbox () { 584 | return this.getCloud().bbox() 585 | } 586 | 587 | getCloud () { 588 | return new PointCloud([ this.p1, this.p2 ]) 589 | } 590 | 591 | length () { 592 | return this.p2.sub(this.p1).abs() 593 | } 594 | 595 | pointAt (t) { 596 | const vec = this.p2.sub(this.p1).mul(t) 597 | return this.p1.add(vec) 598 | } 599 | 600 | toPath () { 601 | return [ 'M', this.p1.x, this.p1.y, this.p2.x, this.p2.y ].join(' ') 602 | } 603 | 604 | toPathFragment () { 605 | return [ 'L', this.p2.x, this.p2.y ] 606 | } 607 | 608 | transform (matrix) { 609 | this.p1.transformO(matrix) 610 | this.p2.transformO(matrix) 611 | return this 612 | } 613 | } 614 | 615 | export const pathBBox = function (d) { 616 | return pathParser(d).reduce((l, c) => l.merge(c.bbox()), new NoBox()) 617 | } 618 | 619 | export class PathSegmentArray extends Array { 620 | bbox () { 621 | return this.reduce((l, c) => l.merge(c.bbox()), new NoBox()) 622 | } 623 | 624 | cloud () { 625 | return this.reduce( 626 | (cloud, segment) => segment.getCloud().merge(cloud), 627 | new PointCloud() 628 | ) 629 | } 630 | 631 | merge (other) { 632 | return this.concat(other) 633 | } 634 | 635 | transform (matrix) { 636 | return this.map(segment => segment.transform(matrix)) 637 | } 638 | } 639 | 640 | export const getPathSegments = function (d) { 641 | return new PathSegmentArray(...pathParser(d)) 642 | } 643 | 644 | export const pointAtLength = function (d, len) { 645 | const segs = pathParser(d) 646 | 647 | const segLengths = segs.map(el => el.length()) 648 | 649 | const length = segLengths.reduce((l, c) => l + c, 0) 650 | 651 | let i = 0 652 | 653 | let t = len / length 654 | 655 | // FIXME: Pop Move before using shortcut? 656 | // shortcut for trivial cases 657 | if (t >= 1) { 658 | // Check if there is a p2. If not, use p1 659 | if (segs[segs.length - 1].p2) { 660 | return segs[segs.length - 1].p2.native() 661 | } else { 662 | return segs[segs.length - 1].p1.native() 663 | } 664 | } 665 | 666 | if (t <= 0) return segs[0].p1.native() 667 | 668 | // remove move commands at the very end of the path 669 | while (segs[segs.length - 1] instanceof Move) segs.pop() 670 | 671 | let segEnd = 0 672 | 673 | for (const il = segLengths.length; i < il; ++i) { 674 | const k = segLengths[i] / length 675 | segEnd += k 676 | 677 | if (segEnd > t) { 678 | break 679 | } 680 | } 681 | 682 | const ratio = length / segLengths[i] 683 | t = ratio * (t - segEnd) + 1 684 | 685 | return segs[i].pointAt(t).native() 686 | } 687 | 688 | export const length = function (d) { 689 | return pathParser(d) 690 | .reduce((l, c) => l + c.length(), 0) 691 | } 692 | 693 | export const debug = function (node) { 694 | const parse = pathParser(node.getAttribute('d')) 695 | 696 | const ret = { 697 | paths: parse.map(el => el.toPath()), 698 | fragments: parse.map(el => el.toPathFragment().join(' ')), 699 | bboxs: parse.map(el => { 700 | const box = el.bbox() 701 | return [ box.x, box.y, box.width, box.height ] 702 | }), 703 | bbox: parse.reduce((l, c) => l.merge(c.bbox()), new NoBox()), 704 | bboxsTransformed: parse.map(el => { 705 | return el.getCloud().transform(node.matrixify()).bbox() 706 | }) 707 | } 708 | 709 | return Object.assign({}, ret, { 710 | bboxTransformed: ret.bboxsTransformed.reduce((l, c) => l.merge(c), new NoBox()) 711 | }) 712 | } 713 | 714 | export const getCloud = (d) => { 715 | return pathParser(d).reduce((cloud, segment) => 716 | segment.getCloud().merge(cloud), new PointCloud() 717 | ) 718 | } 719 | 720 | export const pathFrom = { 721 | box ({ x, y, width, height }) { 722 | return `M ${x} ${y} h ${width} v ${height} H ${x} V ${y}` 723 | }, 724 | rect (node) { 725 | const width = parseFloat(node.getAttribute('width')) || 0 726 | const height = parseFloat(node.getAttribute('height')) || 0 727 | const x = parseFloat(node.getAttribute('x')) || 0 728 | const y = parseFloat(node.getAttribute('y')) || 0 729 | return `M ${x} ${y} h ${width} v ${height} H ${x} V ${y}` 730 | }, 731 | circle (node) { 732 | const r = parseFloat(node.getAttribute('r')) || 0 733 | const x = parseFloat(node.getAttribute('cx')) || 0 734 | const y = parseFloat(node.getAttribute('cy')) || 0 735 | 736 | if (r === 0) return 'M0 0' 737 | 738 | return `M ${x - r} ${y} A ${r} ${r} 0 0 0 ${x + r} ${y} A ${r} ${r} 0 0 0 ${x - r} ${y}` 739 | }, 740 | ellipse (node) { 741 | const rx = parseFloat(node.getAttribute('rx')) || 0 742 | const ry = parseFloat(node.getAttribute('ry')) || 0 743 | const x = parseFloat(node.getAttribute('cx')) || 0 744 | const y = parseFloat(node.getAttribute('cy')) || 0 745 | 746 | return `M ${x - rx} ${y} A ${rx} ${ry} 0 0 0 ${x + rx} ${y} A ${rx} ${ry} 0 0 0 ${x - rx} ${y}` 747 | }, 748 | line (node) { 749 | const x1 = parseFloat(node.getAttribute('x1')) || 0 750 | const x2 = parseFloat(node.getAttribute('x2')) || 0 751 | const y1 = parseFloat(node.getAttribute('y1')) || 0 752 | const y2 = parseFloat(node.getAttribute('y2')) || 0 753 | 754 | return `M ${x1} ${y1} L ${x2} ${y2}` 755 | }, 756 | polygon (node) { 757 | return `M ${node.getAttribute('points')} z` 758 | }, 759 | polyline (node) { 760 | return `M ${node.getAttribute('points')}` 761 | } 762 | } 763 | -------------------------------------------------------------------------------- /src/utils/regex.js: -------------------------------------------------------------------------------- 1 | // splits a transformation chain 2 | export const transforms = /\)\s*,?\s*/ 3 | 4 | // split at whitespace and comma 5 | export const delimiter = /[\s,]+/ 6 | 7 | // The following regex are used to parse the d attribute of a path 8 | 9 | // Matches all hyphens which are not after an exponent 10 | export const hyphen = /([^e])-/gi 11 | 12 | // Replaces and tests for all path letters 13 | export const pathLetters = /[MLHVCSQTAZ]/gi 14 | 15 | // yes we need this one, too 16 | export const isPathLetter = /[MLHVCSQTAZ]/i 17 | 18 | // matches 0.154.23.45 19 | export const numbersWithDots = /((\d?\.\d+(?:e[+-]?\d+)?)((?:\.\d+(?:e[+-]?\d+)?)+))+/gi 20 | 21 | // matches . 22 | export const dots = /\./g 23 | -------------------------------------------------------------------------------- /src/utils/strUtils.js: -------------------------------------------------------------------------------- 1 | // Ensure to six-based hex 2 | export const fullHex = function (hex) { 3 | return hex.length === 4 4 | ? [ '#', 5 | hex.substring(1, 2), hex.substring(1, 2), 6 | hex.substring(2, 3), hex.substring(2, 3), 7 | hex.substring(3, 4), hex.substring(3, 4) 8 | ].join('') : hex 9 | } 10 | 11 | export const hexToRGB = function (valOrMap) { 12 | if (typeof valOrMap instanceof Map) { 13 | for (const [ key, val ] of valOrMap) { 14 | valOrMap.set(key, hexToRGB(val)) 15 | } 16 | return valOrMap 17 | } 18 | 19 | if (!/#[0-9a-f]{3,6}/.test(valOrMap)) { return valOrMap } 20 | 21 | valOrMap = fullHex(valOrMap) 22 | 23 | return 'rgb(' + [ 24 | parseInt(valOrMap.slice(1, 3), 16), 25 | parseInt(valOrMap.slice(3, 5), 16), 26 | parseInt(valOrMap.slice(5, 7), 16) 27 | ].join(',') + ')' 28 | } 29 | 30 | export function decamelize (s) { 31 | return String(s).replace(/([a-z])([A-Z])/g, function (m, g1, g2) { 32 | return g1 + '-' + g2.toLowerCase() 33 | }) 34 | } 35 | 36 | export function camelCase (s) { 37 | return String(s).replace(/([a-z])-([a-z])/g, function (m, g1, g2) { 38 | return g1 + g2.toUpperCase() 39 | }) 40 | } 41 | 42 | export function removeQuotes (str) { 43 | if (str.startsWith('"') || str.startsWith("'")) { 44 | return str.slice(1, -1) 45 | } 46 | return str 47 | } 48 | 49 | export function htmlEntities (str) { 50 | return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') 51 | } 52 | 53 | export function unhtmlEntities (str) { 54 | return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace('"', '"') 55 | } 56 | 57 | export function cdata (str) { 58 | return `${str}` 59 | } 60 | 61 | export function comment (str) { 62 | return `` 63 | } 64 | 65 | export const splitNotInBrackets = (str, delimiter) => { 66 | var roundBrackets = 0 67 | 68 | var squareBrackets = 0 69 | 70 | var lastIndex = 0 71 | 72 | var split = [] 73 | 74 | var ch; var i; var il 75 | 76 | for (i = 0, il = str.length; i < il; ++i) { 77 | ch = str.charAt(i) 78 | 79 | if (ch === delimiter && !roundBrackets && !squareBrackets) { 80 | split.push(str.slice(lastIndex, i).trim()) 81 | lastIndex = i + 1 82 | continue 83 | } 84 | 85 | if (ch === '(') ++roundBrackets 86 | else if (ch === ')') --roundBrackets 87 | else if (ch === '[') ++squareBrackets 88 | else if (ch === ']') --squareBrackets 89 | } 90 | 91 | split.push(str.slice(lastIndex).trim()) 92 | return split 93 | } 94 | -------------------------------------------------------------------------------- /src/utils/tagUtils.js: -------------------------------------------------------------------------------- 1 | const htmlEntities = function (str) { 2 | return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') 3 | } 4 | 5 | var emptyElements = { 6 | br: true, 7 | hr: true, 8 | img: true, 9 | link: true 10 | } 11 | 12 | export const tag = function (node) { 13 | const attrs = [ ...node.attrs ].map(function (node) { 14 | return (node.prefix ? node.prefix + ':' : '') + node.localName + '="' + htmlEntities(node.value) + '"' 15 | }) 16 | 17 | const { prefix, localName } = node 18 | const qualifiedName = (prefix ? prefix + ':' : '') + localName 19 | 20 | return '<' + [].concat(qualifiedName, attrs).join(' ') + '>' + (emptyElements[qualifiedName.toLowerCase()] ? '' : node.innerHTML + '') 21 | } 22 | 23 | export const cloneNode = function (node) { 24 | 25 | const { prefix, localName, namespaceURI: ns, nodeValue, ownerDocument } = node 26 | 27 | // Build up the correctly cased qualified name 28 | const qualifiedName = (prefix ? prefix + ':' : '') + localName 29 | 30 | // Check if node was created using non-namespace function which can lead to : in the localName. 31 | // This check allows false negatives because `local` only matters IF there are : in the localName 32 | // and we dont care about it when there are non 33 | const local = localName.includes(':') 34 | 35 | var clone = new node.constructor(qualifiedName, { 36 | attrs: new Set([ ...node.attrs ].map(node => node.cloneNode())), 37 | nodeValue, 38 | ownerDocument, 39 | local 40 | }, ns) 41 | 42 | return clone 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/textUtils.js: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import * as fontkit from 'fontkit' 3 | import * as defaults from './defaults.js' 4 | import { Box, NoBox } from '../other/Box.js' 5 | import { getConfig, getFonts } from '../config.js' 6 | 7 | export const textBBox = function (text, x, y, details) { 8 | 9 | if (!text) return new NoBox() 10 | 11 | const config = getConfig() 12 | const preloaded = getFonts() 13 | 14 | const families = (details.fontFamily || defaults.fontFamily).split(/\s*,\s*/) 15 | const fontMap = Object.assign({}, defaults.fontFamilyMappings, config.fontFamilyMappings) 16 | const fontSize = Number(`${details.fontSize}`.replace(/\D/g, '') || defaults.fontSize) 17 | const fontDir = config.fontDir || defaults.fontDir 18 | let fontFamily 19 | let font 20 | 21 | for (let i = 0, il = families.length; i < il; ++i) { 22 | if (fontMap[families[i]]) { 23 | fontFamily = families[i] 24 | break 25 | } 26 | } 27 | 28 | if (!fontFamily) { 29 | fontFamily = defaults.fontFamily 30 | } 31 | 32 | if (preloaded[fontFamily]) { 33 | font = preloaded[fontFamily] 34 | } else { 35 | const filename = path.join(fontDir, fontMap[fontFamily]) 36 | try { 37 | font = fontkit.openSync(filename) 38 | } catch (e) { 39 | console.warn(`Could not open font "${fontFamily}" in file "${filename}". ${e.toString()}`) 40 | return new NoBox() 41 | } 42 | 43 | preloaded[fontFamily] = font 44 | } 45 | 46 | const fontHeight = font.ascent - font.descent 47 | const lineHeight = fontHeight > font.unitsPerEm ? fontHeight : fontHeight + font.lineGap 48 | 49 | const height = lineHeight / font.unitsPerEm * fontSize 50 | const width = font.layout(text).glyphs.reduce((last, curr) => last + curr.advanceWidth, 0) / font.unitsPerEm * fontSize 51 | 52 | // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-anchor 53 | let xAdjust = 0 54 | if (details.textAnchor === 'end') { 55 | xAdjust = -width 56 | } else if (details.textAnchor === 'middle') { 57 | xAdjust = -width / 2 58 | } 59 | 60 | // https://www.w3.org/TR/2002/WD-css3-linebox-20020515/ 61 | // 4.2. Baseline identifiers 62 | let yAdjust = font.ascent // alphabetic 63 | if (details.dominantBaseline === 'before-edge' || details.dominantBaseline === 'text-before-edge') { 64 | yAdjust = 0 65 | } else if (details.dominantBaseline === 'hanging') { 66 | yAdjust = font.ascent - font.xHeight - font.capHeight 67 | } else if (details.dominantBaseline === 'mathematical') { 68 | yAdjust = font.ascent - font.xHeight 69 | } else if (details.dominantBaseline === 'middle') { 70 | yAdjust = font.ascent - font.xHeight / 2 71 | } else if (details.dominantBaseline === 'central') { 72 | yAdjust = font.ascent / 2 + font.descent / 2 73 | } else if (details.dominantBaseline === 'ideographic') { 74 | yAdjust = font.ascent + font.descent 75 | } 76 | 77 | return new Box(x + xAdjust, y - yAdjust / font.unitsPerEm * fontSize, width, height) 78 | } 79 | -------------------------------------------------------------------------------- /test/001-svg-dom.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before, after, beforeEach, puppeteer */ 2 | 3 | // import puppeteer from 'puppeteer' 4 | import assert from 'assert' 5 | // import { DOMParser, XMLSerializer, DOMImplementation } from 'xmldom' 6 | import { createSVGDocument } from '../main-module.js' 7 | // import fs from 'fs' 8 | 9 | // var svgString 10 | let svgDoc 11 | let svgRoot 12 | 13 | // function makeEl (nodeName, attrs = {}) { 14 | // var svgNS = 'http://www.w3.org/2000/svg' 15 | // var el = this.createElementNS(svgNS, nodeName) 16 | // Object.keys(attrs).forEach(attrName => { el.setAttribute(attrName, attrs[attrName]) }) 17 | // return el 18 | // } 19 | 20 | let browser, page 21 | 22 | const testEnv = process.env.TEST_BROWSER ? 'browser' : 'node' 23 | 24 | function wrappedIt (message, testFn) { 25 | if (testEnv === 'browser') { 26 | return it(message, () => page.evaluate(testFn)) // page.evaluate will return promise 27 | } 28 | return it(message, testFn) 29 | } 30 | 31 | wrappedIt.skip = function (message, testFn) { 32 | return it.skip(message, testFn) 33 | } 34 | 35 | wrappedIt.only = function (message, testFn) { 36 | if (testEnv === 'browser') { 37 | return it.only(message, () => page.evaluate(testFn)) // page.evaluate will return promise 38 | } 39 | return it.only(message, testFn) 40 | } 41 | 42 | describe('svg document', () => { 43 | 44 | before(async () => { 45 | 46 | if (testEnv === 'browser') { 47 | 48 | const crConfig = { 49 | headless: false, // replace with false to check rendering 50 | args: [ 51 | '--disable-infobars', 52 | '--disable-gpu' 53 | // '--disable-web-security', 54 | // '--user-data-dir' 55 | ] 56 | } 57 | 58 | if (process.env.CHROMIUM_PATH) { 59 | crConfig.executablePath = process.env.CHROMIUM_PATH + '/Contents/MacOS/Chromium' 60 | } 61 | 62 | browser = await puppeteer.launch(crConfig) 63 | page = await browser.newPage() 64 | 65 | await page.goto( 66 | 'about:blank', 67 | { waitUntil: 'load' } 68 | ) 69 | 70 | await page.evaluate(() => { 71 | window.assert = function (check, message) { 72 | if (!check) throw (message || 'Assertion failed') 73 | } 74 | 75 | window.assert.strictEqual = function (value1, value2, message) { 76 | if (value1 !== value2) throw (message || 'Assertion failed') 77 | } 78 | }) 79 | 80 | } 81 | }) 82 | 83 | after(async () => { 84 | const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) 85 | await delay(1000) 86 | browser && browser.close() 87 | }) 88 | 89 | beforeEach(async () => { 90 | 91 | function prepare () { 92 | 93 | const svgNS = 'http://www.w3.org/2000/svg' 94 | 95 | svgRoot.appendChild(svgDoc.createTextNode('\n ')) 96 | 97 | const g = svgDoc.createElementNS(svgNS, 'g') 98 | g.id = 'g-1' 99 | g.setAttribute('transform', 'translate(20, 20) scale(10)') 100 | 101 | svgRoot.appendChild(g) 102 | 103 | g.appendChild(svgDoc.createTextNode('\n ')) 104 | 105 | const gRect = svgDoc.createElementNS(svgNS, 'g') 106 | gRect.setAttribute('transform', 'translate(15)') 107 | g.appendChild(gRect) 108 | 109 | let rect = svgDoc.createElementNS(svgNS, 'rect') 110 | rect.id = 'rect-1' 111 | rect.setAttribute('x', 0) 112 | rect.setAttribute('y', 0) 113 | rect.setAttribute('width', 10) 114 | rect.setAttribute('height', 10) 115 | rect.setAttribute('fill', '#c63') 116 | 117 | gRect.appendChild(rect) 118 | 119 | gRect.appendChild(svgDoc.createTextNode('\n ')) 120 | 121 | rect = svgDoc.createElementNS(svgNS, 'rect') 122 | rect.id = 'rect-2' 123 | rect.setAttribute('x', 0) 124 | rect.setAttribute('y', 0) 125 | rect.setAttribute('width', 10) 126 | rect.setAttribute('height', 10) 127 | rect.setAttribute('fill', '#63c') 128 | 129 | gRect.appendChild(rect) 130 | 131 | const gCircle = svgDoc.createElementNS(svgNS, 'g') 132 | g.appendChild(gCircle) 133 | 134 | const circle = svgDoc.createElementNS(svgNS, 'circle') 135 | circle.id = 'circle-1' 136 | circle.setAttribute('cx', 5) 137 | circle.setAttribute('cy', 5) 138 | circle.setAttribute('r', 5) 139 | circle.setAttribute('fill', '#6c3') 140 | 141 | gCircle.appendChild(circle) 142 | 143 | const text = svgDoc.createElementNS(svgNS, 'text') 144 | text.id = 'text-1' 145 | text.setAttribute('x', 5) 146 | text.setAttribute('y', 5) 147 | 148 | text.appendChild(svgDoc.createTextNode('TEXT')) 149 | 150 | g.appendChild(text) 151 | 152 | } 153 | 154 | if (testEnv === 'browser') { 155 | await page.evaluate(() => { 156 | const svgNS = 'http://www.w3.org/2000/svg' 157 | const svg = window.svgRoot = document.createElementNS(svgNS, 'svg') 158 | svg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink') 159 | svg.setAttribute('height', '200') 160 | svg.setAttribute('width', '400') 161 | svg.setAttribute('viewPort', '0 0 200 400') 162 | svg.setAttribute('style', 'background-color: #eee') 163 | 164 | document.body.appendChild(svg) 165 | window.svgDoc = document 166 | }) 167 | 168 | await page.evaluate(prepare) 169 | } else { 170 | svgDoc = createSVGDocument() 171 | svgRoot = svgDoc.documentElement 172 | 173 | prepare() 174 | } 175 | 176 | }) 177 | 178 | wrappedIt('should have children method for nodes', () => { 179 | assert(svgRoot.children) 180 | }) 181 | 182 | wrappedIt('should have createComment method', () => { 183 | assert(svgDoc.createComment('xxx')) 184 | }) 185 | 186 | wrappedIt.skip('should have ownerSVGElement property for nodes', () => { 187 | // this will not work with documents, embedded into html 188 | // but should work with svg docs as media 189 | if (svgDoc.documentElement.nodeName === 'svg') { assert(svgRoot.ownerSVGElement) } 190 | // assert(svgRoot.ownerSVGElement === svgRoot); 191 | }) 192 | 193 | wrappedIt('transform: rotate', () => { 194 | 195 | const circle = svgRoot.querySelector('#circle-1') 196 | const g = circle.parentNode 197 | 198 | const bbox1 = g.getBBox() 199 | 200 | circle.setAttribute('transform', 'rotate (180)') 201 | 202 | const bbox2 = g.getBBox() 203 | 204 | // floats! 205 | assert(bbox1.x - bbox2.x < 10.001) 206 | assert(bbox1.x - bbox2.x > 9.999) 207 | assert(bbox1.y - bbox2.y < 10.001) 208 | assert(bbox1.y - bbox2.y > 9.999) 209 | assert.strictEqual(bbox1.width.toFixed(3), bbox2.width.toFixed(3)) 210 | assert.strictEqual(bbox1.height.toFixed(3), bbox2.height.toFixed(3)) 211 | 212 | circle.setAttribute('transform', 'rotate (90, 5, 5)') 213 | 214 | const bbox3 = g.getBBox() 215 | 216 | assert.strictEqual(bbox1.x.toFixed(3), bbox3.x.toFixed(3)) 217 | assert.strictEqual(bbox1.y.toFixed(3), bbox3.y.toFixed(3)) 218 | assert.strictEqual(bbox1.width, bbox3.width) 219 | assert.strictEqual(bbox1.height, bbox3.height) 220 | }) 221 | 222 | wrappedIt('transforms', () => { 223 | const rect = svgRoot.querySelector('#rect-1') 224 | 225 | const x = 0; const y = 0; const width = 10; const height = 10 226 | 227 | let bbox = rect.getBBox() 228 | 229 | assert.strictEqual(bbox.x, x) 230 | assert.strictEqual(bbox.y, y) 231 | assert.strictEqual(bbox.width, width) 232 | assert.strictEqual(bbox.height, height) 233 | 234 | rect.setAttribute('transform', 'rotate(45)') 235 | 236 | bbox = rect.parentNode.getBBox() 237 | 238 | assert(bbox.width > width) 239 | assert.strictEqual(bbox.width.toFixed(3), (Math.sqrt(2 * width * width) / 2 + width).toFixed(3)) 240 | assert(bbox.height > height) 241 | assert.strictEqual(bbox.height.toFixed(3), (Math.sqrt(2 * height * height)).toFixed(3)) 242 | 243 | rect.setAttribute('transform', '') 244 | 245 | const rect2 = svgRoot.querySelector('#rect-2') 246 | 247 | rect2.setAttribute('transform', 'translate(15, 0)') 248 | 249 | const circle = svgRoot.querySelector('#circle-1') 250 | 251 | bbox = circle.getBBox() 252 | 253 | assert.strictEqual(bbox.x, x) 254 | assert.strictEqual(bbox.y, y) 255 | assert.strictEqual(bbox.width, width) 256 | assert.strictEqual(bbox.height, height) 257 | 258 | circle.setAttribute('transform', 'translate(15, 0)') 259 | 260 | bbox = circle.parentNode.getBBox() 261 | 262 | assert.strictEqual(bbox.x, x + 15) 263 | assert.strictEqual(bbox.width, width) 264 | 265 | // scales from 0, 0 266 | circle.setAttribute('transform', 'scale(2)') 267 | 268 | bbox = circle.parentNode.getBBox() 269 | 270 | assert.strictEqual(bbox.x, x) 271 | assert.strictEqual(bbox.y, y) 272 | assert.strictEqual(bbox.width, width * 2) 273 | assert.strictEqual(bbox.height, height * 2) 274 | 275 | const g = svgRoot.querySelector('#g-1') 276 | 277 | bbox = g.getBBox() 278 | 279 | /* 280 | console.log (bbox); 281 | 282 | assert.strictEqual (bbox.x, 0); 283 | assert.strictEqual (bbox.y, 0); 284 | assert.strictEqual (bbox.width, 25); 285 | assert.strictEqual (bbox.height, 20); 286 | */ 287 | }) 288 | 289 | wrappedIt('transform: translateX', () => { 290 | 291 | const rect = svgRoot.querySelector('#rect-1') 292 | 293 | const bbox1 = rect.getBBox() 294 | 295 | rect.setAttribute('transform', 'translate(15)') 296 | 297 | const bbox2 = rect.getBBox() 298 | 299 | assert.strictEqual(bbox1.x, bbox2.x, 'Translated element should have the same BBox') 300 | 301 | const bbox3 = rect.parentNode.getBBox() 302 | 303 | assert.strictEqual(bbox1.x, bbox3.x, 'x in not affected because #rect-2 not transformed') 304 | assert.strictEqual(bbox1.width, bbox3.width - 15) 305 | 306 | }) 307 | 308 | wrappedIt('transform: scaleXY', () => { 309 | 310 | const rect = svgRoot.querySelector('#rect-1') 311 | 312 | const bbox1 = rect.getBBox() 313 | 314 | rect.setAttribute('transform', 'scale(2, 0.5)') 315 | 316 | const bbox2 = rect.getBBox() 317 | 318 | assert.strictEqual(bbox1.width, bbox2.width) 319 | assert.strictEqual(bbox1.height, bbox2.height) 320 | 321 | const bbox3 = rect.parentNode.getBBox() 322 | 323 | assert.strictEqual(bbox3.width, 20) 324 | 325 | }) 326 | 327 | wrappedIt('exposed style attribute on attributes enumeration', () => { 328 | 329 | const connector = svgRoot.querySelector('#rect-1') 330 | 331 | assert.strictEqual(connector.getAttribute('style'), null) 332 | 333 | connector.style.fill = 'black' 334 | 335 | assert(connector.getAttribute('style').match(/^fill:\s*black\b/)) 336 | 337 | connector.style.setProperty('color', 'green') 338 | 339 | assert(connector.getAttribute('style').match(/color:\s*green\b/)) 340 | 341 | assert([].some.call(connector.attributes, attr => attr.nodeName === 'style')) 342 | }) 343 | 344 | wrappedIt('should match [attr^=startsWith] css selector', () => { 345 | 346 | const connector = svgRoot.querySelector('[id^=rect-1]') 347 | 348 | assert(connector) 349 | 350 | const connectors = svgRoot.querySelectorAll('[id^=rect]') 351 | 352 | assert(connectors) 353 | 354 | assert.strictEqual(connectors.length, 2) 355 | }) 356 | 357 | wrappedIt('closest() should find ancestors', () => { 358 | 359 | const rect1 = svgRoot.querySelector('#rect-1') 360 | 361 | assert.strictEqual(rect1.closest('svg').localName, 'svg') 362 | 363 | assert.strictEqual(rect1.closest('g[id]').id, 'g-1', 'attribute selector') 364 | 365 | assert.strictEqual(rect1.closest('g:not([id])').id, '', 'negated attribute selector') 366 | 367 | assert.strictEqual(rect1.closest('foobar'), null, 'non-matching selector') 368 | 369 | assert.strictEqual(rect1.closest('g:last-child').id, 'g-1', 'pseudo-class') 370 | 371 | assert.strictEqual(rect1.closest('[id]:scope').id, 'rect-1', ':scope') 372 | 373 | assert.strictEqual(rect1.closest('[id]:not(:scope)').id, 'g-1', 'negated :scope') 374 | }) 375 | 376 | wrappedIt('text-anchor should affect bbox', () => { 377 | 378 | const text = svgRoot.querySelector('#text-1') 379 | 380 | assert(text) 381 | 382 | text.setAttribute('text-anchor', 'start') 383 | 384 | const bbox1 = text.getBBox() 385 | 386 | text.setAttribute('text-anchor', 'end') 387 | 388 | const bbox2 = text.getBBox() 389 | 390 | assert.strictEqual('' + bbox1.x, (bbox2.x + bbox2.width).toFixed(0)) 391 | assert.strictEqual(bbox1.y, bbox2.y) 392 | assert.strictEqual(bbox1.width, bbox2.width) 393 | 394 | }) 395 | 396 | }) 397 | -------------------------------------------------------------------------------- /test/002_escaped-text.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | 3 | import { createSVGDocument } from '../main-module.js' 4 | import assert from 'assert' 5 | 6 | describe('escaped-text', () => { 7 | it(' svg with text contain html elements should be printable ', () => { 8 | const svgDoc = createSVGDocument() 9 | const node = svgDoc.createElementNS('http://www.w3.org/2000/svg', 'text') 10 | node.appendChild(svgDoc.createTextNode('A { 8 | it("bbox('<').x should be less then bbox('WW') ", () => { 9 | const svgDoc = createSVGWindow().document 10 | const svgRoot = svgDoc.documentElement 11 | const textLt = svgDoc.createElement('text') 12 | textLt.textContent = '<' 13 | const textWW = svgDoc.createElement('text') 14 | textWW.textContent = 'W' 15 | svgRoot.appendChild(textLt) 16 | svgRoot.appendChild(textWW) 17 | const bboxLt = getSegments(textLt).bbox() 18 | const bboxWW = getSegments(textWW).bbox() 19 | assert(bboxLt.width < bboxWW.width) 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /test/004-circle-length.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | 3 | import { createSVGWindow } from '../main-module.js' 4 | import assert from 'assert' 5 | import { SVG, registerWindow } from '@svgdotjs/svg.js' 6 | 7 | const window = createSVGWindow() 8 | const document = window.document 9 | registerWindow(window, document) 10 | 11 | describe('circle-length', () => { 12 | it('circumference of circle of radius 49.5 should be close to 99*Math.PI', () => { 13 | const canvas = SVG(document.documentElement) 14 | const circle = canvas.path('M0.5 50a49.5 49.5 0 1 0 99 0 49.5 49.5 0 1 0-99 0') 15 | const len = circle.length() 16 | assert(Math.abs(len - 99 * Math.PI) < 0.0005) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /test/005-svg-length.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { SVGLength } from '../src/dom/svg/SVGLength.js' 3 | import { createSVGDocument } from '../main-module.js' 4 | import { svg as SVG_NS } from '../src/utils/namespaces.js' 5 | import { describe, it } from 'mocha' 6 | 7 | describe('SVGLength', function () { 8 | /** @type {SVGRectElement} */ 9 | let rect 10 | /** @type {SVGLength} */ 11 | let svgLength 12 | 13 | this.beforeEach(function () { 14 | const svgDoc = createSVGDocument() 15 | rect = svgDoc.createElementNS(SVG_NS, 'rect') 16 | svgLength = new SVGLength(rect, 'x') 17 | }) 18 | 19 | it('returns default value', function () { 20 | assert.strictEqual(svgLength.value, 0, 'default value') 21 | assert.strictEqual( 22 | svgLength.unitType, 23 | SVGLength.SVG_LENGTHTYPE_NUMBER, 24 | 'default unit' 25 | ) 26 | }) 27 | 28 | it('parses unitless values', function () { 29 | svgLength.valueAsString = '42' 30 | assert.strictEqual(svgLength.value, 42, 'value') 31 | assert.strictEqual( 32 | svgLength.valueInSpecifiedUnits, 33 | 42, 34 | 'valueInSpecifiedUnits' 35 | ) 36 | assert.strictEqual(svgLength.valueAsString, '42', 'valueAsString') 37 | assert.strictEqual( 38 | svgLength.unitType, 39 | SVGLength.SVG_LENGTHTYPE_NUMBER, 40 | 'default unit' 41 | ) 42 | }) 43 | 44 | it('parses values with units', function () { 45 | svgLength.valueAsString = '1in' 46 | assert.strictEqual(svgLength.value, 96, 'value') 47 | assert.strictEqual( 48 | svgLength.valueInSpecifiedUnits, 49 | 1, 50 | 'valueInSpecifiedUnits' 51 | ) 52 | assert.strictEqual(svgLength.valueAsString, '1in', 'valueAsString') 53 | assert.strictEqual(rect.getAttribute('x'), '1in', 'getAttribute') 54 | assert.strictEqual(svgLength.unitType, SVGLength.SVG_LENGTHTYPE_IN) 55 | }) 56 | 57 | it('handles values with unknown units', function () { 58 | rect.setAttribute('x', '4dm') 59 | assert.strictEqual(svgLength.value, 0, 'value') 60 | assert.strictEqual( 61 | svgLength.valueInSpecifiedUnits, 62 | 0, 63 | 'valueInSpecifiedUnits' 64 | ) 65 | assert.strictEqual(svgLength.valueAsString, '0', 'valueAsString') 66 | assert.strictEqual(svgLength.unitType, SVGLength.SVG_LENGTHTYPE_NUMBER) 67 | }) 68 | 69 | it('allows setting value', function () { 70 | svgLength.valueAsString = '1in' 71 | svgLength.value = 2 * 96 72 | assert.strictEqual(svgLength.value, 2 * 96, 'value') 73 | assert.strictEqual( 74 | svgLength.valueInSpecifiedUnits, 75 | 2, 76 | 'valueInSpecifiedUnits' 77 | ) 78 | assert.strictEqual(svgLength.valueAsString, '2in', 'valueInSpecifiedUnits') 79 | }) 80 | 81 | it('allows setting valueInSpecifiedUnits', function () { 82 | svgLength.valueAsString = '1in' 83 | svgLength.valueInSpecifiedUnits = 2 84 | assert.strictEqual(svgLength.value, 2 * 96, 'value') 85 | assert.strictEqual( 86 | svgLength.valueInSpecifiedUnits, 87 | 2, 88 | 'valueInSpecifiedUnits' 89 | ) 90 | assert.strictEqual(svgLength.valueAsString, '2in', 'valueInSpecifiedUnits') 91 | }) 92 | 93 | it('supports some aspects of not fully supported units, throws for unsupported features', function () { 94 | svgLength.valueAsString = '50%' 95 | assert.strictEqual(rect.getAttribute('x'), '50%', 'getAttribute') 96 | assert.strictEqual(svgLength.valueAsString, '50%', 'valueAsString') 97 | assert.strictEqual( 98 | svgLength.valueInSpecifiedUnits, 99 | 50, 100 | 'valueInSpecifiedUnits' 101 | ) 102 | assert.throws(() => svgLength.value, 'length') 103 | }) 104 | }) 105 | -------------------------------------------------------------------------------- /test/006-svg-rect-element.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import assert from 'assert' 4 | import { SVGLength } from '../src/dom/svg/SVGLength.js' 5 | import { createSVGDocument } from '../main-module.js' 6 | import { describe, it } from 'mocha' 7 | 8 | describe('SVGRectElement', function () { 9 | /** @type {SVGRectElement} */ 10 | let rect 11 | /** @type {Element} */ 12 | let svgElement 13 | 14 | this.beforeEach(function () { 15 | const svgDoc = createSVGDocument() 16 | svgElement = svgDoc.documentElement 17 | svgElement.innerHTML = '' 18 | rect = svgElement.children[0] 19 | }) 20 | 21 | it('has animatedLength properties', function () { 22 | assert.strictEqual(rect.width.baseVal.value, 10, 'width value') 23 | assert.strictEqual(rect.width.baseVal.unitType, SVGLength.SVG_LENGTHTYPE_NUMBER, 'width unit') 24 | assert.strictEqual(rect.height.baseVal.value, 96, 'height value') 25 | assert.strictEqual(rect.height.baseVal.unitType, SVGLength.SVG_LENGTHTYPE_IN, 'height unit') 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /test/007-append-prepend.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import assert from 'assert' 4 | import { createSVGDocument } from '../main-module.js' 5 | import { svg } from '../src/utils/namespaces.js' 6 | import { describe, it } from 'mocha' 7 | 8 | describe('append', function () { 9 | /** @type {Element} */ 10 | let svgElement 11 | 12 | this.beforeEach(function () { 13 | const svgDoc = createSVGDocument() 14 | svgElement = svgDoc.documentElement 15 | svgElement.innerHTML = '' 16 | }) 17 | 18 | it('appends one node', function () { 19 | svgElement.append(svgElement.ownerDocument.createElementNS(svg, 'circle')) 20 | assert.strictEqual(svgElement.children.length, 2) 21 | assert.strictEqual(svgElement.children[1].tagName, 'circle') 22 | }) 23 | 24 | it('appends multiple nodes', function () { 25 | svgElement.append(svgElement.ownerDocument.createElementNS(svg, 'circle'), svgElement.ownerDocument.createElementNS(svg, 'circle')) 26 | assert.strictEqual(svgElement.children.length, 3) 27 | assert.strictEqual(svgElement.children[1].tagName, 'circle') 28 | assert.strictEqual(svgElement.children[2].tagName, 'circle') 29 | }) 30 | }) 31 | 32 | describe('prepend', function () { 33 | /** @type {Element} */ 34 | let svgElement 35 | 36 | this.beforeEach(function () { 37 | const svgDoc = createSVGDocument() 38 | svgElement = svgDoc.documentElement 39 | svgElement.innerHTML = '' 40 | }) 41 | 42 | it('prepends one node', function () { 43 | svgElement.prepend(svgElement.ownerDocument.createElementNS(svg, 'circle')) 44 | assert.strictEqual(svgElement.children.length, 2) 45 | assert.strictEqual(svgElement.children[0].tagName, 'circle') 46 | }) 47 | 48 | it('prepends multiple nodes', function () { 49 | svgElement.prepend(svgElement.ownerDocument.createElementNS(svg, 'circle'), svgElement.ownerDocument.createElementNS(svg, 'circle')) 50 | assert.strictEqual(svgElement.children.length, 3) 51 | assert.strictEqual(svgElement.children[0].tagName, 'circle') 52 | assert.strictEqual(svgElement.children[1].tagName, 'circle') 53 | }) 54 | }) 55 | -------------------------------------------------------------------------------- /test/008-before-after-replaceWith-remove.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import assert from 'assert' 4 | import { createSVGDocument } from '../main-module.js' 5 | import { svg } from '../src/utils/namespaces.js' 6 | import { describe, it } from 'mocha' 7 | 8 | describe('before', function () { 9 | /** @type {Element} */ 10 | let svgElement, rect 11 | 12 | this.beforeEach(function () { 13 | const svgDoc = createSVGDocument() 14 | svgElement = svgDoc.documentElement 15 | svgElement.innerHTML = '' 16 | rect = svgElement.children[0] 17 | }) 18 | 19 | it('inserts one node', function () { 20 | rect.before(svgElement.ownerDocument.createElementNS(svg, 'circle')) 21 | assert.strictEqual(svgElement.children.length, 2) 22 | assert.strictEqual(svgElement.children[0].tagName, 'circle') 23 | }) 24 | 25 | it('inserts multiple nodes', function () { 26 | rect.before( 27 | svgElement.ownerDocument.createElementNS(svg, 'circle'), 28 | svgElement.ownerDocument.createElementNS(svg, 'circle'), 29 | 'circle' 30 | ) 31 | 32 | assert.strictEqual(svgElement.childNodes.length, 4) 33 | assert.strictEqual(svgElement.childNodes[0].nodeName, 'circle') 34 | assert.strictEqual(svgElement.childNodes[1].nodeName, 'circle') 35 | assert.strictEqual(svgElement.childNodes[2].nodeName, '#text') 36 | }) 37 | }) 38 | 39 | describe('after', function () { 40 | /** @type {Element} */ 41 | let svgElement, rect 42 | 43 | this.beforeEach(function () { 44 | const svgDoc = createSVGDocument() 45 | svgElement = svgDoc.documentElement 46 | svgElement.innerHTML = '' 47 | rect = svgElement.children[0] 48 | }) 49 | 50 | it('inserts one node', function () { 51 | rect.after(svgElement.ownerDocument.createElementNS(svg, 'circle')) 52 | assert.strictEqual(svgElement.children.length, 2) 53 | assert.strictEqual(svgElement.children[1].tagName, 'circle') 54 | }) 55 | 56 | it('inserts multiple nodes', function () { 57 | rect.after( 58 | svgElement.ownerDocument.createElementNS(svg, 'circle'), 59 | svgElement.ownerDocument.createElementNS(svg, 'circle'), 60 | 'circle' 61 | ) 62 | assert.strictEqual(svgElement.childNodes.length, 4) 63 | assert.strictEqual(svgElement.childNodes[1].nodeName, 'circle') 64 | assert.strictEqual(svgElement.childNodes[2].nodeName, 'circle') 65 | assert.strictEqual(svgElement.childNodes[3].nodeName, '#text') 66 | }) 67 | }) 68 | 69 | describe('replaceWith', function () { 70 | /** @type {Element} */ 71 | let svgElement, rect 72 | 73 | this.beforeEach(function () { 74 | const svgDoc = createSVGDocument() 75 | svgElement = svgDoc.documentElement 76 | svgElement.innerHTML = '' 77 | rect = svgElement.children[0] 78 | }) 79 | 80 | it('inserts one node', function () { 81 | rect.replaceWith(svgElement.ownerDocument.createElementNS(svg, 'circle')) 82 | assert.strictEqual(svgElement.children.length, 1) 83 | assert.strictEqual(svgElement.children[0].tagName, 'circle') 84 | }) 85 | 86 | it('inserts multiple nodes', function () { 87 | rect.replaceWith( 88 | svgElement.ownerDocument.createElementNS(svg, 'circle'), 89 | svgElement.ownerDocument.createElementNS(svg, 'circle'), 90 | 'circle' 91 | ) 92 | assert.strictEqual(svgElement.childNodes.length, 3) 93 | assert.strictEqual(svgElement.childNodes[0].nodeName, 'circle') 94 | assert.strictEqual(svgElement.childNodes[1].nodeName, 'circle') 95 | assert.strictEqual(svgElement.childNodes[2].nodeName, '#text') 96 | }) 97 | }) 98 | 99 | describe('remove', function () { 100 | /** @type {Element} */ 101 | let svgElement, rect 102 | 103 | this.beforeEach(function () { 104 | const svgDoc = createSVGDocument() 105 | svgElement = svgDoc.documentElement 106 | svgElement.innerHTML = '' 107 | rect = svgElement.children[0] 108 | }) 109 | 110 | it('removes the node', function () { 111 | rect.remove() 112 | assert.strictEqual(svgElement.children.length, 0) 113 | }) 114 | }) 115 | -------------------------------------------------------------------------------- /test/009-selectors.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import assert from 'assert' 4 | import { CssQuery } from '../src/other/CssQuery.js' 5 | import { describe, it } from 'mocha' 6 | import { createHTMLDocument } from '../main-module.js' 7 | 8 | const document = createHTMLDocument() 9 | 10 | describe('CssQuery - Single Selector', function () { 11 | it('parses a simple selector', function () { 12 | const query = new CssQuery('div') 13 | 14 | const trueCase = document.createElement('div') 15 | const falseCase = document.createElement('span') 16 | 17 | assert.ok(query.matches(trueCase)) 18 | assert.ok(!query.matches(falseCase)) 19 | }) 20 | 21 | it('parses a simple selector with a class', function () { 22 | const query = new CssQuery('div.foo') 23 | 24 | const trueCase = document.createElement('div') 25 | trueCase.setAttribute('class', 'foo') 26 | const falseCase = document.createElement('div') 27 | 28 | assert.ok(query.matches(trueCase)) 29 | assert.ok(!query.matches(falseCase)) 30 | }) 31 | 32 | it('parses a simple selector with an id', function () { 33 | const query = new CssQuery('div#foo') 34 | 35 | const trueCase = document.createElement('div') 36 | trueCase.setAttribute('id', 'foo') 37 | const falseCase = document.createElement('div') 38 | 39 | assert.ok(query.matches(trueCase)) 40 | assert.ok(!query.matches(falseCase)) 41 | }) 42 | 43 | it('parses a simple selector with an attribute', function () { 44 | const query = new CssQuery('div[foo="bar"]') 45 | 46 | const trueCase = document.createElement('div') 47 | trueCase.setAttribute('foo', 'bar') 48 | const falseCase = document.createElement('div') 49 | 50 | assert.ok(query.matches(trueCase)) 51 | assert.ok(!query.matches(falseCase)) 52 | }) 53 | 54 | it('parses a simple selector with a pseudo-class', function () { 55 | const query = new CssQuery('div:first-child') 56 | 57 | const parent = document.createElement('div') 58 | const trueCase = document.createElement('div') 59 | const falseCase = document.createElement('span') 60 | 61 | parent.appendChild(trueCase) 62 | parent.appendChild(falseCase) 63 | 64 | assert.ok(query.matches(trueCase)) 65 | assert.ok(!query.matches(falseCase)) 66 | }) 67 | 68 | it('parses a selector with all combined', function () { 69 | const query = new CssQuery('div.foo#bar[foo="bar"]:first-child') 70 | 71 | const parent = document.createElement('div') 72 | const trueCase = document.createElement('div') 73 | trueCase.setAttribute('id', 'bar') 74 | trueCase.setAttribute('class', 'foo') 75 | trueCase.setAttribute('foo', 'bar') 76 | const falseCase = document.createElement('span') 77 | 78 | parent.appendChild(trueCase) 79 | parent.appendChild(falseCase) 80 | 81 | assert.ok(query.matches(trueCase)) 82 | assert.ok(!query.matches(falseCase)) 83 | }) 84 | 85 | it('parses a selector . and # inside attribute', function () { 86 | const query = new CssQuery('div[foo="bar#baz.blub"]') 87 | 88 | const trueCase = document.createElement('div') 89 | trueCase.setAttribute('foo', 'bar#baz.blub') 90 | const falseCase = document.createElement('div') 91 | 92 | assert.ok(query.matches(trueCase)) 93 | assert.ok(!query.matches(falseCase)) 94 | }) 95 | }) 96 | 97 | describe('CssQuery - Multiple Selectors', function () { 98 | it('parses a simple selector list', function () { 99 | const query = new CssQuery('div, span') 100 | 101 | const trueCase = document.createElement('div') 102 | const trueCase2 = document.createElement('span') 103 | const falseCase = document.createElement('p') 104 | 105 | assert.ok(query.matches(trueCase)) 106 | assert.ok(query.matches(trueCase2)) 107 | assert.ok(!query.matches(falseCase)) 108 | }) 109 | 110 | it('parses a simple selector list with a class', function () { 111 | const query = new CssQuery('div.foo, span') 112 | 113 | const trueCase = document.createElement('div') 114 | trueCase.setAttribute('class', 'foo') 115 | const trueCase2 = document.createElement('span') 116 | const falseCase = document.createElement('p') 117 | 118 | assert.ok(query.matches(trueCase)) 119 | assert.ok(query.matches(trueCase2)) 120 | assert.ok(!query.matches(falseCase)) 121 | }) 122 | 123 | it('parses a simple selector list with an id', function () { 124 | const query = new CssQuery('div#foo, span') 125 | 126 | const trueCase = document.createElement('div') 127 | trueCase.setAttribute('id', 'foo') 128 | const trueCase2 = document.createElement('span') 129 | const falseCase = document.createElement('p') 130 | 131 | assert.ok(query.matches(trueCase)) 132 | assert.ok(query.matches(trueCase2)) 133 | assert.ok(!query.matches(falseCase)) 134 | }) 135 | 136 | it('parses a simple selector list with an attribute', function () { 137 | const query = new CssQuery('div[foo="bar"], span') 138 | 139 | const trueCase = document.createElement('div') 140 | trueCase.setAttribute('foo', 'bar') 141 | const trueCase2 = document.createElement('span') 142 | const falseCase = document.createElement('p') 143 | 144 | assert.ok(query.matches(trueCase)) 145 | assert.ok(query.matches(trueCase2)) 146 | assert.ok(!query.matches(falseCase)) 147 | }) 148 | 149 | it('parses a simple selector list with a pseudo-class', function () { 150 | const query = new CssQuery('div:first-child, span') 151 | 152 | const parent = document.createElement('div') 153 | const trueCase = document.createElement('div') 154 | const trueCase2 = document.createElement('span') 155 | const falseCase = document.createElement('p') 156 | 157 | parent.appendChild(trueCase) 158 | parent.appendChild(trueCase2) 159 | parent.appendChild(falseCase) 160 | 161 | assert.ok(query.matches(trueCase)) 162 | assert.ok(query.matches(trueCase2)) 163 | assert.ok(!query.matches(falseCase)) 164 | }) 165 | 166 | it('parses a selector list with all combined', function () { 167 | const query = new CssQuery('div.foo#bar[foo="bar"]:first-child, span') 168 | 169 | const parent = document.createElement('div') 170 | const trueCase = document.createElement('div') 171 | trueCase.setAttribute('id', 'bar') 172 | trueCase.setAttribute('class', 'foo') 173 | trueCase.setAttribute('foo', 'bar') 174 | const trueCase2 = document.createElement('span') 175 | const falseCase = document.createElement('p') 176 | 177 | parent.appendChild(trueCase) 178 | parent.appendChild(trueCase2) 179 | parent.appendChild(falseCase) 180 | 181 | assert.ok(query.matches(trueCase)) 182 | assert.ok(query.matches(trueCase2)) 183 | assert.ok(!query.matches(falseCase)) 184 | }) 185 | 186 | it('parses a selector list with all combined and a class', function () { 187 | const query = new CssQuery('div.foo#bar[foo="bar"]:first-child, span.blub') 188 | 189 | const parent = document.createElement('div') 190 | const trueCase = document.createElement('div') 191 | trueCase.setAttribute('id', 'bar') 192 | trueCase.setAttribute('class', 'foo') 193 | trueCase.setAttribute('foo', 'bar') 194 | const trueCase2 = document.createElement('span') 195 | trueCase2.setAttribute('class', 'blub') 196 | const falseCase = document.createElement('p') 197 | 198 | parent.appendChild(trueCase) 199 | parent.appendChild(trueCase2) 200 | parent.appendChild(falseCase) 201 | 202 | assert.ok(query.matches(trueCase)) 203 | assert.ok(query.matches(trueCase2)) 204 | assert.ok(!query.matches(falseCase)) 205 | }) 206 | }) 207 | -------------------------------------------------------------------------------- /test/010-bbox-text.js: -------------------------------------------------------------------------------- 1 | import { createSVGWindow } from "../main-module.js" 2 | import assert from 'assert' 3 | import { getSegments } from "../src/utils/bboxUtils.js" 4 | 5 | describe('bbox-text', () => { 6 | it("text doesn't return NaN in Box values", () => { 7 | const svgDoc = createSVGWindow().document 8 | const svgRoot = svgDoc.documentElement 9 | const text = svgDoc.createElement('text') 10 | text.style.fontSize = '16px'; 11 | text.textContent = 'F' 12 | 13 | svgRoot.appendChild(text) 14 | const bboxText = getSegments(text).bbox(); 15 | 16 | ['left', 'x', 'top', 'y', 'width', 'height', 'right', 'bottom'].forEach((property) => { 17 | assert(!isNaN(bboxText[property])); 18 | }) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require reify --timeout 15000 2 | --------------------------------------------------------------------------------