├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── prettier.config.cjs ├── renovate.json ├── src ├── accessibility.ts ├── css.ts ├── dom.ts ├── element.ts ├── gradients.ts ├── index.ts ├── inline.ts ├── stacking.ts ├── svg.ts ├── test │ ├── PuppeteerAdapter.ts │ ├── injected-script.ts │ ├── recordings │ │ └── https-_2961427464 │ │ │ └── _2166136261 │ │ │ ├── github-com_1228281881 │ │ │ └── felixfbecker_581563825 │ │ │ │ └── dom-to-svg_1518967916 │ │ │ │ └── blob_1673310378 │ │ │ │ └── fee7e1e7b63c888bc1c5205126b05c63073ebdd3_1275420045 │ │ │ │ └── -vscode_2531069167 │ │ │ │ └── settings-json_3995660748 │ │ │ │ └── recording.har │ │ │ ├── news-ycombinator-com_2307374702 │ │ │ └── _2166136261 │ │ │ │ └── recording.har │ │ │ ├── sourcegraph-com_2915255159 │ │ │ ├── extensions_370250955 │ │ │ │ └── recording.har │ │ │ └── search_2150836393 │ │ │ │ └── recording.har │ │ │ └── www-google-com_211838746 │ │ │ └── -hl-en_4217965546 │ │ │ └── recording.har │ ├── snapshots │ │ ├── https%3A%2F%2Fgithub.com%2Ffelixfbecker%2Fdom-to-svg%2Fblob%2Ffee7e1e7b63c888bc1c5205126b05c63073ebdd3%2F.vscode%2Fsettings.json.a11y.json │ │ ├── https%3A%2F%2Fnews.ycombinator.com%2F.a11y.json │ │ ├── https%3A%2F%2Fsourcegraph.com%2Fextensions.a11y.json │ │ ├── https%3A%2F%2Fsourcegraph.com%2Fsearch.a11y.json │ │ └── https%3A%2F%2Fwww.google.com%2F%3Fhl%3Den.a11y.json │ ├── test.ts │ └── util.ts ├── text.ts ├── traversal.ts ├── types │ └── gradient-parser │ │ └── index.d.ts └── util.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = tab 3 | end_of_line = lf 4 | insert_final_newline = true 5 | trim_trailing_whitespace = true 6 | charset = utf-8 7 | 8 | [*.yml] 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.har 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sourcegraph/eslint-config", 3 | "parserOptions": { 4 | "project": "tsconfig.json" 5 | }, 6 | "rules": { 7 | "import/extensions": ["error", "ignorePackages"], 8 | "no-restricted-globals": ["error", "document", "window", "location"], 9 | "etc/throw-error": "off", 10 | "unicorn/import-index": "off", 11 | "callback-return": "off" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | FORCE_COLOR: 3 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Use Node.js 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: '15.x' 17 | - run: npm ci 18 | - run: npm run prettier 19 | - run: npm run eslint 20 | - run: npm run build 21 | - run: npm test 22 | - name: Upload artifacts 23 | if: always() 24 | uses: actions/upload-artifact@v2 25 | with: 26 | name: snapshots 27 | path: src/test/snapshots 28 | - name: release 29 | if: github.event_name == 'push' && github.repository_owner == 'felixfbecker' && github.ref == 'refs/heads/main' 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | run: npm run semantic-release 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | .DS_Store 4 | .cache/ 5 | dist/ 6 | src/test/snapshots/*.png 7 | src/test/snapshots/*.svg 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | lib/test 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | *.har 3 | **/recordings 4 | **/snapshots 5 | node_modules/ 6 | lib/ 7 | dist/ 8 | .cache/ 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "node", 5 | "name": "Launch Mocha", 6 | "request": "launch", 7 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 8 | "cwd": "${workspaceFolder}", 9 | "console": "integratedTerminal", 10 | "runtimeExecutable": "/Users/felix/.local/share/powershell/Modules/nvm/2.4.0/vs/v14.14.0/bin/node", 11 | "args": ["--no-timeouts", "${workspaceFolder}/lib/test/test.js"], 12 | }, 13 | ], 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.preferences.importModuleSpecifierEnding": "js", 4 | "search.exclude": { 5 | "lib": true, 6 | "*.har": true, 7 | "**/snapshots/**": true, 8 | "**/recordings/**": true, 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "typescript", 6 | "tsconfig": "tsconfig.json", 7 | "option": "watch", 8 | "problemMatcher": ["$tsc-watch"], 9 | "group": "build", 10 | "label": "tsc: watch - tsconfig.json", 11 | "runOptions": { 12 | "runOn": "folderOpen", 13 | }, 14 | }, 15 | ], 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Felix Frederick Becker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DOM to SVG 2 | 3 | [![npm](https://img.shields.io/npm/v/dom-to-svg)](https://www.npmjs.com/package/dom-to-svg) 4 | [![CI status](https://github.com/felixfbecker/dom-to-svg/workflows/test/badge.svg?branch=main)](https://github.com/felixfbecker/dom-to-svg/actions) 5 | ![license: MIT](https://img.shields.io/npm/l/dom-to-svg) 6 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 7 | 8 | Library to convert a given HTML DOM node into an accessible SVG "screenshot". 9 | 10 | ## Demo 📸 11 | 12 | Try out the [SVG Screenshots Chrome extension](https://chrome.google.com/webstore/detail/svg-screenshot/nfakpcpmhhilkdpphcjgnokknpbpdllg) which uses this library to allow you to take SVG screenshots of any webpage. 13 | You can find the source code at [github.com/felixfbecker/svg-screenshots](https://github.com/felixfbecker/svg-screenshots). 14 | 15 | ## Usage 16 | 17 | ```js 18 | import { documentToSVG, elementToSVG, inlineResources, formatXML } from 'dom-to-svg' 19 | 20 | // Capture the whole document 21 | const svgDocument = documentToSVG(document) 22 | 23 | // Capture specific element 24 | const svgDocument = elementToSVG(document.querySelector('#my-element')) 25 | 26 | // Inline external resources (fonts, images, etc) as data: URIs 27 | await inlineResources(svgDocument.documentElement) 28 | 29 | // Get SVG string 30 | const svgString = new XMLSerializer().serializeToString(svgDocument) 31 | ``` 32 | 33 | The output can be used as-is as valid SVG or easily passed to other packages to pretty-print or compress. 34 | 35 | ## Features 36 | 37 | - Does NOT rely on `` - SVGs will work in design tools like Illustrator, Figma etc. 38 | - Maintains DOM accessibility tree by annotating SVG with correct ARIA attributes. 39 | - Maintains interactive links. 40 | - Maintains text to allow copying to clipboard. 41 | - Can inline external resources like images, fonts, etc to make SVG self-contained. 42 | - Maintains CSS stacking order of elements. 43 | - Outputs debug attributes on SVG to trace elements back to their DOM nodes. 44 | 45 | ## Caveats 46 | 47 | - Designed to run in the browser. Using JSDOM on the server will likely not work, but it can easily run inside Puppeteer. 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dom-to-svg", 3 | "version": "0.1.2", 4 | "description": "Take SVG screenshots of DOM elements", 5 | "main": "lib/index.js", 6 | "sideEffects": false, 7 | "type": "module", 8 | "files": [ 9 | "lib" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/felixfbecker/dom-to-svg" 14 | }, 15 | "browserslist": [ 16 | "last 2 Chrome versions", 17 | "last 2 Firefox versions" 18 | ], 19 | "keywords": [ 20 | "svg", 21 | "dom", 22 | "screenshot", 23 | "snapshot", 24 | "document", 25 | "element", 26 | "image" 27 | ], 28 | "scripts": { 29 | "build": "tsc -p .", 30 | "watch": "tsc -p . -w", 31 | "eslint": "eslint 'src/**/*.ts'", 32 | "prettier": "prettier --check '**/*.{yml,ts,json}'", 33 | "get-fixture": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' ts-node src/test/get-fixture.ts", 34 | "test": "mocha src/test/test.ts", 35 | "semantic-release": "semantic-release" 36 | }, 37 | "mocha": { 38 | "timeout": 150000, 39 | "exit": true, 40 | "enableSourceMaps": true, 41 | "watchFiles": [ 42 | "lib/**/*.js" 43 | ], 44 | "loader": "ts-node/esm" 45 | }, 46 | "commitlint": { 47 | "extends": [ 48 | "@commitlint/config-conventional" 49 | ] 50 | }, 51 | "release": { 52 | "branches": [ 53 | "main" 54 | ] 55 | }, 56 | "husky": { 57 | "hooks": { 58 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 59 | } 60 | }, 61 | "author": "Felix Becker", 62 | "license": "MIT", 63 | "devDependencies": { 64 | "@commitlint/cli": "^11.0.0", 65 | "@commitlint/config-conventional": "^11.0.0", 66 | "@pollyjs/adapter": "^5.0.0", 67 | "@pollyjs/core": "^5.0.0", 68 | "@pollyjs/persister-fs": "^5.0.0", 69 | "@sourcegraph/eslint-config": "^0.24.0", 70 | "@sourcegraph/prettierrc": "^3.0.3", 71 | "@types/chai": "^4.2.19", 72 | "@types/content-type": "^1.1.3", 73 | "@types/lodash-es": "^4.17.4", 74 | "@types/mime-types": "^2.1.0", 75 | "@types/mocha": "^8.2.2", 76 | "@types/node": "^14.17.4", 77 | "@types/parcel-bundler": "^1.12.3", 78 | "@types/pixelmatch": "^5.2.3", 79 | "@types/pngjs": "^6.0.0", 80 | "@types/pollyjs__core": "^4.3.2", 81 | "@types/pollyjs__persister-fs": "^2.0.1", 82 | "@types/prettier": "^2.2.3", 83 | "@types/puppeteer": "^5.4.3", 84 | "@types/type-is": "^1.6.3", 85 | "chai": "^4.3.4", 86 | "chardet": "^1.3.0", 87 | "content-type": "^1.0.4", 88 | "delay": "^4.4.0", 89 | "eslint": "^7.30.0", 90 | "husky": "^4.3.0", 91 | "lodash-es": "^4.17.21", 92 | "mime-types": "^2.1.30", 93 | "mocha": "^8.3.2", 94 | "parcel-bundler": "^1.12.5", 95 | "pixelmatch": "^5.2.1", 96 | "pngjs": "^6.0.0", 97 | "prettier": "^2.2.1", 98 | "puppeteer": "5.4.0", 99 | "rxjs": "^7.1.0", 100 | "semantic-release": "^17.2.4", 101 | "source-map-support": "^0.5.19", 102 | "tagged-template-noop": "^2.1.1", 103 | "ts-node": "^9.1.1", 104 | "typescript": "^4.3.5", 105 | "xml-formatter": "^2.4.0" 106 | }, 107 | "dependencies": { 108 | "gradient-parser": "^1.0.2", 109 | "postcss": "^8.3.5", 110 | "postcss-value-parser": "^4.1.0" 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = require('@sourcegraph/prettierrc') 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "semanticCommits": true, 4 | "rangeStrategy": "bump", 5 | "prCreation": "not-pending", 6 | "masterIssue": true, 7 | "prHourlyLimit": 0, 8 | "packageRules": [ 9 | { 10 | "packagePatterns": ["^@types/"], 11 | "automerge": true 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/accessibility.ts: -------------------------------------------------------------------------------- 1 | import { hasLabels, isHTMLElement } from './dom.js' 2 | import { TraversalContext } from './traversal.js' 3 | 4 | const isStandaloneFooter = (element: Element): boolean => 5 | !element.closest( 6 | 'article, aside, main, nav, section, [role="article"], [role="complementary"], [role="main"], [role="navigation"], [role="region"]' 7 | ) 8 | 9 | export function getAccessibilityAttributes( 10 | element: Element, 11 | { labels, getUniqueId }: Pick 12 | ): Map { 13 | // https://www.w3.org/TR/html-aria/ 14 | const attributes = new Map() 15 | switch (element.tagName) { 16 | case 'A': 17 | attributes.set('role', 'link') 18 | break 19 | case 'ARTICLE': 20 | attributes.set('role', 'article') 21 | break 22 | case 'ASIDE': 23 | attributes.set('role', 'complementary') 24 | break 25 | case 'BODY': 26 | attributes.set('role', 'document') 27 | break 28 | case 'BUTTON': 29 | case 'SUMMARY': 30 | attributes.set('role', 'button') 31 | break 32 | case 'DD': 33 | attributes.set('role', 'definition') 34 | break 35 | case 'DETAILS': 36 | attributes.set('role', 'group') 37 | break 38 | case 'DFN': 39 | attributes.set('role', 'term') 40 | break 41 | case 'DIALOG': 42 | attributes.set('role', 'dialog') 43 | break 44 | case 'DT': 45 | attributes.set('role', 'term') 46 | break 47 | case 'FIELDSET': 48 | attributes.set('role', 'group') 49 | break 50 | case 'FIGURE': 51 | attributes.set('role', 'figure') 52 | break 53 | case 'FOOTER': 54 | if (isStandaloneFooter(element)) { 55 | attributes.set('role', 'contentinfo') 56 | } 57 | break 58 | case 'FORM': 59 | attributes.set('role', 'form') 60 | break 61 | case 'H1': 62 | case 'H2': 63 | case 'H3': 64 | case 'H4': 65 | case 'H5': 66 | case 'H6': 67 | attributes.set('role', 'heading') 68 | attributes.set('aria-level', element.tagName.slice(1)) 69 | break 70 | case 'HEADER': 71 | if (isStandaloneFooter(element)) { 72 | attributes.set('role', 'banner') 73 | } 74 | break 75 | case 'HR': 76 | attributes.set('role', 'separator') 77 | break 78 | case 'IMG': { 79 | const alt = element.getAttribute('alt') 80 | if (alt === null || alt !== '') { 81 | attributes.set('role', 'img') 82 | if (alt) { 83 | attributes.set('aria-label', alt) 84 | } 85 | } 86 | break 87 | } 88 | case 'INPUT': 89 | switch ((element as HTMLInputElement).type) { 90 | case 'button': 91 | case 'image': 92 | case 'reset': 93 | case 'submit': 94 | attributes.set('role', 'button') 95 | break 96 | case 'number': 97 | attributes.set('role', 'spinbutton') 98 | break 99 | case 'range': 100 | attributes.set('role', 'slider') 101 | break 102 | case 'checkbox': 103 | attributes.set('role', 'checkbox') 104 | break 105 | case 'radio': 106 | attributes.set('role', 'radio') 107 | break 108 | case 'email': 109 | case 'tel': 110 | if (!element.hasAttribute('list')) { 111 | attributes.set('role', 'textbox') 112 | } 113 | break 114 | } 115 | break 116 | case 'LI': 117 | if ( 118 | element.parentElement?.tagName === 'OL' || 119 | element.parentElement?.tagName === 'UL' || 120 | element.parentElement?.tagName === 'MENU' 121 | ) { 122 | attributes.set('role', 'listitem') 123 | } 124 | break 125 | case 'LINK': 126 | if ((element as HTMLLinkElement).href) { 127 | attributes.set('role', 'link') 128 | } 129 | break 130 | case 'MAIN': 131 | attributes.set('role', 'main') 132 | break 133 | case 'MATH': 134 | attributes.set('role', 'math') 135 | break 136 | case 'OL': 137 | case 'UL': 138 | case 'MENU': 139 | attributes.set('role', 'list') 140 | break 141 | case 'NAV': 142 | attributes.set('role', 'navigation') 143 | break 144 | case 'OPTION': 145 | attributes.set('role', 'option') 146 | break 147 | case 'PROGRESS': 148 | attributes.set('role', 'progressbar') 149 | break 150 | case 'SECTION': 151 | attributes.set('role', 'region') 152 | break 153 | case 'SELECT': 154 | attributes.set( 155 | 'role', 156 | !element.hasAttribute('multiple') && (element as HTMLSelectElement).size <= 1 ? 'combobox' : 'listbox' 157 | ) 158 | break 159 | case 'TABLE': 160 | attributes.set('role', 'table') 161 | break 162 | case 'THEAD': 163 | case 'TBODY': 164 | case 'TFOOT': 165 | attributes.set('role', 'rowgroup') 166 | break 167 | case 'TEXTAREA': 168 | attributes.set('role', 'textbox') 169 | break 170 | case 'TD': 171 | attributes.set('role', 'cell') 172 | break 173 | case 'TH': 174 | attributes.set('role', element.closest('thead') ? 'columnheader' : 'rowheader') 175 | break 176 | case 'TR': 177 | attributes.set('role', 'tablerow') 178 | break 179 | } 180 | if (element.hasAttribute('disabled')) { 181 | attributes.set('aria-disabled', 'true') 182 | } 183 | if (element.hasAttribute('placeholder')) { 184 | attributes.set('aria-placeholder', element.getAttribute('placeholder') || '') 185 | } 186 | const tabIndex = element.getAttribute('tabindex') 187 | if (tabIndex) { 188 | attributes.set('tabindex', tabIndex) 189 | } 190 | if (isHTMLElement(element) && hasLabels(element) && element.labels) { 191 | // Need to invert the label[for] / [aria-labelledby] relationship 192 | attributes.set( 193 | 'aria-labelledby', 194 | [...element.labels] 195 | .map(label => { 196 | let labelId = label.id || labels.get(label) 197 | if (!labelId) { 198 | labelId = getUniqueId('label') 199 | labels.set(label, labelId) 200 | } 201 | return labelId 202 | }) 203 | .join(' ') 204 | ) 205 | } 206 | 207 | for (const attribute of element.attributes) { 208 | if (attribute.name.startsWith('aria-')) { 209 | attributes.set(attribute.name, attribute.value) 210 | } 211 | } 212 | const customRole = element.getAttribute('role') 213 | if (customRole) { 214 | attributes.set('role', customRole) 215 | } 216 | return attributes 217 | } 218 | -------------------------------------------------------------------------------- /src/css.ts: -------------------------------------------------------------------------------- 1 | export const isCSSFontFaceRule = (rule: CSSRule): rule is CSSFontFaceRule => rule.type === CSSRule.FONT_FACE_RULE 2 | 3 | export const isInline = (styles: CSSStyleDeclaration): boolean => 4 | styles.displayOutside === 'inline' || styles.display.startsWith('inline-') 5 | 6 | export const isPositioned = (styles: CSSStyleDeclaration): boolean => styles.position !== 'static' 7 | 8 | export const isInFlow = (styles: CSSStyleDeclaration): boolean => 9 | styles.float !== 'none' && styles.position !== 'absolute' && styles.position !== 'fixed' 10 | 11 | export const isTransparent = (color: string): boolean => color === 'transparent' || color === 'rgba(0, 0, 0, 0)' 12 | 13 | export const hasUniformBorder = (styles: CSSStyleDeclaration): boolean => 14 | parseFloat(styles.borderTopWidth) !== 0 && 15 | styles.borderTopStyle !== 'none' && 16 | styles.borderTopStyle !== 'inset' && 17 | styles.borderTopStyle !== 'outset' && 18 | !isTransparent(styles.borderTopColor) && 19 | // Cannot use border property directly as in Firefox those are empty strings. 20 | // Need to get the specific border properties from the specific sides. 21 | // https://stackoverflow.com/questions/41696063/getcomputedstyle-returns-empty-strings-on-ff-when-instead-crome-returns-a-comp 22 | styles.borderTopWidth === styles.borderLeftWidth && 23 | styles.borderTopWidth === styles.borderRightWidth && 24 | styles.borderTopWidth === styles.borderBottomWidth && 25 | styles.borderTopColor === styles.borderLeftColor && 26 | styles.borderTopColor === styles.borderRightColor && 27 | styles.borderTopColor === styles.borderBottomColor && 28 | styles.borderTopStyle === styles.borderLeftStyle && 29 | styles.borderTopStyle === styles.borderRightStyle && 30 | styles.borderTopStyle === styles.borderBottomStyle 31 | 32 | /** A side of a box. */ 33 | export type Side = 'top' | 'bottom' | 'right' | 'left' 34 | 35 | /** The 4 sides of a box. */ 36 | const SIDES: Side[] = ['top', 'bottom', 'right', 'left'] 37 | 38 | /** Whether the given side is a horizontal side. */ 39 | export const isHorizontal = (side: Side): boolean => side === 'bottom' || side === 'top' 40 | 41 | /** 42 | * The two corners for each side, in order of lower coordinate to higher coordinate. 43 | */ 44 | const CORNERS: Record = { 45 | top: ['left', 'right'], 46 | bottom: ['left', 'right'], 47 | left: ['top', 'bottom'], 48 | right: ['top', 'bottom'], 49 | } 50 | 51 | /** 52 | * Returns the (elliptic) border radii for a given side. 53 | * For example, for the top side it will return the horizontal top-left and the horizontal top-right border radii. 54 | */ 55 | export function getBorderRadiiForSide( 56 | side: Side, 57 | styles: CSSStyleDeclaration, 58 | bounds: DOMRectReadOnly 59 | ): [number, number] { 60 | const [horizontalStyle1, verticalStyle1] = styles 61 | .getPropertyValue( 62 | isHorizontal(side) 63 | ? `border-${side}-${CORNERS[side][0]}-radius` 64 | : `border-${CORNERS[side][0]}-${side}-radius` 65 | ) 66 | .split(' ') 67 | 68 | const [horizontalStyle2, verticalStyle2] = styles 69 | .getPropertyValue( 70 | isHorizontal(side) 71 | ? `border-${side}-${CORNERS[side][1]}-radius` 72 | : `border-${CORNERS[side][1]}-${side}-radius` 73 | ) 74 | .split(' ') 75 | 76 | if (isHorizontal(side)) { 77 | return [ 78 | parseCSSLength(horizontalStyle1 || '0px', bounds.width) ?? 0, 79 | parseCSSLength(horizontalStyle2 || '0px', bounds.width) ?? 0, 80 | ] 81 | } 82 | return [ 83 | parseCSSLength(verticalStyle1 || horizontalStyle1 || '0px', bounds.height) ?? 0, 84 | parseCSSLength(verticalStyle2 || horizontalStyle2 || '0px', bounds.height) ?? 0, 85 | ] 86 | } 87 | 88 | /** 89 | * Returns the factor by which all border radii have to be scaled to fit correctly. 90 | * 91 | * @see https://drafts.csswg.org/css-backgrounds-3/#corner-overlap 92 | */ 93 | export const calculateOverlappingCurvesFactor = (styles: CSSStyleDeclaration, bounds: DOMRectReadOnly): number => 94 | Math.min( 95 | ...SIDES.map(side => { 96 | const length = isHorizontal(side) ? bounds.width : bounds.height 97 | const radiiSum = getBorderRadiiForSide(side, styles, bounds).reduce((sum, radius) => sum + radius, 0) 98 | return length / radiiSum 99 | }), 100 | 1 101 | ) 102 | 103 | export const isVisible = (styles: CSSStyleDeclaration): boolean => 104 | styles.displayOutside !== 'none' && 105 | styles.display !== 'none' && 106 | styles.visibility !== 'hidden' && 107 | styles.opacity !== '0' 108 | 109 | export function parseCSSLength(length: string, containerLength: number): number | undefined { 110 | if (length.endsWith('px')) { 111 | return parseFloat(length) 112 | } 113 | if (length.endsWith('%')) { 114 | return (parseFloat(length) / 100) * containerLength 115 | } 116 | return undefined 117 | } 118 | 119 | export const unescapeStringValue = (value: string): string => 120 | value 121 | // Replace hex escape sequences 122 | .replace(/\\([\da-f]{1,2})/gi, (substring, codePoint) => String.fromCodePoint(parseInt(codePoint, 16))) 123 | // Replace all other escapes (quotes, backslash, etc) 124 | .replace(/\\(.)/g, '$1') 125 | 126 | export function copyCssStyles(from: CSSStyleDeclaration, to: CSSStyleDeclaration): void { 127 | for (const property of from) { 128 | to.setProperty(property, from.getPropertyValue(property), from.getPropertyPriority(property)) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/dom.ts: -------------------------------------------------------------------------------- 1 | // Namespaces 2 | export const svgNamespace = 'http://www.w3.org/2000/svg' 3 | export const xlinkNamespace = 'http://www.w3.org/1999/xlink' 4 | export const xhtmlNamespace = 'http://www.w3.org/1999/xhtml' 5 | 6 | // DOM 7 | export const isElement = (node: Node): node is Element => node.nodeType === Node.ELEMENT_NODE 8 | export const isTextNode = (node: Node): node is Text => node.nodeType === Node.TEXT_NODE 9 | export const isCommentNode = (node: Node): node is Comment => node.nodeType === Node.COMMENT_NODE 10 | 11 | // SVG 12 | export const isSVGElement = (element: Element): element is SVGElement => element.namespaceURI === svgNamespace 13 | export const isSVGSVGElement = (element: Element): element is SVGSVGElement => 14 | isSVGElement(element) && element.tagName === 'svg' 15 | export const isSVGGraphicsElement = (element: Element): element is SVGGraphicsElement => 16 | isSVGElement(element) && 'getCTM' in element && 'getScreenCTM' in element 17 | export const isSVGGroupElement = (element: Element): element is SVGGElement => 18 | isSVGElement(element) && element.tagName === 'g' 19 | export const isSVGAnchorElement = (element: Element): element is SVGAElement => 20 | isSVGElement(element) && element.tagName === 'a' 21 | export const isSVGTextContentElement = (element: Element): element is SVGTextContentElement => 22 | isSVGElement(element) && 'textLength' in element 23 | export const isSVGImageElement = (element: Element): element is SVGImageElement => 24 | element.tagName === 'image' && isSVGElement(element) 25 | export const isSVGStyleElement = (element: Element): element is SVGStyleElement => 26 | element.tagName === 'style' && isSVGElement(element) 27 | 28 | // HTML 29 | export const isHTMLElement = (element: Element): element is HTMLElement => element.namespaceURI === xhtmlNamespace 30 | export const isHTMLAnchorElement = (element: Element): element is HTMLAnchorElement => 31 | element.tagName === 'A' && isHTMLElement(element) 32 | export const isHTMLLabelElement = (element: Element): element is HTMLLabelElement => 33 | element.tagName === 'LABEL' && isHTMLElement(element) 34 | export const isHTMLImageElement = (element: Element): element is HTMLImageElement => 35 | element.tagName === 'IMG' && isHTMLElement(element) 36 | export const isHTMLInputElement = (element: Element): element is HTMLInputElement => 37 | element.tagName === 'INPUT' && isHTMLElement(element) 38 | export const hasLabels = (element: HTMLElement): element is HTMLElement & Pick => 39 | 'labels' in element 40 | 41 | export function* traverseDOM(node: Node, shouldEnter: (node: Node) => boolean = () => true): Iterable { 42 | yield node 43 | if (shouldEnter(node)) { 44 | for (const childNode of node.childNodes) { 45 | yield* traverseDOM(childNode) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/element.ts: -------------------------------------------------------------------------------- 1 | import cssValueParser from 'postcss-value-parser' 2 | 3 | import { getAccessibilityAttributes } from './accessibility.js' 4 | import { 5 | copyCssStyles, 6 | isVisible, 7 | isTransparent, 8 | hasUniformBorder, 9 | parseCSSLength, 10 | unescapeStringValue, 11 | Side, 12 | getBorderRadiiForSide, 13 | calculateOverlappingCurvesFactor, 14 | } from './css.js' 15 | import { 16 | svgNamespace, 17 | isHTMLAnchorElement, 18 | isHTMLImageElement, 19 | isHTMLInputElement, 20 | isHTMLElement, 21 | isSVGSVGElement, 22 | } from './dom.js' 23 | import { convertLinearGradient } from './gradients.js' 24 | import { 25 | createStackingLayers, 26 | establishesStackingContext, 27 | determineStackingLayer, 28 | StackingLayers, 29 | sortStackingLayerChildren, 30 | cleanupStackingLayerChildren, 31 | } from './stacking.js' 32 | import { handleSvgNode } from './svg.js' 33 | import { copyTextStyles } from './text.js' 34 | import { TraversalContext, walkNode } from './traversal.js' 35 | import { doRectanglesIntersect, isTaggedUnionMember } from './util.js' 36 | 37 | export function handleElement(element: Element, context: Readonly): void { 38 | const cleanupFunctions: (() => void)[] = [] 39 | 40 | try { 41 | const window = element.ownerDocument.defaultView 42 | if (!window) { 43 | throw new Error("Element's ownerDocument has no defaultView") 44 | } 45 | 46 | const bounds = element.getBoundingClientRect() // Includes borders 47 | const rectanglesIntersect = doRectanglesIntersect(bounds, context.options.captureArea) 48 | 49 | const styles = window.getComputedStyle(element) 50 | const parentStyles = element.parentElement && window.getComputedStyle(element.parentElement) 51 | 52 | const svgContainer = 53 | isHTMLAnchorElement(element) && context.options.keepLinks 54 | ? createSvgAnchor(element, context) 55 | : context.svgDocument.createElementNS(svgNamespace, 'g') 56 | 57 | // Add IDs, classes, debug info 58 | svgContainer.dataset.tag = element.tagName.toLowerCase() 59 | const id = element.id || context.getUniqueId(element.classList[0] || element.tagName.toLowerCase()) 60 | svgContainer.id = id 61 | const className = element.getAttribute('class') 62 | if (className) { 63 | svgContainer.setAttribute('class', className) 64 | } 65 | 66 | // Title 67 | if (isHTMLElement(element) && element.title) { 68 | const svgTitle = context.svgDocument.createElementNS(svgNamespace, 'title') 69 | svgTitle.textContent = element.title 70 | svgContainer.prepend(svgTitle) 71 | } 72 | 73 | // Which parent should the container itself be appended to? 74 | const stackingLayerName = determineStackingLayer(styles, parentStyles) 75 | const stackingLayer = stackingLayerName 76 | ? context.stackingLayers[stackingLayerName] 77 | : context.parentStackingLayer 78 | if (stackingLayer) { 79 | context.currentSvgParent.setAttribute( 80 | 'aria-owns', 81 | [context.currentSvgParent.getAttribute('aria-owns'), svgContainer.id].filter(Boolean).join(' ') 82 | ) 83 | } 84 | // If the parent is within the same stacking layer, append to the parent. 85 | // Otherwise append to the right stacking layer. 86 | const elementToAppendTo = 87 | context.parentStackingLayer === stackingLayer ? context.currentSvgParent : stackingLayer 88 | svgContainer.dataset.zIndex = styles.zIndex // Used for sorting 89 | elementToAppendTo.append(svgContainer) 90 | 91 | // If the element establishes a stacking context, create subgroups for each stacking layer. 92 | let childContext: TraversalContext 93 | let backgroundContainer: SVGElement 94 | let ownStackingLayers: StackingLayers | undefined 95 | if (establishesStackingContext(styles, parentStyles)) { 96 | ownStackingLayers = createStackingLayers(svgContainer) 97 | backgroundContainer = ownStackingLayers.rootBackgroundAndBorders 98 | childContext = { 99 | ...context, 100 | currentSvgParent: svgContainer, 101 | stackingLayers: ownStackingLayers, 102 | parentStackingLayer: stackingLayer, 103 | } 104 | } else { 105 | backgroundContainer = svgContainer 106 | childContext = { 107 | ...context, 108 | currentSvgParent: svgContainer, 109 | parentStackingLayer: stackingLayer, 110 | } 111 | } 112 | 113 | // Opacity 114 | if (styles.opacity !== '1') { 115 | svgContainer.setAttribute('opacity', styles.opacity) 116 | } 117 | 118 | // Accessibility 119 | for (const [name, value] of getAccessibilityAttributes(element, context)) { 120 | svgContainer.setAttribute(name, value) 121 | } 122 | 123 | // Handle ::before and ::after by creating temporary child elements in the DOM. 124 | // Avoid infinite loop, in case `element` already is already a synthetic element created by us for a pseudo element. 125 | if (isHTMLElement(element) && !element.dataset.pseudoElement) { 126 | const handlePseudoElement = ( 127 | pseudoSelector: '::before' | '::after', 128 | position: 'prepend' | 'append' 129 | ): void => { 130 | const pseudoElementStyles = window.getComputedStyle(element, pseudoSelector) 131 | const content = cssValueParser(pseudoElementStyles.content).nodes.find( 132 | isTaggedUnionMember('type', 'string' as const) 133 | ) 134 | if (!content) { 135 | return 136 | } 137 | // Pseudo elements are inline by default (like a span) 138 | const span = element.ownerDocument.createElement('span') 139 | span.dataset.pseudoElement = pseudoSelector 140 | copyCssStyles(pseudoElementStyles, span.style) 141 | span.textContent = unescapeStringValue(content.value) 142 | element.dataset.pseudoElementOwner = id 143 | cleanupFunctions.push(() => element.removeAttribute('data-pseudo-element-owner')) 144 | const style = element.ownerDocument.createElement('style') 145 | // Hide the *actual* pseudo element temporarily while we have a real DOM equivalent in the DOM 146 | style.textContent = `[data-pseudo-element-owner="${id}"]${pseudoSelector} { display: none !important; }` 147 | element.before(style) 148 | cleanupFunctions.push(() => style.remove()) 149 | element[position](span) 150 | cleanupFunctions.push(() => span.remove()) 151 | } 152 | handlePseudoElement('::before', 'prepend') 153 | handlePseudoElement('::after', 'append') 154 | // TODO handle ::marker etc 155 | } 156 | 157 | if (rectanglesIntersect) { 158 | addBackgroundAndBorders(styles, bounds, backgroundContainer, window, context) 159 | } 160 | 161 | // If element is overflow: hidden, create a masking rectangle to hide any overflowing content of any descendants. 162 | // Use instead of as Figma supports , but not . 163 | if (styles.overflow !== 'visible') { 164 | const mask = context.svgDocument.createElementNS(svgNamespace, 'mask') 165 | mask.id = context.getUniqueId('mask-for-' + id) 166 | const visibleRectangle = createBox(bounds, context) 167 | visibleRectangle.setAttribute('fill', '#ffffff') 168 | mask.append(visibleRectangle) 169 | svgContainer.append(mask) 170 | svgContainer.setAttribute('mask', `url(#${mask.id})`) 171 | childContext = { 172 | ...childContext, 173 | ancestorMasks: [{ mask, forElement: element }, ...childContext.ancestorMasks], 174 | } 175 | } 176 | 177 | if ( 178 | isHTMLElement(element) && 179 | (styles.position === 'absolute' || styles.position === 'fixed') && 180 | context.ancestorMasks.length > 0 && 181 | element.offsetParent 182 | ) { 183 | // Absolute and fixed elements are out of the flow and will bleed out of an `overflow: hidden` ancestor 184 | // as long as their offsetParent is higher up than the mask element. 185 | for (const { mask, forElement } of context.ancestorMasks) { 186 | if (element.offsetParent.contains(forElement) || element.offsetParent === forElement) { 187 | // Add a cutout to the ancestor mask 188 | const visibleRectangle = createBox(bounds, context) 189 | visibleRectangle.setAttribute('fill', '#ffffff') 190 | mask.append(visibleRectangle) 191 | } else { 192 | break 193 | } 194 | } 195 | } 196 | 197 | if ( 198 | rectanglesIntersect && 199 | isHTMLImageElement(element) && 200 | // Make sure the element has a src/srcset attribute (the relative URL). `element.src` is absolute and always defined. 201 | (element.getAttribute('src') || element.getAttribute('srcset')) 202 | ) { 203 | const svgImage = context.svgDocument.createElementNS(svgNamespace, 'image') 204 | svgImage.id = `${id}-image` // read by inlineResources() 205 | svgImage.setAttribute('xlink:href', element.currentSrc || element.src) 206 | const paddingLeft = parseCSSLength(styles.paddingLeft, bounds.width) ?? 0 207 | const paddingRight = parseCSSLength(styles.paddingRight, bounds.width) ?? 0 208 | const paddingTop = parseCSSLength(styles.paddingTop, bounds.height) ?? 0 209 | const paddingBottom = parseCSSLength(styles.paddingBottom, bounds.height) ?? 0 210 | svgImage.setAttribute('x', (bounds.x + paddingLeft).toString()) 211 | svgImage.setAttribute('y', (bounds.y + paddingTop).toString()) 212 | svgImage.setAttribute('width', (bounds.width - paddingLeft - paddingRight).toString()) 213 | svgImage.setAttribute('height', (bounds.height - paddingTop - paddingBottom).toString()) 214 | if (element.alt) { 215 | svgImage.setAttribute('aria-label', element.alt) 216 | } 217 | svgContainer.append(svgImage) 218 | } else if (rectanglesIntersect && isHTMLInputElement(element) && bounds.width > 0 && bounds.height > 0) { 219 | // Handle button labels or input field content 220 | if (element.value) { 221 | const svgTextElement = context.svgDocument.createElementNS(svgNamespace, 'text') 222 | copyTextStyles(styles, svgTextElement) 223 | svgTextElement.setAttribute('dominant-baseline', 'central') 224 | svgTextElement.setAttribute('xml:space', 'preserve') 225 | svgTextElement.setAttribute( 226 | 'x', 227 | (bounds.x + (parseCSSLength(styles.paddingLeft, bounds.width) ?? 0)).toString() 228 | ) 229 | const top = bounds.top + (parseCSSLength(styles.paddingTop, bounds.height) ?? 0) 230 | const bottom = bounds.bottom + (parseCSSLength(styles.paddingBottom, bounds.height) ?? 0) 231 | const middle = (top + bottom) / 2 232 | svgTextElement.setAttribute('y', middle.toString()) 233 | svgTextElement.textContent = element.value 234 | childContext.stackingLayers.inFlowInlineLevelNonPositionedDescendants.append(svgTextElement) 235 | } 236 | } else if (rectanglesIntersect && isSVGSVGElement(element) && isVisible(styles)) { 237 | handleSvgNode(element, { ...childContext, idPrefix: `${id}-` }) 238 | } else { 239 | // Walk children even if rectangles don't intersect, 240 | // because children can overflow the parent's bounds as long as overflow: visible (default). 241 | for (const child of element.childNodes) { 242 | walkNode(child, childContext) 243 | } 244 | if (ownStackingLayers) { 245 | sortStackingLayerChildren(ownStackingLayers) 246 | cleanupStackingLayerChildren(ownStackingLayers) 247 | } 248 | } 249 | } finally { 250 | for (const cleanup of cleanupFunctions) { 251 | cleanup() 252 | } 253 | } 254 | } 255 | 256 | function addBackgroundAndBorders( 257 | styles: CSSStyleDeclaration, 258 | bounds: DOMRect, 259 | backgroundAndBordersContainer: SVGElement, 260 | window: Window, 261 | context: Pick 262 | ): void { 263 | if (isVisible(styles)) { 264 | if ( 265 | bounds.width > 0 && 266 | bounds.height > 0 && 267 | (!isTransparent(styles.backgroundColor) || hasUniformBorder(styles) || styles.backgroundImage !== 'none') 268 | ) { 269 | const box = createBackgroundAndBorderBox(bounds, styles, context) 270 | backgroundAndBordersContainer.append(box) 271 | if (styles.backgroundImage !== 'none') { 272 | const backgrounds = cssValueParser(styles.backgroundImage) 273 | .nodes.filter(isTaggedUnionMember('type', 'function' as const)) 274 | .reverse() 275 | const xBackgroundPositions = styles.backgroundPositionX.split(/\s*,\s*/g) 276 | const yBackgroundPositions = styles.backgroundPositionY.split(/\s*,\s*/g) 277 | const backgroundRepeats = styles.backgroundRepeat.split(/\s*,\s*/g) 278 | for (const [index, backgroundNode] of backgrounds.entries()) { 279 | const backgroundPositionX = parseCSSLength(xBackgroundPositions[index]!, bounds.width) ?? 0 280 | const backgroundPositionY = parseCSSLength(yBackgroundPositions[index]!, bounds.height) ?? 0 281 | const backgroundRepeat = backgroundRepeats[index] 282 | if (backgroundNode.value === 'url' && backgroundNode.nodes[0]) { 283 | const urlArgument = backgroundNode.nodes[0] 284 | const image = context.svgDocument.createElementNS(svgNamespace, 'image') 285 | image.id = context.getUniqueId('background-image') // read by inlineResources() 286 | const [cssWidth = 'auto', cssHeight = 'auto'] = styles.backgroundSize.split(' ') 287 | const backgroundWidth = parseCSSLength(cssWidth, bounds.width) ?? bounds.width 288 | const backgroundHeight = parseCSSLength(cssHeight, bounds.height) ?? bounds.height 289 | image.setAttribute('width', backgroundWidth.toString()) 290 | image.setAttribute('height', backgroundHeight.toString()) 291 | if (cssWidth !== 'auto' && cssHeight !== 'auto') { 292 | image.setAttribute('preserveAspectRatio', 'none') 293 | } else if (styles.backgroundSize === 'contain') { 294 | image.setAttribute('preserveAspectRatio', 'xMidYMid meet') 295 | } else if (styles.backgroundSize === 'cover') { 296 | image.setAttribute('preserveAspectRatio', 'xMidYMid slice') 297 | } 298 | // Technically not correct, because relative URLs should be resolved relative to the stylesheet, 299 | // not the page. But we have no means to know what stylesheet the style came from 300 | // (unless we iterate through all rules in all style sheets and find the matching one). 301 | const url = new URL(unescapeStringValue(urlArgument.value), window.location.href) 302 | image.setAttribute('xlink:href', url.href) 303 | 304 | if ( 305 | backgroundRepeat === 'no-repeat' || 306 | (backgroundPositionX === 0 && 307 | backgroundPositionY === 0 && 308 | backgroundWidth === bounds.width && 309 | backgroundHeight === bounds.height) 310 | ) { 311 | image.setAttribute('x', bounds.x.toString()) 312 | image.setAttribute('y', bounds.y.toString()) 313 | backgroundAndBordersContainer.append(image) 314 | } else { 315 | image.setAttribute('x', '0') 316 | image.setAttribute('y', '0') 317 | const pattern = context.svgDocument.createElementNS(svgNamespace, 'pattern') 318 | pattern.setAttribute('patternUnits', 'userSpaceOnUse') 319 | pattern.setAttribute('patternContentUnits', 'userSpaceOnUse') 320 | pattern.setAttribute('x', (bounds.x + backgroundPositionX).toString()) 321 | pattern.setAttribute('y', (bounds.y + backgroundPositionY).toString()) 322 | pattern.setAttribute( 323 | 'width', 324 | (backgroundRepeat === 'repeat' || backgroundRepeat === 'repeat-x' 325 | ? backgroundWidth 326 | : // If background shouldn't repeat on this axis, make the tile as big as the element so the repetition is cut off. 327 | backgroundWidth + bounds.x + backgroundPositionX 328 | ).toString() 329 | ) 330 | pattern.setAttribute( 331 | 'height', 332 | (backgroundRepeat === 'repeat' || backgroundRepeat === 'repeat-y' 333 | ? backgroundHeight 334 | : // If background shouldn't repeat on this axis, make the tile as big as the element so the repetition is cut off. 335 | backgroundHeight + bounds.y + backgroundPositionY 336 | ).toString() 337 | ) 338 | pattern.id = context.getUniqueId('pattern') 339 | pattern.append(image) 340 | box.before(pattern) 341 | box.setAttribute('fill', `url(#${pattern.id})`) 342 | } 343 | } else if (/^(-webkit-)?linear-gradient$/.test(backgroundNode.value)) { 344 | const linearGradientCss = cssValueParser.stringify(backgroundNode) 345 | const svgLinearGradient = convertLinearGradient(linearGradientCss, context) 346 | if (backgroundPositionX !== 0 || backgroundPositionY !== 0) { 347 | svgLinearGradient.setAttribute( 348 | 'gradientTransform', 349 | `translate(${backgroundPositionX}, ${backgroundPositionY})` 350 | ) 351 | } 352 | svgLinearGradient.id = context.getUniqueId('linear-gradient') 353 | box.before(svgLinearGradient) 354 | box.setAttribute('fill', `url(#${svgLinearGradient.id})`) 355 | } 356 | } 357 | } 358 | } 359 | 360 | if (!hasUniformBorder(styles)) { 361 | // Draw lines for each border 362 | for (const borderLine of createBorders(styles, bounds, context)) { 363 | backgroundAndBordersContainer.append(borderLine) 364 | } 365 | } 366 | } 367 | } 368 | 369 | function createBox(bounds: DOMRectReadOnly, context: Pick): SVGRectElement { 370 | const box = context.svgDocument.createElementNS(svgNamespace, 'rect') 371 | 372 | // TODO consider rotation 373 | box.setAttribute('width', bounds.width.toString()) 374 | box.setAttribute('height', bounds.height.toString()) 375 | box.setAttribute('x', bounds.x.toString()) 376 | box.setAttribute('y', bounds.y.toString()) 377 | 378 | return box 379 | } 380 | 381 | function createBackgroundAndBorderBox( 382 | bounds: DOMRectReadOnly, 383 | styles: CSSStyleDeclaration, 384 | context: Pick 385 | ): SVGRectElement { 386 | const background = createBox(bounds, context) 387 | 388 | // TODO handle background image and other properties 389 | if (styles.backgroundColor) { 390 | background.setAttribute('fill', styles.backgroundColor) 391 | } 392 | 393 | if (hasUniformBorder(styles)) { 394 | // Uniform border, use stroke 395 | // Cannot use borderColor/borderWidth directly as in Firefox those are empty strings. 396 | // Need to get the border property from some specific side (they are all the same in this condition). 397 | // https://stackoverflow.com/questions/41696063/getcomputedstyle-returns-empty-strings-on-ff-when-instead-crome-returns-a-comp 398 | background.setAttribute('stroke', styles.borderTopColor) 399 | background.setAttribute('stroke-width', styles.borderTopWidth) 400 | if (styles.borderTopStyle === 'dashed') { 401 | // > Displays a series of short square-ended dashes or line segments. 402 | // > The exact size and length of the segments are not defined by the specification and are implementation-specific. 403 | background.setAttribute('stroke-dasharray', '1') 404 | } 405 | } 406 | 407 | // Set border radius 408 | // Approximation, always assumes uniform border-radius by using the top-left horizontal radius and the top-left vertical radius for all corners. 409 | // TODO support irregular border radii on all corners by drawing border as a . 410 | const overlappingCurvesFactor = calculateOverlappingCurvesFactor(styles, bounds) 411 | const radiusX = getBorderRadiiForSide('top', styles, bounds)[0] * overlappingCurvesFactor 412 | const radiusY = getBorderRadiiForSide('left', styles, bounds)[0] * overlappingCurvesFactor 413 | if (radiusX !== 0) { 414 | background.setAttribute('rx', radiusX.toString()) 415 | } 416 | if (radiusY !== 0) { 417 | background.setAttribute('ry', radiusY.toString()) 418 | } 419 | 420 | return background 421 | } 422 | 423 | function* createBorders( 424 | styles: CSSStyleDeclaration, 425 | bounds: DOMRectReadOnly, 426 | context: Pick 427 | ): Iterable { 428 | for (const side of ['top', 'bottom', 'right', 'left'] as const) { 429 | if (hasBorder(styles, side)) { 430 | yield createBorder(styles, bounds, side, context) 431 | } 432 | } 433 | } 434 | 435 | function hasBorder(styles: CSSStyleDeclaration, side: Side): boolean { 436 | return ( 437 | !!styles.getPropertyValue(`border-${side}-color`) && 438 | !isTransparent(styles.getPropertyValue(`border-${side}-color`)) && 439 | styles.getPropertyValue(`border-${side}-width`) !== '0px' 440 | ) 441 | } 442 | 443 | function createBorder( 444 | styles: CSSStyleDeclaration, 445 | bounds: DOMRectReadOnly, 446 | side: Side, 447 | context: Pick 448 | ): SVGLineElement { 449 | // TODO handle border-radius for non-uniform borders 450 | const border = context.svgDocument.createElementNS(svgNamespace, 'line') 451 | border.setAttribute('stroke-linecap', 'square') 452 | const color = styles.getPropertyValue(`border-${side}-color`) 453 | border.setAttribute('stroke', color) 454 | border.setAttribute('stroke-width', styles.getPropertyValue(`border-${side}-width`)) 455 | 456 | // Handle inset/outset borders 457 | const borderStyle = styles.getPropertyValue(`border-${side}-style`) 458 | if ( 459 | (borderStyle === 'inset' && (side === 'top' || side === 'left')) || 460 | (borderStyle === 'outset' && (side === 'right' || side === 'bottom')) 461 | ) { 462 | const match = color.match(/rgba?\((\d+), (\d+), (\d+)(?:, ([\d.]+))?\)/) 463 | if (!match) { 464 | throw new Error(`Unexpected color: ${color}`) 465 | } 466 | const components = match.slice(1, 4).map(value => parseInt(value, 10) * 0.3) 467 | if (match[4]) { 468 | components.push(parseFloat(match[4])) 469 | } 470 | // Low-light border 471 | // https://stackoverflow.com/questions/4147940/how-do-browsers-determine-which-exact-colors-to-use-for-border-inset-or-outset 472 | border.setAttribute('stroke', `rgba(${components.join(', ')})`) 473 | } 474 | 475 | if (side === 'top') { 476 | border.setAttribute('x1', bounds.left.toString()) 477 | border.setAttribute('x2', bounds.right.toString()) 478 | border.setAttribute('y1', bounds.top.toString()) 479 | border.setAttribute('y2', bounds.top.toString()) 480 | } else if (side === 'left') { 481 | border.setAttribute('x1', bounds.left.toString()) 482 | border.setAttribute('x2', bounds.left.toString()) 483 | border.setAttribute('y1', bounds.top.toString()) 484 | border.setAttribute('y2', bounds.bottom.toString()) 485 | } else if (side === 'right') { 486 | border.setAttribute('x1', bounds.right.toString()) 487 | border.setAttribute('x2', bounds.right.toString()) 488 | border.setAttribute('y1', bounds.top.toString()) 489 | border.setAttribute('y2', bounds.bottom.toString()) 490 | } else if (side === 'bottom') { 491 | border.setAttribute('x1', bounds.left.toString()) 492 | border.setAttribute('x2', bounds.right.toString()) 493 | border.setAttribute('y1', bounds.bottom.toString()) 494 | border.setAttribute('y2', bounds.bottom.toString()) 495 | } 496 | return border 497 | } 498 | 499 | function createSvgAnchor(element: HTMLAnchorElement, context: Pick): SVGAElement { 500 | const svgAnchor = context.svgDocument.createElementNS(svgNamespace, 'a') 501 | if (element.href && !element.href.startsWith('javascript:')) { 502 | svgAnchor.setAttribute('href', element.href) 503 | } 504 | if (element.rel) { 505 | svgAnchor.setAttribute('rel', element.rel) 506 | } 507 | if (element.target) { 508 | svgAnchor.setAttribute('target', element.target) 509 | } 510 | if (element.download) { 511 | svgAnchor.setAttribute('download', element.download) 512 | } 513 | return svgAnchor 514 | } 515 | -------------------------------------------------------------------------------- /src/gradients.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable id-length */ 2 | import * as gradientParser from 'gradient-parser' 3 | 4 | import { svgNamespace } from './dom.js' 5 | import { TraversalContext } from './traversal.js' 6 | 7 | const positionsForOrientation = ( 8 | orientation: gradientParser.Gradient['orientation'] 9 | ): Record<'x1' | 'x2' | 'y1' | 'y2', string> => { 10 | const positions = { 11 | x1: '0%', 12 | x2: '0%', 13 | y1: '0%', 14 | y2: '0%', 15 | } 16 | 17 | if (orientation?.type === 'angular') { 18 | const anglePI = orientation.value * (Math.PI / 180) 19 | positions.x1 = `${Math.round(50 + Math.sin(anglePI + Math.PI) * 50)}%` 20 | positions.y1 = `${Math.round(50 + Math.cos(anglePI) * 50)}%` 21 | positions.x2 = `${Math.round(50 + Math.sin(anglePI) * 50)}%` 22 | positions.y2 = `${Math.round(50 + Math.cos(anglePI + Math.PI) * 50)}%` 23 | } else if (orientation?.type === 'directional') { 24 | switch (orientation.value) { 25 | case 'left': 26 | positions.x1 = '100%' 27 | break 28 | 29 | case 'top': 30 | positions.y1 = '100%' 31 | break 32 | 33 | case 'right': 34 | positions.x2 = '100%' 35 | break 36 | 37 | case 'bottom': 38 | positions.y2 = '100%' 39 | break 40 | } 41 | } 42 | 43 | return positions 44 | } 45 | 46 | export function convertLinearGradient( 47 | css: string, 48 | { svgDocument }: Pick 49 | ): SVGLinearGradientElement { 50 | const { orientation, colorStops } = gradientParser.parse(css)[0]! 51 | const { x1, x2, y1, y2 } = positionsForOrientation(orientation) 52 | 53 | const getColorStops = (colorStop: gradientParser.ColorStop, index: number): SVGStopElement => { 54 | const offset = `${(index / (colorStops.length - 1)) * 100}%` 55 | let stopColor = 'rgb(0,0,0)' 56 | let stopOpacity = 1 57 | 58 | switch (colorStop.type) { 59 | case 'rgb': { 60 | const [red, green, blue] = colorStop.value 61 | stopColor = `rgb(${red},${green},${blue})` 62 | break 63 | } 64 | 65 | case 'rgba': { 66 | const [red, green, blue, alpha] = colorStop.value 67 | stopColor = `rgb(${red},${green},${blue})` 68 | stopOpacity = alpha 69 | break 70 | } 71 | 72 | case 'hex': { 73 | stopColor = `#${colorStop.value}` 74 | break 75 | } 76 | 77 | case 'literal': { 78 | stopColor = colorStop.value 79 | break 80 | } 81 | } 82 | 83 | const stop = svgDocument.createElementNS(svgNamespace, 'stop') 84 | stop.setAttribute('offset', offset) 85 | stop.setAttribute('stop-color', stopColor) 86 | stop.setAttribute('stop-opacity', stopOpacity.toString()) 87 | return stop 88 | } 89 | 90 | const linearGradient = svgDocument.createElementNS(svgNamespace, 'linearGradient') 91 | linearGradient.setAttribute('x1', x1) 92 | linearGradient.setAttribute('y1', y1) 93 | linearGradient.setAttribute('x2', x2) 94 | linearGradient.setAttribute('y2', y2) 95 | linearGradient.append(...colorStops.map(getColorStops)) 96 | 97 | return linearGradient 98 | } 99 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as postcss from 'postcss' 2 | import cssValueParser from 'postcss-value-parser' 3 | 4 | import { isCSSFontFaceRule, unescapeStringValue } from './css.js' 5 | import { svgNamespace, xlinkNamespace } from './dom.js' 6 | import { createStackingLayers } from './stacking.js' 7 | import { DomToSvgOptions, walkNode } from './traversal.js' 8 | import { createIdGenerator } from './util.js' 9 | 10 | export { DomToSvgOptions } 11 | 12 | export function documentToSVG(document: Document, options?: DomToSvgOptions): XMLDocument { 13 | return elementToSVG(document.documentElement, options) 14 | } 15 | 16 | export function elementToSVG(element: Element, options?: DomToSvgOptions): XMLDocument { 17 | const svgDocument = element.ownerDocument.implementation.createDocument(svgNamespace, 'svg', null) 18 | 19 | const svgElement = (svgDocument.documentElement as unknown) as SVGSVGElement 20 | svgElement.setAttribute('xmlns', svgNamespace) 21 | svgElement.setAttribute('xmlns:xlink', xlinkNamespace) 22 | svgElement.append( 23 | svgDocument.createComment( 24 | // "--" is invalid in comments, percent-encode. 25 | ` Generated by dom-to-svg from ${element.ownerDocument.location.href.replace(/--/g, '%2D%2D')} ` 26 | ) 27 | ) 28 | 29 | // Copy @font-face rules 30 | const styleElement = svgDocument.createElementNS(svgNamespace, 'style') 31 | for (const styleSheet of element.ownerDocument.styleSheets) { 32 | try { 33 | // Make font URLs absolute (need to be resolved relative to the stylesheet) 34 | for (const rule of styleSheet.rules ?? []) { 35 | if (!isCSSFontFaceRule(rule)) { 36 | continue 37 | } 38 | const styleSheetHref = rule.parentStyleSheet?.href 39 | if (styleSheetHref) { 40 | // Note: Firefox does not implement rule.style.src, need to use rule.style.getPropertyValue() 41 | const parsedSourceValue = cssValueParser(rule.style.getPropertyValue('src')) 42 | parsedSourceValue.walk(node => { 43 | if (node.type === 'function' && node.value === 'url' && node.nodes[0]) { 44 | const urlArgumentNode = node.nodes[0] 45 | if (urlArgumentNode.type === 'string' || urlArgumentNode.type === 'word') { 46 | urlArgumentNode.value = new URL( 47 | unescapeStringValue(urlArgumentNode.value), 48 | styleSheetHref 49 | ).href 50 | } 51 | } 52 | }) 53 | // Firefox does not support changing `src` on CSSFontFaceRule declarations, need to use PostCSS. 54 | const updatedFontFaceRule = postcss.parse(rule.cssText) 55 | updatedFontFaceRule.walkDecls('src', declaration => { 56 | declaration.value = cssValueParser.stringify(parsedSourceValue.nodes) 57 | }) 58 | styleElement.append(updatedFontFaceRule.toString() + '\n') 59 | } 60 | } 61 | } catch (error) { 62 | console.error('Error resolving @font-face src URLs for styleSheet, skipping', styleSheet, error) 63 | } 64 | } 65 | svgElement.append(styleElement) 66 | 67 | walkNode(element, { 68 | svgDocument, 69 | currentSvgParent: svgElement, 70 | stackingLayers: createStackingLayers(svgElement), 71 | parentStackingLayer: svgElement, 72 | getUniqueId: createIdGenerator(), 73 | labels: new Map(), 74 | ancestorMasks: [], 75 | options: { 76 | captureArea: options?.captureArea ?? element.getBoundingClientRect(), 77 | keepLinks: options?.keepLinks !== false, 78 | }, 79 | }) 80 | 81 | const bounds = options?.captureArea ?? element.getBoundingClientRect() 82 | svgElement.setAttribute('width', bounds.width.toString()) 83 | svgElement.setAttribute('height', bounds.height.toString()) 84 | svgElement.setAttribute('viewBox', `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`) 85 | 86 | return svgDocument 87 | } 88 | 89 | export { inlineResources } from './inline.js' 90 | -------------------------------------------------------------------------------- /src/inline.ts: -------------------------------------------------------------------------------- 1 | import * as postcss from 'postcss' 2 | import cssValueParser from 'postcss-value-parser' 3 | 4 | import { unescapeStringValue } from './css.js' 5 | import { isSVGImageElement, isSVGStyleElement, svgNamespace } from './dom.js' 6 | import { handleSvgNode } from './svg.js' 7 | import { withTimeout, assert } from './util.js' 8 | 9 | declare global { 10 | interface SVGStyleElement extends LinkStyle {} 11 | } 12 | 13 | /** 14 | * Inlines all external resources of the given element, such as fonts and images. 15 | * 16 | * Fonts and binary images are inlined as Base64 data: URIs. 17 | * 18 | * Images that reference another SVG are inlined by inlining the embedded SVG into the output SVG. 19 | * Note: The passed element needs to be attached to a document with a window (`defaultView`) for this so that `getComputedStyle()` can be used. 20 | */ 21 | export async function inlineResources(element: Element): Promise { 22 | await Promise.all([ 23 | ...[...element.children].map(inlineResources), 24 | (async () => { 25 | if (isSVGImageElement(element)) { 26 | const blob = await withTimeout(10000, `Timeout fetching ${element.href.baseVal}`, () => 27 | fetchResource(element.href.baseVal) 28 | ) 29 | if (blob.type === 'image/svg+xml') { 30 | // If the image is an SVG, inline it into the output SVG. 31 | // Some tools (e.g. Figma) do not support nested SVG. 32 | 33 | assert(element.ownerDocument, 'Expected element to have ownerDocument') 34 | 35 | // Replace with inline 36 | const embeddedSvgDocument = new DOMParser().parseFromString( 37 | await blob.text(), 38 | 'image/svg+xml' 39 | ) as XMLDocument 40 | const svgRoot = (embeddedSvgDocument.documentElement as Element) as SVGSVGElement 41 | svgRoot.setAttribute('x', element.getAttribute('x')!) 42 | svgRoot.setAttribute('y', element.getAttribute('y')!) 43 | svgRoot.setAttribute('width', element.getAttribute('width')!) 44 | svgRoot.setAttribute('height', element.getAttribute('height')!) 45 | svgRoot.remove() 46 | element.replaceWith(svgRoot) 47 | try { 48 | // Let handleSvgNode inline the into a simple 49 | const svgDocument = element.ownerDocument 50 | const mount = svgDocument.createElementNS(svgNamespace, 'g') 51 | assert(element.id, ' element must have ID') 52 | handleSvgNode(svgRoot, { 53 | currentSvgParent: mount, 54 | svgDocument, 55 | idPrefix: `${element.id}-`, 56 | options: { 57 | // SVGs embedded through are never interactive. 58 | keepLinks: false, 59 | captureArea: svgRoot.viewBox.baseVal, 60 | }, 61 | }) 62 | 63 | // Replace the element with the 64 | mount.dataset.tag = 'img' 65 | mount.setAttribute('role', 'img') 66 | svgRoot.replaceWith(mount) 67 | } finally { 68 | svgRoot.remove() 69 | } 70 | } else { 71 | // Inline binary images as base64 data: URL 72 | const dataUrl = await blobToDataURL(blob) 73 | element.dataset.src = element.href.baseVal 74 | element.setAttribute('xlink:href', dataUrl.href) 75 | } 76 | } else if (isSVGStyleElement(element)) { 77 | try { 78 | const promises: Promise[] = [] 79 | // Walk the stylesheet and replace @font-face src URLs with data URIs 80 | const parsedSheet = postcss.parse(element.textContent ?? '') 81 | parsedSheet.walkAtRules('font-face', fontFaceRule => { 82 | fontFaceRule.walkDecls('src', sourceDeclaration => { 83 | const parsedSourceValue = cssValueParser(sourceDeclaration.value) 84 | parsedSourceValue.walk(node => { 85 | if (node.type === 'function' && node.value === 'url' && node.nodes[0]) { 86 | const urlArgumentNode = node.nodes[0] 87 | if (urlArgumentNode.type === 'string' || urlArgumentNode.type === 'word') { 88 | promises.push(inlineCssFontUrlArgumentNode(urlArgumentNode)) 89 | } 90 | } 91 | }) 92 | sourceDeclaration.value = cssValueParser.stringify(parsedSourceValue.nodes) 93 | }) 94 | }) 95 | await Promise.all(promises) 96 | // Update