├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── card.png └── workflows │ └── ci.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── playground ├── LICENSE ├── assets │ ├── Roboto-Bold.ttf │ └── Roboto-Regular.ttf ├── cards │ ├── data.js │ ├── github.js │ ├── overflow.js │ ├── rauchg.js │ ├── text-align.js │ ├── transform-origin.js │ ├── vercel.js │ └── white-space.js ├── package.json ├── pages │ ├── _app.jsx │ ├── api │ │ ├── font.js │ │ └── og.js │ └── index.jsx ├── public │ ├── break_iterator.wasm │ ├── iaw-mono-var.woff2 │ ├── inter-latin-ext-400-normal.woff │ ├── inter-latin-ext-700-normal.woff │ ├── material-icons-base-400-normal.woff │ ├── resvg.wasm │ └── satori-card.jpeg ├── styles.css └── utils │ └── twemoji.js ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src ├── builder │ ├── background-image.ts │ ├── border-radius.ts │ ├── border.ts │ ├── content-mask.ts │ ├── image.ts │ ├── overflow.ts │ ├── rect.ts │ ├── shadow.ts │ ├── svg.ts │ ├── text-decoration.ts │ ├── text.ts │ └── transform.ts ├── font.ts ├── handler │ ├── expand.ts │ ├── image.ts │ ├── index.ts │ ├── inheritable.ts │ ├── presets.ts │ └── tailwind.ts ├── index.ts ├── language.ts ├── layout.ts ├── satori.ts ├── text.ts ├── transform-origin.ts ├── types.d.ts ├── utils.ts ├── vendor │ ├── gradient-parser │ │ ├── LICENSE │ │ └── index.js │ ├── parse-css-dimension │ │ ├── LICENSE │ │ ├── index.js │ │ ├── package.json │ │ └── src.js │ └── twrnc │ │ ├── deprecate.js │ │ ├── log.js │ │ └── picocolors.js └── yoga │ ├── index.ts │ ├── yoga-prebuilt.ts │ └── yoga-prebuilt.wasm.ts ├── test ├── __image_snapshots__ │ ├── basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-combine-text-nodes-correctly-1-snap.png │ ├── basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-render-basic-div-with-background-color-1-snap.png │ ├── basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-render-basic-div-with-text-1-snap.png │ ├── basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-render-basic-div-with-text-and-background-color-1-snap.png │ ├── basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-render-empty-div-1-snap.png │ ├── basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-support-array-in-jsx-children-1-snap.png │ ├── basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-support-hex-colors-1-snap.png │ ├── basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-support-skipping-embedded-fonts-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-2-m-22-mshould-support-the-shorthand-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-color-2-m-22-mshould-fallback-border-color-to-the-current-color-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-color-2-m-22-mshould-render-black-border-by-default-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-color-2-m-22-mshould-support-overriding-border-color-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-color-2-m-22-mshould-support-specifying-border-color-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-not-exceed-the-length-of-the-short-side-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-percentage-border-radius-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-radius-for-a-certain-corner-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-slash-and-2-value-corner-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-the-shorthand-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-vw-vh-em-and-rem-units-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-style-2-m-22-mshould-support-dashed-border-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-width-2-m-22-mshould-render-border-inside-the-shape-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mdirectional-2-m-22-mshould-support-advanced-border-with-radius-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mdirectional-2-m-22-mshould-support-directional-border-1-snap.png │ ├── border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mdirectional-2-m-22-mshould-support-non-complete-border-1-snap.png │ ├── font-test-tsx-test-font-test-tsx-2-m-22-m-font-2-m-22-mfont-size-2-m-22-mshould-allow-font-size-to-be-0-1-snap.png │ ├── font-test-tsx-test-font-test-tsx-2-m-22-m-font-2-m-22-mshould-not-error-when-no-font-is-specified-and-no-text-rendered-1-snap.png │ ├── gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mlinear-gradient-2-m-22-mshould-support-linear-gradient-1-snap.png │ ├── gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mlinear-gradient-2-m-22-mshould-support-linear-gradient-with-transparency-1-snap.png │ ├── gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mlinear-gradient-2-m-22-mshould-support-repeating-linear-gradient-1-snap.png │ ├── gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mradial-gradient-2-m-22-mshould-support-radial-gradient-1-snap.png │ ├── gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mshould-render-gradient-patterns-in-the-correct-object-space-1-snap.png │ ├── gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mshould-resolve-gradient-layers-in-the-correct-order-1-snap.png │ ├── gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mshould-support-advanced-usage-1-snap.png │ ├── image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mbackground-image-url-2-m-22-mshould-resolve-image-data-1-snap.png │ ├── image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-clip-content-in-the-border-and-padding-areas-1-snap.png │ ├── image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-clip-content-in-the-border-area-1-snap.png │ ├── image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-deduplicate-image-data-requests-1-snap.png │ ├── image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-resolve-image-data-1-snap.png │ ├── image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-resolve-the-image-size-and-scale-automatically-1-snap.png │ ├── image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-support-styles-1-snap.png │ ├── image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-support-svg-images-and-percentage-with-correct-aspect-ratio-1-snap.png │ ├── overflow-test-tsx-test-overflow-test-tsx-2-m-22-m-overflow-2-m-22-mshould-not-show-overflowed-text-1-snap.png │ ├── overflow-test-tsx-test-overflow-test-tsx-2-m-22-m-overflow-2-m-22-mshould-work-with-nested-border-border-radius-padding-1-snap.png │ ├── position-test-tsx-test-position-test-tsx-2-m-22-m-position-2-m-22-mabsolute-2-m-22-mshould-support-absolute-position-1-snap.png │ ├── shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-be-affected-by-container-opacity-1-snap.png │ ├── shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-render-box-shadow-with-offset-1-snap.png │ ├── shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-render-box-shadow-with-offset-and-spread-1-snap.png │ ├── shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-render-multiple-box-shadows-1-snap.png │ ├── shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-render-regular-box-shadow-1-snap.png │ ├── shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-support-box-shadow-for-transparent-elements-1-snap.png │ ├── shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-support-box-shadow-spread-with-transparency-1-snap.png │ ├── shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-support-inset-box-shadows-1-snap.png │ ├── shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-support-negative-spread-1-snap.png │ ├── svg-test-tsx-test-svg-test-tsx-2-m-22-m-svg-2-m-22-mshould-render-svg-attributes-correctly-1-snap.png │ ├── svg-test-tsx-test-svg-test-tsx-2-m-22-m-svg-2-m-22-mshould-render-svg-nodes-1-snap.png │ ├── svg-test-tsx-test-svg-test-tsx-2-m-22-m-svg-2-m-22-mshould-render-svg-size-correctly-1-snap.png │ ├── transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mmultiple-transforms-2-m-22-mshould-support-translate-rotate-and-scale-1-snap.png │ ├── transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mrotate-2-m-22-mshould-rotate-shape-1-snap.png │ ├── transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mscale-2-m-22-mshould-scale-shape-1-snap.png │ ├── transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mscale-2-m-22-mshould-scale-shape-in-two-directions-1-snap.png │ ├── transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mtranslate-2-m-22-mshould-support-1-snap.png │ ├── transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mtranslate-2-m-22-mshould-translate-shape-1-snap.png │ ├── transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mtranslate-2-m-22-mshould-translate-shape-in-x-axis-1-snap.png │ ├── transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mtranslate-2-m-22-mshould-translate-shape-in-y-axis-1-snap.png │ ├── units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-1-snap.png │ ├── units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-em-1-snap.png │ ├── units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-px-and-numbers-1-snap.png │ ├── units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-rem-1-snap.png │ ├── units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-rgb-syntaxs-1-snap.png │ ├── units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-vh-and-vw-1-snap.png │ ├── white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mnormal-2-m-22-mshould-not-render-extra-line-breaks-with-white-space-normal-1-snap.png │ ├── white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mnormal-2-m-22-mshould-not-render-extra-spaces-with-white-space-normal-1-snap.png │ ├── white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mnormal-2-m-22-mshould-wrap-automatically-with-white-space-normal-1-snap.png │ ├── white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mpre-2-m-22-mshould-always-preserve-extra-line-breaks-with-white-space-pre-1-snap.png │ ├── white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mpre-2-m-22-mshould-always-preserve-extra-spaces-with-white-space-pre-1-snap.png │ ├── white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mpre-2-m-22-mshould-not-wrap-with-white-space-pre-1-snap.png │ ├── white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mwith-white-space-nowrap-2-m-22-mshould-not-wrap-with-white-space-nowrap-and-swallow-extra-spaces-1-snap.png │ ├── white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mwith-white-space-pre-wrap-2-m-22-mshould-always-preserve-extra-line-breaks-with-white-space-pre-wrap-1-snap.png │ ├── white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mwith-white-space-pre-wrap-2-m-22-mshould-always-preserve-extra-spaces-with-white-space-pre-wrap-1-snap.png │ └── white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mwith-white-space-pre-wrap-2-m-22-mshould-automatically-wrap-with-white-space-pre-wrap-1-snap.png ├── assets │ ├── Roboto-Bold.ttf │ ├── Roboto-Regular.ttf │ └── playfair-display.ttf ├── basic.test.tsx ├── border.test.tsx ├── error.test.tsx ├── font.test.tsx ├── gradient.test.tsx ├── image.test.tsx ├── language.test.tsx ├── overflow.test.tsx ├── position.test.tsx ├── shadow.test.tsx ├── svg.test.tsx ├── transform.test.tsx ├── units.test.tsx ├── utils.tsx └── white-space.test.tsx ├── tsconfig.json ├── tsconfig.wasm.json ├── tsup.config.ts └── vitest.config.ts /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report for Satori 4 | --- 5 | 6 | # Bug report 7 | 8 | ## Description / Observed Behavior 9 | 10 | What kind of issues did you encounter with Satori? 11 | 12 | ## Expected Behavior 13 | 14 | How did you expect Satori to behave here? 15 | 16 | ## Reproduction 17 | 18 | Create a shareable reproduction link for the issue using https://og-playground.vercel.app. 19 | 20 | ## Additional Context 21 | 22 | Satori version, and any other context about the problem here. 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Question & Ideas 4 | url: https://github.com/vercel/satori/discussions 5 | about: Ask questions and share your thoughts with other community members 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Request a new feature for Satori 4 | --- 5 | 6 | # Feature Request 7 | 8 | ## Description 9 | 10 | What do you want to add to Satori, and why? 11 | 12 | ## Additional Context 13 | 14 | You can add a shareable link using https://og-playground.vercel.app, or any other context that helps explaining this feature request here. 15 | -------------------------------------------------------------------------------- /.github/card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/.github/card.png -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | branches: ['main'] 8 | 9 | jobs: 10 | test: 11 | name: Node.js ${{ matrix.node }} on ${{ matrix.os }} 12 | timeout-minutes: 5 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: [ubuntu-latest] 17 | node: [16, 18] 18 | runs-on: ${{ matrix.os }} 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | - name: Use pnpm 23 | run: corepack enable pnpm && pnpm --version 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'pnpm' 29 | - run: pnpm install 30 | - run: pnpm build 31 | - run: pnpm test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .vercel 4 | .vscode 5 | .next 6 | dist 7 | .pnpm-debug.log 8 | 9 | playground/public/yoga.wasm -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Satori Contribution Guidelines 2 | 3 | Thank you for reading this guide and we appreciate any contribution. 4 | 5 | ## Ask a Question 6 | 7 | You can use the repository's [Discussions](https://github.com/vercel/satori/discussions) page to ask any questions, post feedback, or share your experience on how you use this library. 8 | 9 | ## Report a Bug 10 | 11 | Whenever you find something which is not working properly, please first search the repository's [Issues](https://github.com/vercel/satori/issues) page and make sure it's not reported by someone else already. 12 | 13 | If not, feel free to open an issue with a detailed description of the problem and the expected behavior. A bug reproduction using [Satori’s playground](https://og-playground.vercel.app) will be extremely helpful. 14 | 15 | ## Request for a New Feature 16 | 17 | For new features, it would be great to have some discussions from the community before starting working on it. You can either create an issue (if there isn't one) or post a thread on the [Discussions](https://github.com/vercel/satori/discussions) page to describe the feature that you want to have. 18 | 19 | If possible, you can add another additional context like how this feature can be implemented technically, what other alternative solutions we can have, etc. 20 | 21 | ## Local Development 22 | 23 | This project uses [pnpm](https://pnpm.io). To install dependencies, run: 24 | 25 | ```bash 26 | pnpm install 27 | ``` 28 | 29 | To start the playground locally, run: 30 | 31 | ```bash 32 | cd playground 33 | pnpm dev 34 | ``` 35 | 36 | And visit localhost:3000. 37 | 38 | To start the development mode of Satori, run `pnpm dev` in the root directory (can be used together with the playground to see changes in live). 39 | 40 | ## Adding Tests 41 | 42 | Satori uses [Vitest](https://vitest.dev) to test and generate snapshots. To start and live-watch the tests, run: 43 | 44 | ```bash 45 | pnpm dev:test 46 | ``` 47 | 48 | It will update snapshot images as well. 49 | 50 | You can also use `pnpm test` to only run the test. 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "satori", 3 | "version": "0.0.38", 4 | "description": "Enlightened library to convert HTML and CSS to SVG.", 5 | "module": "./dist/esm/index.js", 6 | "main": "./dist/esm/index.js", 7 | "types": "./dist/index.d.ts", 8 | "type": "module", 9 | "license": "MPL-2.0", 10 | "files": [ 11 | "dist/**" 12 | ], 13 | "exports": { 14 | "./package.json": "./package.json", 15 | ".": "./dist/esm/index.js", 16 | "./wasm": "./dist/esm/index.wasm.js" 17 | }, 18 | "scripts": { 19 | "dev": "concurrently \"pnpm dev:default\" \"pnpm dev:wasm\"", 20 | "dev:default": "NODE_ENV=development tsup src/index.ts --watch --ignore-watch playground", 21 | "dev:wasm": "WASM=1 NODE_ENV=development tsup src/index.ts --watch --ignore-watch playground", 22 | "build": "NODE_ENV=production pnpm run build:default && pnpm run build:wasm", 23 | "build:default": "tsup", 24 | "build:wasm": "WASM=1 tsup", 25 | "test": "NODE_ENV=test vitest run --outputTruncateLength=9999999", 26 | "dev:test": "NODE_ENV=test vitest --update --outputTruncateLength=9999999" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/vercel/satori.git" 31 | }, 32 | "keywords": [ 33 | "HTML", 34 | "JSX", 35 | "SVG", 36 | "converter", 37 | "renderer" 38 | ], 39 | "author": "Shu Ding ", 40 | "bugs": { 41 | "url": "https://github.com/vercel/satori/issues" 42 | }, 43 | "homepage": "https://github.com/vercel/satori#readme", 44 | "devDependencies": { 45 | "@resvg/resvg-js": "^2.1.0", 46 | "@types/node": "^16", 47 | "@types/opentype.js": "^1.3.3", 48 | "@types/react": "^17.0.38", 49 | "@types/yoga-layout": "^1.9.4", 50 | "@vitest/ui": "^0.7.6", 51 | "concurrently": "^7.3.0", 52 | "esbuild-plugin-replace": "^1.2.0", 53 | "jest-image-snapshot": "^5.2.0", 54 | "react": "^17.0.2", 55 | "tsup": "^5.11.13", 56 | "twrnc": "^3.4.0", 57 | "typescript": "^4.5.5", 58 | "vitest": "0.23.4" 59 | }, 60 | "dependencies": { 61 | "@shuding/opentype.js": "1.4.0-beta.0", 62 | "css-background-parser": "^0.1.0", 63 | "css-box-shadow": "1.0.0-3", 64 | "css-to-react-native": "^3.0.0", 65 | "postcss-value-parser": "^4.2.0", 66 | "yoga-layout-prebuilt": "^1.10.0" 67 | }, 68 | "packageManager": "pnpm@7.11.0", 69 | "engines": { 70 | "node": ">=16" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /playground/assets/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/playground/assets/Roboto-Bold.ttf -------------------------------------------------------------------------------- /playground/assets/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/playground/assets/Roboto-Regular.ttf -------------------------------------------------------------------------------- /playground/cards/github.js: -------------------------------------------------------------------------------- 1 | // https://opengraph.githubassets.com/f460d576b0a8eb3c0f96ab489be83adfbc198d418a5a38f168e5fc65008f7a4b/vercel/next.js 2 | function Item({ title, subtitle, icon }) { 3 | return ( 4 |
13 | 21 | {icon} 22 | 23 |
24 |
{title}
25 |
{subtitle}
26 |
27 |
28 | ) 29 | } 30 | 31 | export const VercelLogo = 32 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAHv0lEQVR4nOzdzUtUfxvH8V/3MdJjAyWczAEZwXAkMAhCF9qmlbiuTZsJ2w25aVXQJpCEQEjXRWtnXdCqhZsycCE4MRVoixppMSOoMYjJ3Nz3QPVrxpM6Z851Zj7v1z/gNYfv23m45uE//wDCCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSCADSHOsBpMXjcdd1v3//bj0IYCGTySwsLFhPAVgYGxsr/9/o6Kj1LEC4HMdZWlqqBLC8vHzixAnriYAQpVKp8m9u3bplPREQFtd1v3379nsA+Xy+o6PDei4gFI8ePSpXuX37tvVcQOPF4/GdnZ3qAFZWVqxHAxpvbm6u+vRX3Llzx3o6oJH6+/tLpdJBAZRKpUQiYT0j0DCZTOag01/BXgwt6+fmyx97MbSg3zdf/tiLoQX9sfnyx14MLaV68+Uvn8+fPn3aemogIDU3X/6mp6etpwaCMDIy4vPS50F2d3eHh4etZwfq4zhOLpc76umvyGazjsMnltDMpqamjnf6K6ampqxvAXBcnucd6blvtUKhEIvFrG8HcCw+b/s5PN4ghKbU29u7t7dXfwAfPnxgL4bmMzMzU//pr2Avhibjuu7m5mZQAbAXQ5N5+vRpUKe/4tmzZ9a3CTiciYmJYE9/xfj4uPUtA/6mns2XP/ZiaAJ1br78sRdDpNW/+fJXKBTOnj1rfSuBAwSy+fL35MkT61sJ1JJMJgPZfPnb29sbHBy0vq1AlRcvXjT69Fe8fPnS+rYC/zY5ORnO6a9gN4wI8Tyv5pe9Nc7W1lZXV5f17W4F/ERSAB4/ftzZ2RnmX4zFYnyXKCIhnOe+1T5//sxerH7cA9QrnU63tbWF/3cTiUQ6nQ7/7wK/eJ4X4Ls+j4q9GIyFsPnyx14MZqwe/f+OvRhstLe3v3nzxvb0V7x9+/bkyZPW1wNi7t27Z33yf7l//7719YCS8Ddf/ra2ts6fP299VSDj+fPn1mf+T3xmEiG5cuWK+XPfavv7+5cvX7a+NhCwuLhofdprW1xctL42aHU3btywPud+rl+/bn2F0Lpc1/306ZP1IfeztrbGS6JolPn5eesT/nd8cB4NMTQ0ZH22DyWfz586dcr6ajUN3g16WM3yLc09PT137961ngKtxfO8Y/zMkRX2YghYBDdf/tiLITDR3Hz5Yy+GwER28+WPvRgCEPHNlz/2YqhLPB7/8uWL9TE+vnw+393dbX0V0bQymYz1Ga7XwsKC9VVEcxobG7M+vcEYHR21vpZoNo7jLC0tWR/dYCwvL/M7kziaVCplfW6DxHeJHoR/DDW4rru+vn7u3DnrQQKzsbHR399fKpWsB4kc3gtUw4MHD1rp9FfeIHTz5k3rKdAM4vF4pD7wHpSVlRXrSxtFfLvqn6anp69evWo9RfC6u7sLhcK7d++sB0GEVR4oW/+zbpRSqZRIJKyvMSKsBTZf/tiL4UAts/nyx14MNbTS5ssfezHU0GKbL3/sxX7iP8E/Lbn58rexsTEwMLCzs2M9iD1eBv2fhw8fjo+PW08RnlgsVi6XX79+bT0IImBkZKSFX/o8yO7u7vDwsPW1hzXHcXK5nPVptJHNZvmdSfX3AqXT6WQyaT2FjYsXL/I7k9JPgj3PW11d1XnuW61YLPb19W1vb1sPYkb6HqD13vV5VF1dXalUynoKS7r3AL29vWtraya/cR0pHz9+HBwcLJfL1oPY0L0HsPqF96gZGBhQvhMQvQdwXffr169nzpyxHiQSlPdiovcA8/PznP6fenp65ubmrKdAWCYmJqxfgo8iqV34T3IPgRzHyWazsq/9+3j//v2lS5f29/etBwmV3EMg5c2XP829mNY9AJsvf8Vi8cKFC5ubm9aDhEfrrSAzMzPXrl2zniK6Ojo62tvbX716ZT1IeITuAZLJ5OrqKq/9+/vx48fQ0FAul7MeJCRCzwFmZ2c5/X/V1tY2OztrPQWCNjk5af0yYzPR+cykxEMgz/PW19c7OzutB2ka29vbfX19xWLRehAAjST0HACoRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQRgCQ9t8AAAD//6oW3AkoFImNAAAAAElFTkSuQmCC' 33 | 34 | export default ( 35 |
45 |
54 |
63 |

64 | vercel/next.js 65 |

66 |

73 | The React Framework 74 |

75 |
76 |
77 | 85 |
86 |
87 |
94 | 95 | 96 | 97 | 98 | 99 |
100 |
101 | ) 102 | -------------------------------------------------------------------------------- /playground/cards/overflow.js: -------------------------------------------------------------------------------- 1 | import { VercelLogo } from './github' 2 | 3 | export default ( 4 |
11 |
18 |
28 | Hello, world!!!! 29 |
30 |
41 | Hello, world!!!! 42 |
43 |
53 | Hello, world!!!! 54 |
55 |
56 |
63 |
73 |
74 |
75 |
85 |
86 |
87 |
97 |
98 |
99 |
109 |
112 |
113 |
123 |
131 |
132 |
133 |
140 |
148 | Nested 149 |
159 |
160 |
161 |
162 |
171 | Border 172 |
182 |
183 |
184 |
185 |
195 | Border 196 |
206 |
207 |
208 |
209 |
220 |
231 |
232 |
233 |
240 |
248 | 256 |
257 |
265 | 274 |
275 |
276 |
277 | ) 278 | -------------------------------------------------------------------------------- /playground/cards/rauchg.js: -------------------------------------------------------------------------------- 1 | export default ( 2 |
15 |
24 | 31 | 39 | rauchg.com 40 | 41 |
42 |
67 | 7 Principles of 68 | 76 | Rich 77 | 78 | Web Applications 79 |
80 |
81 | ) 82 | -------------------------------------------------------------------------------- /playground/cards/text-align.js: -------------------------------------------------------------------------------- 1 | export default ( 2 |
9 |
23 |
31 | Hello, world!!!! Satori is a library. 32 |
33 |
43 | Hello, world!!!! Satori is a library. 44 |
45 |
54 | Hello, world!!!! Satori is a library. 55 |
56 |
57 |
65 | Hello, world!!!! 66 |
67 |
68 |
76 | Hello, world!!!! 77 |
78 |
79 |
88 | Hello, world!!!! 89 |
90 |
91 |
101 | Hey, Satori. 102 |
103 |
104 |
116 |
Hello, world!!!!
117 |
125 | text-align: left. Lorem ipsum dolor sit amet consectetur adipisicing 126 | elit. Animi natus doloribus unde eaque facere suscipit eum! Error, 127 | quidem commodi suscipit eos expedita repellendus fuga. Officia, ut! Esse 128 | pariatur saepe praesentium. 129 |
130 |
138 | text-align: center. Lorem ipsum dolor sit amet consectetur adipisicing 139 | elit. Animi natus doloribus unde eaque facere suscipit eum! Error, 140 | quidem commodi suscipit eos expedita repellendus fuga. Officia, ut! Esse 141 | pariatur saepe praesentium. 142 |
143 |
151 | text-align: jusitfy. Lorem ipsum dolor sit amet consectetur adipisicing 152 | elit. Animi natus doloribus unde eaque facere suscipit eum! Error, 153 | quidem commodi suscipit eos expedita repellendus fuga. Officia, ut! Esse 154 | pariatur saepe praesentium. 155 |
156 |
164 | text-align: right. Lorem ipsum dolor sit amet consectetur adipisicing 165 | elit. Animi natus doloribus unde eaque facere suscipit eum! Error, 166 | quidem commodi suscipit eos expedita repellendus fuga. Officia, ut! Esse 167 | pariatur saepe praesentium. 168 |
169 |
170 |
171 | ) 172 | -------------------------------------------------------------------------------- /playground/cards/transform-origin.js: -------------------------------------------------------------------------------- 1 | export default ( 2 |
14 |
23 |
32 |
40 |
41 | ) 42 | -------------------------------------------------------------------------------- /playground/cards/vercel.js: -------------------------------------------------------------------------------- 1 | // https://og-image.vercel.app/**Hello**%20World.png?theme=light&md=1&fontSize=100px&images=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Ffront%2Fassets%2Fdesign%2Fvercel-triangle-black.svg 2 | 3 | //const VercelLogo = 'data:image/svg+xml,' 4 | const VercelLogo = 5 | 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTE2IiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTU3LjUgMEwxMTUgMTAwSDBMNTcuNSAweiIgLz48L3N2Zz4=' 6 | 7 | const Strong = (props) => ( 8 | {props.children} 9 | ) 10 | 11 | const Spacer = (props) => ( 12 |
{props.children}
13 | ) 14 | 15 | export default ( 16 |
32 |
41 | logo 48 |
49 |
59 | Hello World 60 |
61 |
62 | ) 63 | -------------------------------------------------------------------------------- /playground/cards/white-space.js: -------------------------------------------------------------------------------- 1 | const code = `export default function Counter() { 2 | const [count, setCount] = useState(0); 3 | return ( 4 |
5 |

You clicked {count} times

6 | 7 |
8 | ); 9 | }` 10 | 11 | export default ( 12 |
21 |
33 |
44 |
50 |
51 | white-space: normal 52 |
53 | {code} 54 |
55 |
62 |
63 | white-space: pre-wrap 64 |
65 | {code} 66 |
67 |
68 |
79 |
86 |
87 | white-space: pre 88 |
89 | {code} 90 |
91 |
98 |
99 | white-space: nowrap 100 |
101 | {code} 102 |
103 |
104 |
105 |
106 | ) 107 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "license": "MPL-2.0", 4 | "scripts": { 5 | "dev": "next", 6 | "debug": "node --inspect node_modules/next/dist/bin/next", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@babel/runtime": "^7.19.0", 12 | "@monaco-editor/react": "^4.4.5", 13 | "@resvg/resvg-js": "^1.4.0", 14 | "@resvg/resvg-wasm": "2.0.0-alpha.4", 15 | "blob-stream": "^0.1.3", 16 | "copy-to-clipboard": "^3.3.2", 17 | "fflate": "^0.7.3", 18 | "intl-segmenter-polyfill": "^0.4.4", 19 | "js-base64": "^3.7.2", 20 | "next": "^12.2.5", 21 | "pdfkit": "^0.13.0", 22 | "react": "^17.0.2", 23 | "react-dom": "^17.0.2", 24 | "react-hot-toast": "^2.3.0", 25 | "react-live": "^2.4.1", 26 | "satori": "workspace:*", 27 | "svg-to-pdfkit": "^0.1.8", 28 | "yoga-wasm-web": "0.1.2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /playground/pages/_app.jsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | 3 | import '../styles.css' 4 | 5 | export default function App({ Component, pageProps }) { 6 | return ( 7 | <> 8 | 9 | Satori Playground 10 | 14 | 15 | 16 | 20 | 21 | 25 | 26 | 30 | 34 | 35 | 39 | 40 | 44 | 48 | 54 | 60 | 66 | 72 | 76 | 77 | 78 | 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /playground/pages/api/font.js: -------------------------------------------------------------------------------- 1 | export const config = { 2 | runtime: 'experimental-edge', 3 | } 4 | 5 | export default async function loadGoogleFont(req) { 6 | if (req.nextUrl.pathname !== '/api/font') return 7 | const { searchParams, hostname } = new URL(req.url) 8 | 9 | const font = searchParams.get('font') 10 | const text = searchParams.get('text') 11 | 12 | if (!font || !text) return 13 | 14 | const API = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent( 15 | text 16 | )}` 17 | 18 | const css = await ( 19 | await fetch(API, { 20 | headers: { 21 | // Make sure it returns TTF. 22 | 'User-Agent': 23 | 'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; de-at) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1', 24 | }, 25 | }) 26 | ).text() 27 | 28 | const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/) 29 | 30 | if (!resource) return 31 | 32 | const res = await fetch(resource[1]) 33 | 34 | // Make sure not to mess it around with compression when developing it locally. 35 | if (hostname === 'localhost') { 36 | res.headers.delete('content-encoding') 37 | res.headers.delete('content-length') 38 | } 39 | 40 | return res 41 | } 42 | -------------------------------------------------------------------------------- /playground/pages/api/og.js: -------------------------------------------------------------------------------- 1 | import { renderAsync } from '@resvg/resvg-js' 2 | import { renderToStaticMarkup } from 'react-dom/server' 3 | import satori from 'satori' 4 | import { promises as fs } from 'fs' 5 | import { join } from 'path' 6 | 7 | import rauchgCard from '../../cards/rauchg' 8 | import textCard from '../../cards/text-align' 9 | import githubCard from '../../cards/github' 10 | import overflowCard from '../../cards/overflow' 11 | import vercelCard from '../../cards/vercel' 12 | 13 | const card = vercelCard 14 | 15 | let customFontsLoaded = false 16 | let fonts = [] 17 | const loadingCustomFonts = (async () => { 18 | const [FONT_ROBOTO, FONT_ROBOTO_BOLD] = await Promise.all([ 19 | fs.readFile(join(process.cwd(), 'assets', 'Roboto-Regular.ttf')), 20 | fs.readFile(join(process.cwd(), 'assets', 'Roboto-Bold.ttf')), 21 | ]) 22 | fonts = [ 23 | { 24 | name: 'Inter', 25 | data: FONT_ROBOTO, 26 | weight: 400, 27 | style: 'normal', 28 | }, 29 | { 30 | name: 'Inter', 31 | data: FONT_ROBOTO_BOLD, 32 | weight: 700, 33 | style: 'normal', 34 | }, 35 | ] 36 | customFontsLoaded = true 37 | })() 38 | 39 | export default async (req, res) => { 40 | const t1 = Date.now() 41 | 42 | if (!customFontsLoaded) { 43 | await loadingCustomFonts 44 | } 45 | 46 | const { width = 800, height = 510, debug = false, type = 'png' } = req.query 47 | 48 | const t2 = Date.now() 49 | 50 | const svg = await satori(card, { 51 | width, 52 | height, 53 | fonts, 54 | debug: !!debug, 55 | }) 56 | 57 | const t3 = Date.now() 58 | 59 | if (type === 'svg') { 60 | res.setHeader('Content-Type', 'image/svg+xml') 61 | res.end(svg) 62 | return 63 | } else if (type === 'html') { 64 | res.setHeader('Content-Type', 'text/html') 65 | res.end(renderToStaticMarkup(card)) 66 | return 67 | } 68 | 69 | const data = await renderAsync(svg, { 70 | fitTo: { 71 | mode: 'width', 72 | value: width, 73 | }, 74 | font: { 75 | loadSystemFonts: false, 76 | }, 77 | }) 78 | 79 | const t4 = Date.now() 80 | 81 | res.setHeader('content-type', 'image/png') 82 | 83 | await new Promise((resolve) => { 84 | res.end(data, resolve) 85 | }) 86 | 87 | const t5 = Date.now() 88 | 89 | console.table({ 90 | loadFonts: t2 - t1, 91 | Satori: t3 - t2, 92 | png: t4 - t3, 93 | response: t5 - t4, 94 | '-------': '--', 95 | TOTAL: t5 - t1, 96 | }) 97 | } 98 | -------------------------------------------------------------------------------- /playground/public/break_iterator.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/playground/public/break_iterator.wasm -------------------------------------------------------------------------------- /playground/public/iaw-mono-var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/playground/public/iaw-mono-var.woff2 -------------------------------------------------------------------------------- /playground/public/inter-latin-ext-400-normal.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/playground/public/inter-latin-ext-400-normal.woff -------------------------------------------------------------------------------- /playground/public/inter-latin-ext-700-normal.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/playground/public/inter-latin-ext-700-normal.woff -------------------------------------------------------------------------------- /playground/public/material-icons-base-400-normal.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/playground/public/material-icons-base-400-normal.woff -------------------------------------------------------------------------------- /playground/public/resvg.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/playground/public/resvg.wasm -------------------------------------------------------------------------------- /playground/public/satori-card.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/playground/public/satori-card.jpeg -------------------------------------------------------------------------------- /playground/styles.css: -------------------------------------------------------------------------------- 1 | /* Fonts for the demo */ 2 | @font-face { 3 | font-family: 'Inter'; 4 | font-style: normal; 5 | font-weight: 700; 6 | src: url(/inter-latin-ext-700-normal.woff) format('woff2'); 7 | } 8 | 9 | /* UI */ 10 | 11 | @font-face { 12 | font-family: 'iaw-mono-var'; 13 | font-weight: 100 700; 14 | font-style: normal; 15 | font-named-instance: 'Regular'; 16 | font-display: block; 17 | src: url('/iaw-mono-var.woff2') format('woff2'); 18 | } 19 | 20 | :root { 21 | --font: iaw-mono-var, SF Mono, SFMono-Regular, ui-monospace, Menlo, Consolas, 22 | monospace; 23 | } 24 | 25 | * { 26 | box-sizing: border-box; 27 | } 28 | 29 | html, 30 | body, 31 | #__next { 32 | height: 100%; 33 | } 34 | 35 | body { 36 | font-family: var(--font); 37 | font-variant: common-ligatures contextual; 38 | letter-spacing: -0.015em; 39 | margin: 0; 40 | background: fixed 0 0 /20px 20px radial-gradient(#d1d1d1 1px, transparent 0), 41 | fixed 10px 10px /20px 20px radial-gradient(#d1d1d1 1px, transparent 0); 42 | } 43 | 44 | nav { 45 | display: flex; 46 | position: sticky; 47 | top: 0; 48 | height: 40px; 49 | align-items: center; 50 | padding: 0 15px; 51 | background: white; 52 | box-shadow: 0 0 24px rgb(0 0 0 / 10%), 0 0 0 1px rgb(0 0 0 / 4%); 53 | font-size: 0.9rem; 54 | z-index: 2; 55 | } 56 | 57 | h1 { 58 | flex: 1; 59 | margin: 0; 60 | font-size: 0.9rem; 61 | font-weight: 500; 62 | color: #444; 63 | } 64 | 65 | nav ul { 66 | display: flex; 67 | list-style: none; 68 | margin: 0; 69 | gap: 10px; 70 | } 71 | 72 | a { 73 | color: inherit; 74 | } 75 | 76 | iframe { 77 | position: absolute; 78 | border: none; 79 | appearance: none; 80 | } 81 | 82 | textarea, 83 | pre, 84 | code { 85 | font-family: var(--font) !important; 86 | } 87 | .editor { 88 | display: flex; 89 | flex-direction: column; 90 | height: 100%; 91 | box-shadow: 0 4px 24px rgb(0 0 0 / 10%), 0 0 0 1px rgb(0 0 0 / 5%); 92 | background: white; 93 | border-radius: 12px; 94 | border-top-left-radius: 0; 95 | overflow: auto; 96 | } 97 | .editor .editor-controls { 98 | padding: 4px 8px; 99 | gap: 8px; 100 | display: flex; 101 | justify-content: flex-end; 102 | } 103 | .editor .editor-controls button { 104 | font-family: var(--font); 105 | font-size: 12px; 106 | user-select: none; 107 | padding: 0px 4px; 108 | height: 20px; 109 | } 110 | .editor .monaco-container { 111 | flex: 1; 112 | } 113 | 114 | .container { 115 | display: flex; 116 | width: 100%; 117 | height: calc(100% - 40px); 118 | margin: 0; 119 | padding: 10px; 120 | overflow: auto; 121 | gap: 10px; 122 | } 123 | .container > .tabs { 124 | height: 100%; 125 | display: flex; 126 | flex-direction: column; 127 | flex: 1 1 50%; 128 | max-width: 50%; 129 | } 130 | 131 | .preview { 132 | flex: 1 1 50%; 133 | max-width: calc(50% - 5px); 134 | align-self: flex-start; 135 | } 136 | 137 | .svg-container { 138 | position: relative; 139 | display: flex; 140 | /* 16:9 */ 141 | height: calc((50vw - 15px) * 9 / 16); 142 | align-items: center; 143 | justify-content: center; 144 | overflow: hidden; 145 | background: #111; 146 | } 147 | 148 | .preview-card { 149 | position: relative; 150 | border-radius: 12px; 151 | border-top-left-radius: 0; 152 | background: white; 153 | box-shadow: 0 4px 24px rgb(0 0 0 / 10%), 0 0 0 1px rgb(0 0 0 / 5%); 154 | overflow: hidden; 155 | } 156 | 157 | .preview-card footer { 158 | font-size: 12px; 159 | display: flex; 160 | align-items: center; 161 | padding: 3px 10px 4px; 162 | height: 24px; 163 | color: #444; 164 | border-top: 1px solid rgb(0 0 0 / 8%); 165 | background: #fafafa; 166 | white-space: nowrap; 167 | } 168 | .preview-card footer .ellipsis { 169 | white-space: pre; 170 | overflow: hidden; 171 | text-overflow: ellipsis; 172 | } 173 | .preview-card footer .data { 174 | flex: 1; 175 | } 176 | 177 | .error { 178 | position: absolute; 179 | width: 100%; 180 | height: calc(100% - 24px); 181 | padding: 10px 20px; 182 | overflow: auto; 183 | color: #ff3737; 184 | font-size: 13px; 185 | z-index: 1; 186 | background: white; 187 | } 188 | .error pre { 189 | margin: 0; 190 | white-space: pre-wrap; 191 | } 192 | 193 | .preview { 194 | height: 100%; 195 | display: flex; 196 | flex-direction: column; 197 | gap: 10px; 198 | } 199 | 200 | .tabs-container { 201 | display: inline-flex; 202 | position: relative; 203 | font-size: 12px; 204 | letter-spacing: -0.02em; 205 | gap: 1px; 206 | z-index: 1; 207 | user-select: none; 208 | margin-bottom: 1px; 209 | } 210 | 211 | .tab { 212 | color: #a5a5a5; 213 | box-shadow: 0 0px 0 1px rgb(231 231 231); 214 | background: #efefef; 215 | height: 20px; 216 | border-top-left-radius: 6px; 217 | border-top-right-radius: 6px; 218 | padding: 2px 10px; 219 | cursor: default; 220 | } 221 | .tab:hover { 222 | background: #fafafa; 223 | } 224 | 225 | .tab.active { 226 | color: #111; 227 | box-shadow: 0 1px white, 0 0px 0 1px rgb(0 0 0 / 5%); 228 | background: white; 229 | } 230 | 231 | .controller { 232 | flex: 1; 233 | border-radius: 12px; 234 | background: white; 235 | box-shadow: 0 4px 24px rgb(0 0 0 / 10%), 0 0 0 1px rgb(0 0 0 / 5%); 236 | overflow: hidden; 237 | } 238 | 239 | .controller h2.title { 240 | font-size: 12px; 241 | letter-spacing: -0.02em; 242 | font-weight: 500; 243 | color: #444; 244 | margin: 0; 245 | padding: 8px 10px; 246 | text-transform: uppercase; 247 | background: #fafafa; 248 | border-bottom: 1px solid rgb(0 0 0 / 8%); 249 | user-select: none; 250 | } 251 | .controller .content { 252 | display: flex; 253 | padding: 8px 10px; 254 | height: calc(100% - 34px); 255 | overflow: auto; 256 | flex-direction: column; 257 | } 258 | .controller .control { 259 | font-size: 12px; 260 | display: flex; 261 | align-items: center; 262 | gap: 8px; 263 | } 264 | .controller .control > div { 265 | display: flex; 266 | align-items: center; 267 | flex-wrap: wrap; 268 | gap: 4px; 269 | } 270 | .controller .control:not(:last-child) { 271 | padding-bottom: 8px; 272 | margin-bottom: 8px; 273 | border-bottom: 1px solid rgb(0 0 0 / 8%); 274 | } 275 | .controller .control label { 276 | flex: 0 0 140px; 277 | user-select: none; 278 | } 279 | .controller input { 280 | font-family: var(--font); 281 | outline: none; 282 | } 283 | .controller input[type='number'] { 284 | appearance: none; 285 | border: 1px solid rgb(0 0 0 / 20%); 286 | border-radius: 4px; 287 | } 288 | .controller input[type='number']:hover { 289 | border: 1px solid rgb(0 0 0 / 30%); 290 | } 291 | .controller input[type='number']:focus { 292 | border: 1px solid rgb(0 0 0 / 40%); 293 | } 294 | .controller a.disabled { 295 | color: #a5a5a5; 296 | cursor: not-allowed; 297 | } 298 | .controller .copyright { 299 | flex: 1; 300 | } 301 | .tabs-container { 302 | white-space: nowrap; 303 | max-width: calc(100% - 15px); 304 | } 305 | .tab { 306 | text-overflow: ellipsis; 307 | max-width: 100%; 308 | overflow: hidden; 309 | } 310 | 311 | @media screen and (max-width: 999px) { 312 | .container { 313 | flex-direction: column-reverse; 314 | } 315 | .preview { 316 | height: unset; 317 | width: 100%; 318 | max-height: calc((50vw - 15px) * 9 / 16 + 44px); 319 | max-width: 100%; 320 | flex-direction: row; 321 | } 322 | .container > .tabs { 323 | max-width: unset; 324 | max-height: calc(100% - (50vw - 15px) * 9 / 16 - 54px); 325 | } 326 | .preview > .tabs { 327 | width: calc(50% - 5px); 328 | } 329 | .controller { 330 | width: calc(50% - 5px); 331 | } 332 | .controller .control label { 333 | flex: 0 0 120px; 334 | } 335 | } 336 | 337 | @media screen and (max-width: 599px) { 338 | .preview > .tabs, 339 | .controller { 340 | width: 100%; 341 | } 342 | .container { 343 | gap: 0; 344 | } 345 | .preview { 346 | flex-direction: column; 347 | max-height: calc((100vw - 20px) * 9 / 16 + 214px - 5px); 348 | padding-bottom: 10px; 349 | } 350 | .container > .tabs { 351 | max-height: calc(100% - (100vw - 20px) * 9 / 16 - 214px + 5px); 352 | } 353 | .svg-container { 354 | height: calc((100vw - 20px) * 9 / 16); 355 | } 356 | .controller { 357 | height: 160px; 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /playground/utils/twemoji.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Modified version of https://unpkg.com/twemoji@13.1.0/dist/twemoji.esm.js. 3 | */ 4 | 5 | /*! Copyright Twitter Inc. and other contributors. Licensed under MIT */ 6 | 7 | const U200D = String.fromCharCode(8205) 8 | const UFE0Fg = /\uFE0F/g 9 | 10 | export function getIconCode(char) { 11 | return toCodePoint(char.indexOf(U200D) < 0 ? char.replace(UFE0Fg, '') : char) 12 | } 13 | 14 | function toCodePoint(unicodeSurrogates) { 15 | var r = [], 16 | c = 0, 17 | p = 0, 18 | i = 0 19 | while (i < unicodeSurrogates.length) { 20 | c = unicodeSurrogates.charCodeAt(i++) 21 | if (p) { 22 | r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16)) 23 | p = 0 24 | } else if (55296 <= c && c <= 56319) { 25 | p = c 26 | } else { 27 | r.push(c.toString(16)) 28 | } 29 | } 30 | return r.join('-') 31 | } 32 | 33 | const apis = { 34 | twemoji: (code) => 35 | 'https://twemoji.maxcdn.com/v/latest/svg/' + code.toLowerCase() + '.svg', 36 | openmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@2.0.0/svg/', 37 | blobmoji: 'https://cdn.jsdelivr.net/npm/@svgmoji/blob@2.0.0/svg/', 38 | noto: 'https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/', 39 | fluent: (code) => 40 | 'https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/' + 41 | code.toLowerCase() + 42 | '_color.svg', 43 | fluentFlat: (code) => 44 | 'https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/' + 45 | code.toLowerCase() + 46 | '_flat.svg', 47 | } 48 | 49 | export function loadEmoji(type, code) { 50 | if (!type || !apis[type]) { 51 | type = 'twemoji' 52 | } 53 | const api = apis[type] 54 | if (typeof api === 'function') { 55 | return fetch(api(code)) 56 | } 57 | return fetch(`${api}${code.toUpperCase()}.svg`) 58 | } 59 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'playground' 3 | -------------------------------------------------------------------------------- /src/builder/border.ts: -------------------------------------------------------------------------------- 1 | import { buildXMLString } from '../utils' 2 | import radius from './border-radius' 3 | 4 | function compareBorderDirections(a: string, b: string, style: any) { 5 | return ( 6 | style[a + 'Width'] === style[b + 'Width'] && 7 | style[a + 'Style'] === style[b + 'Style'] && 8 | style[a + 'Color'] === style[b + 'Color'] 9 | ) 10 | } 11 | 12 | export function getBorderClipPath( 13 | { 14 | id, 15 | // Can be `overflow: hidden` from parent containers. 16 | currentClipPathId, 17 | borderPath, 18 | borderType, 19 | left, 20 | top, 21 | width, 22 | height, 23 | }: { 24 | id: string 25 | currentClipPathId?: string | number 26 | borderPath?: string 27 | borderType?: 'rect' | 'path' 28 | left: number 29 | top: number 30 | width: number 31 | height: number 32 | }, 33 | style: Record 34 | ) { 35 | const hasBorder = 36 | style.borderTopWidth || 37 | style.borderRightWidth || 38 | style.borderBottomWidth || 39 | style.borderLeftWidth 40 | 41 | if (!hasBorder) return null 42 | 43 | // In SVG, stroke is always centered on the path and there is no 44 | // existing property to make it behave like CSS border. So here we 45 | // 2x the border width and introduce another clip path to clip the 46 | // overflowed part. 47 | const rectClipId = `satori_bc-${id}` 48 | const defs = buildXMLString( 49 | 'clipPath', 50 | { 51 | id: rectClipId, 52 | 'clip-path': currentClipPathId ? `url(#${currentClipPathId})` : undefined, 53 | }, 54 | buildXMLString(borderType, { 55 | x: left, 56 | y: top, 57 | width, 58 | height, 59 | d: borderPath ? borderPath : undefined, 60 | }) 61 | ) 62 | 63 | return [defs, rectClipId] 64 | } 65 | 66 | export default function border( 67 | { 68 | left, 69 | top, 70 | width, 71 | height, 72 | props, 73 | asContentMask, 74 | maskBorderOnly, 75 | }: { 76 | left: number 77 | top: number 78 | width: number 79 | height: number 80 | props: any 81 | asContentMask?: boolean 82 | maskBorderOnly?: boolean 83 | }, 84 | style: Record 85 | ) { 86 | const directions = ['borderTop', 'borderRight', 'borderBottom', 'borderLeft'] 87 | 88 | // No border 89 | if ( 90 | !asContentMask && 91 | !directions.some((direction) => style[direction + 'Width']) 92 | ) 93 | return '' 94 | 95 | let fullBorder = '' 96 | 97 | let start = 0 98 | while ( 99 | start > 0 && 100 | compareBorderDirections( 101 | directions[start], 102 | directions[(start + 3) % 4], 103 | style 104 | ) 105 | ) { 106 | start = (start + 3) % 4 107 | } 108 | 109 | let partialSides = [false, false, false, false] 110 | let currentStyle = [] 111 | for (let _i = 0; _i < 4; _i++) { 112 | const i = (start + _i) % 4 113 | const ni = (start + _i + 1) % 4 114 | 115 | const d = directions[i] 116 | const nd = directions[ni] 117 | 118 | partialSides[i] = true 119 | currentStyle = [ 120 | style[d + 'Width'], 121 | style[d + 'Style'], 122 | style[d + 'Color'], 123 | d, 124 | ] 125 | 126 | if (!compareBorderDirections(d, nd, style)) { 127 | const w = 128 | (currentStyle[0] || 0) + 129 | (asContentMask && !maskBorderOnly 130 | ? style[d.replace('border', 'padding')] || 0 131 | : 0) 132 | if (w) { 133 | fullBorder += buildXMLString('path', { 134 | width, 135 | height, 136 | ...props, 137 | fill: 'none', 138 | stroke: asContentMask ? '#000' : currentStyle[2], 139 | 'stroke-width': w * 2, 140 | 'stroke-dasharray': 141 | !asContentMask && currentStyle[1] === 'dashed' 142 | ? w * 2 + ' ' + w 143 | : undefined, 144 | d: radius( 145 | { left, top, width, height }, 146 | style as Record, 147 | partialSides 148 | ), 149 | }) 150 | } 151 | partialSides = [false, false, false, false] 152 | } 153 | } 154 | 155 | if (partialSides.some(Boolean)) { 156 | const w = 157 | (currentStyle[0] || 0) + 158 | (asContentMask && !maskBorderOnly 159 | ? style[currentStyle[3].replace('border', 'padding')] || 0 160 | : 0) 161 | if (w) { 162 | fullBorder += buildXMLString('path', { 163 | width, 164 | height, 165 | ...props, 166 | fill: 'none', 167 | stroke: asContentMask ? '#000' : currentStyle[2], 168 | 'stroke-width': w * 2, 169 | 'stroke-dasharray': 170 | !asContentMask && currentStyle[1] === 'dashed' 171 | ? w * 2 + ' ' + w 172 | : undefined, 173 | d: radius( 174 | { left, top, width, height }, 175 | style as Record, 176 | partialSides 177 | ), 178 | }) 179 | } 180 | } 181 | 182 | return fullBorder 183 | } 184 | -------------------------------------------------------------------------------- /src/builder/content-mask.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * When there is border radius, the content area should be clipped by the 3 | * inner path of border + padding. This applies to element as well as any 4 | * child element inside a `overflow: hidden` container. 5 | */ 6 | 7 | import { buildXMLString } from '../utils' 8 | import border from './border' 9 | 10 | export default function contentMask( 11 | { 12 | id, 13 | left, 14 | top, 15 | width, 16 | height, 17 | matrix, 18 | borderOnly, 19 | }: { 20 | id: string 21 | left: number 22 | top: number 23 | width: number 24 | height: number 25 | matrix: string | undefined 26 | borderOnly?: boolean 27 | }, 28 | style: Record 29 | ) { 30 | const offsetLeft = 31 | ((style.borderLeftWidth as number) || 0) + 32 | (borderOnly ? 0 : (style.paddingLeft as number) || 0) 33 | const offsetTop = 34 | ((style.borderTopWidth as number) || 0) + 35 | (borderOnly ? 0 : (style.paddingTop as number) || 0) 36 | const offsetRight = 37 | ((style.borderRightWidth as number) || 0) + 38 | (borderOnly ? 0 : (style.paddingRight as number) || 0) 39 | const offsetBottom = 40 | ((style.borderBottomWidth as number) || 0) + 41 | (borderOnly ? 0 : (style.paddingBottom as number) || 0) 42 | 43 | const contentArea = { 44 | x: left + offsetLeft, 45 | y: top + offsetTop, 46 | width: width - offsetLeft - offsetRight, 47 | height: height - offsetTop - offsetBottom, 48 | } 49 | 50 | const contentMask = buildXMLString( 51 | 'mask', 52 | { id }, 53 | buildXMLString('rect', { 54 | ...contentArea, 55 | fill: '#fff', 56 | mask: style._inheritedMaskId 57 | ? `url(#${style._inheritedMaskId})` 58 | : undefined, 59 | }) + 60 | border( 61 | { 62 | left, 63 | top, 64 | width, 65 | height, 66 | props: { 67 | transform: matrix ? matrix : undefined, 68 | }, 69 | asContentMask: true, 70 | maskBorderOnly: borderOnly, 71 | }, 72 | style 73 | ) 74 | ) 75 | 76 | return contentMask 77 | } 78 | -------------------------------------------------------------------------------- /src/builder/image.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedTransformOrigin } from '../transform-origin' 2 | 3 | import { buildXMLString } from '../utils' 4 | import border, { getBorderClipPath } from './border' 5 | import radius from './border-radius' 6 | import mask from './content-mask' 7 | import { boxShadow } from './shadow' 8 | import transform from './transform' 9 | 10 | export default function image( 11 | { 12 | id, 13 | left, 14 | top, 15 | width, 16 | height, 17 | src, 18 | debug: _debug, 19 | isInheritingTransform, 20 | }: { 21 | id: string 22 | left: number 23 | top: number 24 | width: number 25 | height: number 26 | src: string 27 | isInheritingTransform: boolean 28 | debug?: boolean 29 | }, 30 | style: Record 31 | ) { 32 | if (style.display === 'none') return '' 33 | 34 | let contentMaskId = '' 35 | let contentMask = '' 36 | let clip = '' 37 | let opacity = 1 38 | let matrix = '' 39 | let defs = '' 40 | let borderShape = '' 41 | 42 | if (style.transform) { 43 | matrix = transform( 44 | { 45 | left, 46 | top, 47 | width, 48 | height, 49 | }, 50 | style.transform as unknown as number[], 51 | isInheritingTransform, 52 | style.transformOrigin as ParsedTransformOrigin | undefined 53 | ) 54 | } 55 | 56 | const preserveAspectRatio = 57 | style.objectFit === 'contain' 58 | ? 'xMidYMid' 59 | : style.objectFit === 'cover' 60 | ? 'xMidYMid slice' 61 | : 'none' 62 | 63 | const path = radius( 64 | { left, top, width, height }, 65 | style as Record 66 | ) 67 | 68 | const clipPathId = style._inheritedClipPathId as number | undefined 69 | const overflowMaskId = style._inheritedMaskId as number | undefined 70 | 71 | if (path) { 72 | clip = buildXMLString( 73 | 'clipPath', 74 | { 75 | id: `satori_c-${id}`, 76 | 'clip-path': clipPathId ? `url(#${clipPathId})` : undefined, 77 | }, 78 | buildXMLString('path', { 79 | x: left, 80 | y: top, 81 | width, 82 | height, 83 | d: path, 84 | }) 85 | ) 86 | } 87 | 88 | const borderClip = getBorderClipPath( 89 | { 90 | id, 91 | left, 92 | top, 93 | width, 94 | height, 95 | currentClipPathId: clipPathId, 96 | borderPath: path, 97 | borderType: path ? 'path' : 'rect', 98 | }, 99 | style 100 | ) 101 | 102 | if (borderClip) { 103 | defs += borderClip[0] 104 | const rectClipId = borderClip[1] 105 | 106 | borderShape += border( 107 | { 108 | left, 109 | top, 110 | width, 111 | height, 112 | props: { 113 | transform: matrix ? matrix : undefined, 114 | // When using `background-clip: text`, we need to draw the extra border because 115 | // it shouldn't be clipped by the clip path, so we are not using currentClipPath here. 116 | 'clip-path': `url(#${rectClipId})`, 117 | mask: overflowMaskId ? `url(#${overflowMaskId})` : undefined, 118 | }, 119 | }, 120 | style 121 | ) 122 | } 123 | 124 | if (style.opacity) { 125 | opacity = +style.opacity 126 | } 127 | 128 | const shadow = boxShadow( 129 | { 130 | width, 131 | height, 132 | id, 133 | opacity, 134 | shape: buildXMLString(path ? 'path' : 'rect', { 135 | x: left, 136 | y: top, 137 | width, 138 | height, 139 | fill: '#fff', 140 | d: path ? path : undefined, 141 | transform: matrix ? matrix : undefined, 142 | 'clip-path': clipPathId ? `url(#${clipPathId})` : undefined, 143 | mask: overflowMaskId ? `url(#${overflowMaskId})` : undefined, 144 | }), 145 | }, 146 | style 147 | ) 148 | 149 | // If there is any border radius, we need to mask the content. 150 | if (path) { 151 | contentMaskId = `satori_cm-${id}` 152 | contentMask = mask( 153 | { 154 | id: `satori_cm-${id}`, 155 | left, 156 | top, 157 | width, 158 | height, 159 | matrix, 160 | }, 161 | style 162 | ) 163 | } 164 | 165 | // We need to subtract the border and padding sizes from the image size. 166 | const offsetLeft = 167 | ((style.borderLeftWidth as number) || 0) + 168 | ((style.paddingLeft as number) || 0) 169 | const offsetTop = 170 | ((style.borderTopWidth as number) || 0) + 171 | ((style.paddingTop as number) || 0) 172 | const offsetRight = 173 | ((style.borderRightWidth as number) || 0) + 174 | ((style.paddingRight as number) || 0) 175 | const offsetBottom = 176 | ((style.borderBottomWidth as number) || 0) + 177 | ((style.paddingBottom as number) || 0) 178 | 179 | return ( 180 | (defs ? buildXMLString('defs', {}, defs) : '') + 181 | contentMask + 182 | (shadow ? shadow[0] : '') + 183 | clip + 184 | buildXMLString('image', { 185 | x: left + offsetLeft, 186 | y: top + offsetTop, 187 | width: width - offsetLeft - offsetRight, 188 | height: height - offsetTop - offsetBottom, 189 | href: src, 190 | preserveAspectRatio, 191 | transform: matrix ? matrix : undefined, 192 | 'clip-path': clip 193 | ? `url(#satori_c-${id})` 194 | : clipPathId 195 | ? `url(#${clipPathId})` 196 | : undefined, 197 | mask: contentMaskId ? `url(#${contentMaskId})` : undefined, 198 | }) + 199 | (shadow ? shadow[1] : '') + 200 | borderShape 201 | ) 202 | } 203 | -------------------------------------------------------------------------------- /src/builder/overflow.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate clip path for the given element. 3 | */ 4 | 5 | import { buildXMLString } from '../utils' 6 | import mask from './content-mask' 7 | 8 | export default function overflow( 9 | { 10 | left, 11 | top, 12 | width, 13 | height, 14 | path, 15 | matrix, 16 | id, 17 | }: { 18 | left: number 19 | top: number 20 | width: number 21 | height: number 22 | path: string 23 | matrix: string | undefined 24 | id: string 25 | }, 26 | style: Record 27 | ) { 28 | if (style.overflow !== 'hidden') { 29 | return '' 30 | } 31 | 32 | const contentMask = mask( 33 | { 34 | id: `satori_om-${id}`, 35 | left, 36 | top, 37 | width, 38 | height, 39 | matrix, 40 | borderOnly: true, 41 | }, 42 | style 43 | ) 44 | 45 | return ( 46 | buildXMLString( 47 | 'clipPath', 48 | { 49 | id: `satori_cp-${id}`, 50 | 'clip-path': style._inheritedClipPathId 51 | ? `url(#${style._inheritedClipPathId})` 52 | : undefined, 53 | }, 54 | buildXMLString(path ? 'path' : 'rect', { 55 | x: left, 56 | y: top, 57 | width, 58 | height, 59 | d: path ? path : undefined, 60 | }) 61 | ) + contentMask 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /src/builder/rect.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedTransformOrigin } from '../transform-origin' 2 | 3 | import backgroundImage from './background-image' 4 | import radius from './border-radius' 5 | import { boxShadow } from './shadow' 6 | import transform from './transform' 7 | import overflow from './overflow' 8 | import { buildXMLString } from '../utils' 9 | import border, { getBorderClipPath } from './border' 10 | 11 | export default async function rect( 12 | { 13 | id, 14 | left, 15 | top, 16 | width, 17 | height, 18 | isInheritingTransform, 19 | debug, 20 | }: { 21 | id: string 22 | left: number 23 | top: number 24 | width: number 25 | height: number 26 | isInheritingTransform: boolean 27 | debug?: boolean 28 | }, 29 | style: Record 30 | ) { 31 | if (style.display === 'none') return '' 32 | 33 | let type: 'rect' | 'path' = 'rect' 34 | let matrix = '' 35 | let defs = '' 36 | let fills: string[] = [] 37 | let opacity = 1 38 | let extra = '' 39 | 40 | if (style.backgroundColor) { 41 | fills.push(style.backgroundColor as string) 42 | } 43 | 44 | if (style.opacity !== undefined) { 45 | opacity = +style.opacity 46 | } 47 | 48 | if (style.transform) { 49 | matrix = transform( 50 | { 51 | left, 52 | top, 53 | width, 54 | height, 55 | }, 56 | style.transform as unknown as number[], 57 | isInheritingTransform, 58 | style.transformOrigin as ParsedTransformOrigin | undefined 59 | ) 60 | } 61 | 62 | let backgroundShapes = '' 63 | if (style.backgroundImage) { 64 | const backgrounds: string[][] = [] 65 | 66 | for ( 67 | let index = 0; 68 | index < (style.backgroundImage as any).length; 69 | index++ 70 | ) { 71 | const background = (style.backgroundImage as any)[index] 72 | const image = await backgroundImage( 73 | { id: id + '_' + index, width, height }, 74 | background 75 | ) 76 | if (image) { 77 | // Background images that come first in the array are rendered last. 78 | backgrounds.unshift(image) 79 | } 80 | } 81 | 82 | for (const background of backgrounds) { 83 | fills.push(`url(#${background[0]})`) 84 | defs += background[1] 85 | if (background[2]) { 86 | backgroundShapes += background[2] 87 | } 88 | } 89 | } 90 | 91 | const path = radius( 92 | { left, top, width, height }, 93 | style as Record 94 | ) 95 | if (path) { 96 | type = 'path' 97 | } 98 | 99 | const clip = overflow( 100 | { left, top, width, height, path, id, matrix }, 101 | style as Record 102 | ) 103 | const clipPathId = style._inheritedClipPathId as number | undefined 104 | const overflowMaskId = style._inheritedMaskId as number | undefined 105 | 106 | if (debug) { 107 | extra = buildXMLString('rect', { 108 | x: left, 109 | y: top, 110 | width, 111 | height, 112 | fill: 'transparent', 113 | stroke: '#ff5757', 114 | 'stroke-width': 1, 115 | transform: matrix || undefined, 116 | 'clip-path': clipPathId ? `url(#${clipPathId})` : undefined, 117 | }) 118 | } 119 | 120 | const { backgroundClip, filter: cssFilter } = style 121 | 122 | const currentClipPath = 123 | backgroundClip === 'text' 124 | ? `url(#satori_bct-${id})` 125 | : clipPathId 126 | ? `url(#${clipPathId})` 127 | : undefined 128 | 129 | // Each background generates a new rectangle. 130 | // @TODO: Not sure if this is the best way to do it, maybe with 131 | // multiple s is better. 132 | let shape = fills 133 | .map((fill) => 134 | buildXMLString(type, { 135 | x: left, 136 | y: top, 137 | width, 138 | height, 139 | fill, 140 | d: path ? path : undefined, 141 | transform: matrix ? matrix : undefined, 142 | 'clip-path': currentClipPath, 143 | style: cssFilter ? `filter:${cssFilter}` : undefined, 144 | mask: overflowMaskId ? `url(#${overflowMaskId})` : undefined, 145 | }) 146 | ) 147 | .join('') 148 | 149 | const borderClip = getBorderClipPath( 150 | { 151 | id, 152 | left, 153 | top, 154 | width, 155 | height, 156 | currentClipPathId: clipPathId, 157 | borderPath: path, 158 | borderType: type, 159 | }, 160 | style 161 | ) 162 | 163 | if (borderClip) { 164 | defs += borderClip[0] 165 | const rectClipId = borderClip[1] 166 | 167 | shape += border( 168 | { 169 | left, 170 | top, 171 | width, 172 | height, 173 | props: { 174 | transform: matrix ? matrix : undefined, 175 | // When using `background-clip: text`, we need to draw the extra border because 176 | // it shouldn't be clipped by the clip path, so we are not using currentClipPath here. 177 | 'clip-path': `url(#${rectClipId})`, 178 | }, 179 | }, 180 | style 181 | ) 182 | } 183 | 184 | // box-shadow. 185 | const shadow = boxShadow( 186 | { 187 | width, 188 | height, 189 | id, 190 | opacity, 191 | shape: buildXMLString(type, { 192 | x: left, 193 | y: top, 194 | width, 195 | height, 196 | fill: '#fff', 197 | stroke: '#fff', 198 | 'stroke-width': 0, 199 | d: path ? path : undefined, 200 | transform: matrix ? matrix : undefined, 201 | 'clip-path': currentClipPath, 202 | mask: overflowMaskId ? `url(#${overflowMaskId})` : undefined, 203 | }), 204 | }, 205 | style 206 | ) 207 | 208 | return ( 209 | (defs ? buildXMLString('defs', {}, defs) : '') + 210 | (shadow ? shadow[0] : '') + 211 | clip + 212 | (opacity !== 1 ? `` : '') + 213 | (backgroundShapes || shape) + 214 | (opacity !== 1 ? `` : '') + 215 | (shadow ? shadow[1] : '') + 216 | extra 217 | ) 218 | } 219 | -------------------------------------------------------------------------------- /src/builder/shadow.ts: -------------------------------------------------------------------------------- 1 | // @TODO: It seems that SVG filters are pretty expensive for resvg, PNG 2 | // generation time 10x'd when adding this filter (WASM in browser). 3 | // https://drafts.fxtf.org/filter-effects/#feGaussianBlurElement 4 | 5 | import { buildXMLString } from '../utils' 6 | 7 | function shiftPath(path: string, dx: number, dy: number) { 8 | return path.replace( 9 | /([MA])([0-9.-]+),([0-9.-]+)/g, 10 | function (_, command, x, y) { 11 | return command + (parseFloat(x) + dx) + ',' + (parseFloat(y) + dy) 12 | } 13 | ) 14 | } 15 | 16 | export function dropShadow( 17 | { id, width, height }: { id: string; width: number; height: number }, 18 | style: Record 19 | ) { 20 | if ( 21 | !style.shadowColor || 22 | !style.shadowOffset || 23 | typeof style.shadowRadius === 'undefined' 24 | ) { 25 | return '' 26 | } 27 | 28 | // Expand the area for the filter to prevent it from cutting off. 29 | const grow = (style.shadowRadius * style.shadowRadius) / 4 30 | 31 | const left = Math.min(style.shadowOffset.width - grow, 0) 32 | const right = Math.max(style.shadowOffset.width + grow + width, width) 33 | const top = Math.min(style.shadowOffset.height - grow, 0) 34 | const bottom = Math.max(style.shadowOffset.height + grow + height, height) 35 | 36 | return `` 50 | } 51 | 52 | export function boxShadow( 53 | { 54 | width, 55 | height, 56 | shape, 57 | opacity, 58 | id, 59 | }: { 60 | width: number 61 | height: number 62 | shape: string 63 | opacity: number 64 | id: string 65 | }, 66 | style: Record 67 | ) { 68 | if (!style.boxShadow) return null 69 | 70 | let shadow = '' 71 | let innerShadow = '' 72 | 73 | for (let i = style.boxShadow.length - 1; i >= 0; i--) { 74 | let s = '' 75 | 76 | const shadowStyle = style.boxShadow[i] 77 | 78 | if (shadowStyle.spreadRadius && shadowStyle.inset) { 79 | shadowStyle.spreadRadius = -shadowStyle.spreadRadius 80 | } 81 | 82 | // Expand the area for the filter to prevent it from cutting off. 83 | const grow = 84 | (shadowStyle.blurRadius * shadowStyle.blurRadius) / 4 + 85 | (shadowStyle.spreadRadius || 0) 86 | 87 | const left = Math.min( 88 | -grow - (shadowStyle.inset ? shadowStyle.offsetX : 0), 89 | 0 90 | ) 91 | const right = Math.max( 92 | grow + width - (shadowStyle.inset ? shadowStyle.offsetX : 0), 93 | width 94 | ) 95 | const top = Math.min( 96 | -grow - (shadowStyle.inset ? shadowStyle.offsetY : 0), 97 | 0 98 | ) 99 | const bottom = Math.max( 100 | grow + height - (shadowStyle.inset ? shadowStyle.offsetY : 0), 101 | height 102 | ) 103 | 104 | const sid = `satori_s-${id}-${i}` 105 | const maskId = `satori_ms-${id}-${i}` 106 | const shapeWithSpread = shadowStyle.spreadRadius 107 | ? shape.replace( 108 | 'stroke-width="0"', 109 | `stroke-width="${shadowStyle.spreadRadius * 2}"` 110 | ) 111 | : shape 112 | 113 | s += buildXMLString( 114 | 'mask', 115 | { 116 | id: maskId, 117 | maskUnits: 'userSpaceOnUse', 118 | }, 119 | buildXMLString('rect', { 120 | x: 0, 121 | y: 0, 122 | width: style._viewportWidth, 123 | height: style._viewportHeight, 124 | fill: shadowStyle.inset ? '#000' : '#fff', 125 | }) + 126 | shapeWithSpread 127 | .replace( 128 | 'fill="#fff"', 129 | shadowStyle.inset ? 'fill="#fff"' : 'fill="#000"' 130 | ) 131 | .replace('stroke="#fff"', '') 132 | ) 133 | 134 | let finalShape = shapeWithSpread 135 | .replace(/d="([^"]+)"/, (_, path) => { 136 | return ( 137 | 'd="' + 138 | shiftPath(path, shadowStyle.offsetX, shadowStyle.offsetY) + 139 | '"' 140 | ) 141 | }) 142 | .replace(/x="([^"]+)"/, (_, x) => { 143 | return 'x="' + (parseFloat(x) + shadowStyle.offsetX) + '"' 144 | }) 145 | .replace(/y="([^"]+)"/, (_, y) => { 146 | return 'y="' + (parseFloat(y) + shadowStyle.offsetY) + '"' 147 | }) 148 | 149 | // Negative spread radius, we need another mask here. 150 | if (shadowStyle.spreadRadius && shadowStyle.spreadRadius < 0) { 151 | s += buildXMLString( 152 | 'mask', 153 | { 154 | id: maskId + '-neg', 155 | maskUnits: 'userSpaceOnUse', 156 | }, 157 | finalShape 158 | .replace('stroke="#fff"', 'stroke="#000"') 159 | .replace( 160 | /stroke-width="[^"]+"/, 161 | `stroke-width="${-shadowStyle.spreadRadius * 2}"` 162 | ) 163 | ) 164 | } 165 | 166 | if (shadowStyle.spreadRadius && shadowStyle.spreadRadius < 0) { 167 | finalShape = buildXMLString( 168 | 'g', 169 | { 170 | mask: `url(#${maskId}-neg)`, 171 | }, 172 | finalShape 173 | ) 174 | } 175 | 176 | s += 177 | buildXMLString( 178 | 'defs', 179 | {}, 180 | buildXMLString( 181 | 'filter', 182 | { 183 | id: sid, 184 | x: `${(left / width) * 100}%`, 185 | y: `${(top / height) * 100}%`, 186 | width: `${((right - left) / width) * 100}%`, 187 | height: `${((bottom - top) / height) * 100}%`, 188 | }, 189 | buildXMLString('feGaussianBlur', { 190 | // According to the spec, we use the half of the blur radius as the standard 191 | // deviation for the filter. 192 | // > the image that would be generated by applying to the shadow a Gaussian 193 | // > blur with a standard deviation equal to half the blur radius 194 | // > https://www.w3.org/TR/css-backgrounds-3/#shadow-blur 195 | stdDeviation: shadowStyle.blurRadius / 2, 196 | result: 'b', 197 | }) + 198 | buildXMLString('feFlood', { 199 | 'flood-color': shadowStyle.color, 200 | in: 'SourceGraphic', 201 | result: 'f', 202 | }) + 203 | buildXMLString('feComposite', { 204 | in: 'f', 205 | in2: 'b', 206 | operator: shadowStyle.inset ? 'out' : 'in', 207 | }) 208 | ) 209 | ) + 210 | buildXMLString( 211 | 'g', 212 | { 213 | mask: `url(#${maskId})`, 214 | filter: `url(#${sid})`, 215 | opacity: opacity, 216 | }, 217 | finalShape 218 | ) 219 | 220 | if (shadowStyle.inset) { 221 | innerShadow += s 222 | } else { 223 | shadow += s 224 | } 225 | } 226 | 227 | return [shadow, innerShadow] 228 | } 229 | -------------------------------------------------------------------------------- /src/builder/svg.ts: -------------------------------------------------------------------------------- 1 | import { buildXMLString } from '../utils' 2 | 3 | export default function svg({ 4 | width, 5 | height, 6 | content, 7 | }: { 8 | width: number 9 | height: number 10 | content: string 11 | }) { 12 | return buildXMLString( 13 | 'svg', 14 | { 15 | width, 16 | height, 17 | viewBox: `0 0 ${width} ${height}`, 18 | xmlns: 'http://www.w3.org/2000/svg', 19 | }, 20 | content 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/builder/text-decoration.ts: -------------------------------------------------------------------------------- 1 | import { buildXMLString } from '../utils' 2 | 3 | export default function decoration( 4 | { 5 | width, 6 | left, 7 | top, 8 | ascender, 9 | clipPathId, 10 | }: { 11 | width: number 12 | left: number 13 | top: number 14 | ascender: number 15 | clipPathId?: string 16 | }, 17 | style: Record 18 | ) { 19 | const { 20 | textDecorationColor, 21 | textDecorationStyle, 22 | textDecorationLine, 23 | fontSize, 24 | } = style 25 | if (!textDecorationLine || textDecorationLine === 'none') return '' 26 | 27 | // The UA should use such font-based information when choosing auto line thicknesses wherever appropriate. 28 | // https://drafts.csswg.org/css-text-decor-4/#text-decoration-thickness 29 | const height = Math.max(1, fontSize * 0.1) 30 | 31 | const y = 32 | textDecorationLine === 'line-through' 33 | ? top + ascender * 0.5 34 | : textDecorationLine === 'underline' 35 | ? top + ascender * 1.1 36 | : top 37 | 38 | const dasharray = 39 | textDecorationStyle === 'dashed' 40 | ? `${height * 1.2} ${height * 2}` 41 | : textDecorationStyle === 'dotted' 42 | ? `0 ${height * 2}` 43 | : undefined 44 | 45 | return buildXMLString('line', { 46 | x1: left, 47 | y1: y, 48 | x2: left + width, 49 | y2: y, 50 | stroke: textDecorationColor, 51 | 'stroke-width': height, 52 | 'stroke-dasharray': dasharray, 53 | 'stroke-linecap': textDecorationStyle === 'dotted' ? 'round' : 'square', 54 | 'clip-path': clipPathId ? `url(#${clipPathId})` : undefined, 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /src/builder/text.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedTransformOrigin } from '../transform-origin' 2 | import transform from './transform' 3 | import { buildXMLString } from '../utils' 4 | 5 | export function container( 6 | { 7 | left, 8 | top, 9 | width, 10 | height, 11 | isInheritingTransform, 12 | }: { 13 | left: number 14 | top: number 15 | width: number 16 | height: number 17 | isInheritingTransform: boolean 18 | }, 19 | style: Record 20 | ) { 21 | let matrix = '' 22 | let opacity = 1 23 | 24 | if (style.transform) { 25 | matrix = transform( 26 | { 27 | left, 28 | top, 29 | width, 30 | height, 31 | }, 32 | style.transform as unknown as number[], 33 | isInheritingTransform, 34 | style.transformOrigin as ParsedTransformOrigin | undefined 35 | ) 36 | } 37 | 38 | if (style.opacity !== undefined) { 39 | opacity = +style.opacity 40 | } 41 | 42 | return { matrix, opacity } 43 | } 44 | 45 | export default function text( 46 | { 47 | id, 48 | content, 49 | filter, 50 | left, 51 | top, 52 | width, 53 | height, 54 | matrix, 55 | opacity, 56 | image, 57 | clipPathId, 58 | debug, 59 | shape, 60 | decorationShape, 61 | }: { 62 | content: string 63 | filter: string 64 | id: string 65 | left: number 66 | top: number 67 | width: number 68 | height: number 69 | matrix: string 70 | opacity: number 71 | image: string | null 72 | clipPathId?: string 73 | debug?: boolean 74 | shape?: boolean 75 | decorationShape?: string 76 | }, 77 | style: Record 78 | ) { 79 | let extra = '' 80 | if (debug) { 81 | extra = buildXMLString('rect', { 82 | x: left, 83 | y: top - height, 84 | width, 85 | height, 86 | fill: 'transparent', 87 | stroke: '#575eff', 88 | 'stroke-width': 1, 89 | transform: matrix || undefined, 90 | 'clip-path': clipPathId ? `url(#${clipPathId})` : undefined, 91 | }) 92 | } 93 | 94 | // This grapheme should be rendered as an image. 95 | if (image) { 96 | const shapeProps = { 97 | href: image, 98 | x: left, 99 | y: top, 100 | width, 101 | height, 102 | transform: matrix || undefined, 103 | 'clip-path': clipPathId ? `url(#${clipPathId})` : undefined, 104 | style: style.filter ? `filter:${style.filter}` : undefined, 105 | } 106 | return [ 107 | (filter ? `${filter}` : '') + 108 | buildXMLString('image', { 109 | ...shapeProps, 110 | opacity: opacity !== 1 ? opacity : undefined, 111 | }) + 112 | (decorationShape || '') + 113 | (filter ? '' : '') + 114 | extra, 115 | // SVG doesn't support `` as the shape. 116 | '', 117 | ] 118 | } 119 | 120 | // Do not embed the font, use with the raw content instead. 121 | const shapeProps = { 122 | x: left, 123 | y: top, 124 | width, 125 | height, 126 | 'font-weight': style.fontWeight, 127 | 'font-style': style.fontStyle, 128 | 'font-size': style.fontSize, 129 | 'font-family': style.fontFamily, 130 | 'letter-spacing': style.letterSpacing || undefined, 131 | transform: matrix || undefined, 132 | 'clip-path': clipPathId ? `url(#${clipPathId})` : undefined, 133 | style: style.filter ? `filter:${style.filter}` : undefined, 134 | } 135 | return [ 136 | (filter ? `${filter}` : '') + 137 | buildXMLString( 138 | 'text', 139 | { 140 | ...shapeProps, 141 | fill: style.color, 142 | opacity: opacity !== 1 ? opacity : undefined, 143 | }, 144 | content 145 | ) + 146 | (decorationShape || '') + 147 | (filter ? '' : '') + 148 | extra, 149 | shape ? buildXMLString('text', shapeProps, content) : '', 150 | ] 151 | } 152 | -------------------------------------------------------------------------------- /src/builder/transform.ts: -------------------------------------------------------------------------------- 1 | import { multiply } from '../utils' 2 | import type { ParsedTransformOrigin } from '../transform-origin' 3 | 4 | const baseMatrix = [1, 0, 0, 1, 0, 0] 5 | 6 | // Mutate the array in place. 7 | function resolveTransforms(transforms: any[], width: number, height: number) { 8 | let matrix = [...baseMatrix] 9 | 10 | // Handle CSS transforms To make it easier, we convert different transform 11 | // types directly to a matrix and apply it recursively to all its children. 12 | // Transforms are applied from right to left. 13 | for (const transform of transforms) { 14 | const type = Object.keys(transform)[0] 15 | let v = transform[type] 16 | 17 | // Resolve percentages based on the element's final size. 18 | if (typeof v === 'string') { 19 | if (type === 'translateX') { 20 | v = (parseFloat(v) / 100) * width 21 | // Override the orignal object. 22 | transform[type] = v 23 | } else if (type === 'translateY') { 24 | v = (parseFloat(v) / 100) * height 25 | transform[type] = v 26 | } else { 27 | throw new Error(`Invalid transform: "${type}: ${v}".`) 28 | } 29 | } 30 | 31 | let len = v as number 32 | 33 | const transformMatrix = [...baseMatrix] 34 | switch (type) { 35 | case 'translateX': 36 | transformMatrix[4] = len 37 | break 38 | case 'translateY': 39 | transformMatrix[5] = len 40 | break 41 | case 'scale': 42 | transformMatrix[0] = len 43 | transformMatrix[3] = len 44 | break 45 | case 'scaleX': 46 | transformMatrix[0] = len 47 | break 48 | case 'scaleY': 49 | transformMatrix[3] = len 50 | break 51 | case 'rotate': 52 | const rad = (len * Math.PI) / 180 53 | const c = Math.cos(rad) 54 | const s = Math.sin(rad) 55 | transformMatrix[0] = c 56 | transformMatrix[1] = s 57 | transformMatrix[2] = -s 58 | transformMatrix[3] = c 59 | break 60 | case 'skewX': 61 | transformMatrix[2] = Math.tan((len * Math.PI) / 180) 62 | break 63 | case 'skewY': 64 | transformMatrix[1] = Math.tan((len * Math.PI) / 180) 65 | break 66 | } 67 | matrix = multiply(transformMatrix, matrix) 68 | } 69 | 70 | transforms.splice(0, transforms.length) 71 | transforms.push(...matrix) 72 | ;(transforms as any).__resolved = true 73 | } 74 | 75 | export default function transform( 76 | { 77 | left, 78 | top, 79 | width, 80 | height, 81 | }: { 82 | left: number 83 | top: number 84 | width: number 85 | height: number 86 | }, 87 | transforms: number[], 88 | isInheritingTransform: boolean, 89 | transformOrigin?: ParsedTransformOrigin 90 | ) { 91 | let result: number[] 92 | 93 | if (!(transforms as any).__resolved) { 94 | resolveTransforms(transforms, width, height) 95 | } 96 | 97 | let matrix = transforms 98 | 99 | // Calculate the transform origin. 100 | if (isInheritingTransform) { 101 | result = matrix 102 | } else { 103 | const xOrigin = 104 | transformOrigin?.xAbsolute ?? 105 | ((transformOrigin?.xRelative ?? 50) * width) / 100 106 | const yOrigin = 107 | transformOrigin?.yAbsolute ?? 108 | ((transformOrigin?.yRelative ?? 50) * height) / 100 109 | 110 | // If this element is the transform target, we attach the origin coordinates 111 | // to this matrix. 112 | const x = left + xOrigin 113 | const y = top + yOrigin 114 | 115 | // Due to the different coordinate systems, we need to move the shape to the 116 | // origin first, then apply the matrix, then move it back. 117 | result = multiply( 118 | [1, 0, 0, 1, x, y], 119 | multiply(matrix, [1, 0, 0, 1, -x, -y]) 120 | ) 121 | 122 | // And we need to apply its parent transform if it has one. 123 | if ((matrix as any).__parent) { 124 | result = multiply((matrix as any).__parent, result) 125 | } 126 | 127 | // Mutate self. 128 | matrix.splice(0, 6, ...result) 129 | } 130 | 131 | return `matrix(${result.map((v) => v.toFixed(2)).join(',')})` 132 | } 133 | -------------------------------------------------------------------------------- /src/handler/image.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module is used to fetch image from the given URL and resolve it as 3 | * base64 inlined data URI, so the toolchain can process it. 4 | * The image data will be cached in a LRU so we don't need to fetch it again 5 | * in new render processes. But to invalidate the cache the workaround is to 6 | * add a random query string to the URL. 7 | * TODO: We might want another option to disable image caching by default. 8 | */ 9 | 10 | function parseJPEG(buf: ArrayBuffer) { 11 | const view = new DataView(buf) 12 | 13 | // Skip magic bytes 14 | let offset = 4 15 | 16 | const len = view.byteLength 17 | while (offset < len) { 18 | const i = view.getUint16(offset, false) 19 | 20 | if (i > len) { 21 | throw new TypeError('Invalid JPEG') 22 | } 23 | 24 | const next = view.getUint8(i + 1 + offset) 25 | if (next === 0xc0 || next === 0xc1 || next === 0xc2) { 26 | return [ 27 | view.getUint16(i + 7 + offset, false), 28 | view.getUint16(i + 5 + offset, false), 29 | ] as [number, number] 30 | } 31 | 32 | // TODO: Support orientations from EXIF. 33 | 34 | offset += i + 2 35 | } 36 | 37 | throw new TypeError('Invalid JPEG') 38 | } 39 | 40 | function parseGIF(buf: ArrayBuffer) { 41 | const view = new Uint8Array(buf.slice(6, 10)) 42 | return [view[0] | (view[1] << 8), view[2] | (view[3] << 8)] as [ 43 | number, 44 | number 45 | ] 46 | } 47 | 48 | function parsePNG(buf: ArrayBuffer) { 49 | const v = new DataView(buf) 50 | return [v.getUint16(18, false), v.getUint16(22, false)] as [number, number] 51 | } 52 | 53 | import { createLRU } from '../utils' 54 | 55 | type ResolvedImageData = [string, number?, number?] 56 | const cache = createLRU(100) 57 | const inflightRequests = new Map>() 58 | 59 | const ALLOWED_IMAGE_TYPES = [ 60 | 'image/png', 61 | 'image/jpeg', 62 | 'image/gif', 63 | 'image/svg+xml', 64 | ] 65 | 66 | function arrayBufferToBase64(buffer) { 67 | let binary = '' 68 | const bytes = new Uint8Array(buffer) 69 | for (let i = 0; i < bytes.byteLength; i++) { 70 | binary += String.fromCharCode(bytes[i]) 71 | } 72 | return btoa(binary) 73 | } 74 | 75 | export async function resolveImageData( 76 | src: string 77 | ): Promise { 78 | if (!src) { 79 | throw new Error('Image source is not provided.') 80 | } 81 | 82 | if (src.startsWith('data:')) { 83 | return [src] 84 | } 85 | 86 | if (!globalThis.fetch) { 87 | throw new Error('`fetch` is required to be polyfilled to load images.') 88 | } 89 | 90 | if (inflightRequests.has(src)) { 91 | return inflightRequests.get(src) 92 | } 93 | const cached = cache.get(src) 94 | if (cached) { 95 | return cached 96 | } 97 | 98 | const promise = new Promise((resolve, reject) => { 99 | fetch(src) 100 | .then((res): Promise => { 101 | const type = res.headers.get('content-type') 102 | 103 | // Handle SVG specially 104 | if (type === 'image/svg+xml' || type === 'application/svg+xml') { 105 | return res.text() 106 | } 107 | 108 | return res.arrayBuffer() 109 | }) 110 | .then((data) => { 111 | if (typeof data === 'string') { 112 | try { 113 | const newSrc = `data:image/svg+xml;base64,${btoa(data)}` 114 | // Parse the SVG image size 115 | const svgTag = data.match(/]*>/)[0] 116 | 117 | let viewBox = svgTag.match(/viewBox="0 0 (\d+) (\d+)"/) 118 | const width = svgTag.match(/width="(\d+)"/) 119 | const height = svgTag.match(/height="(\d+)"/) 120 | if (!viewBox && width && height) { 121 | viewBox = [null, width[1], height[1]] 122 | } 123 | 124 | const ratio = +viewBox[1] / +viewBox[2] 125 | const imageSize: [number, number] = 126 | width && height 127 | ? [+width[1], +height[1]] 128 | : width 129 | ? [+width[1], +width[1] / ratio] 130 | : height 131 | ? [+height[1] * ratio, +height[1]] 132 | : [+viewBox[1], +viewBox[2]] 133 | 134 | cache.set(src, [newSrc, ...imageSize]) 135 | resolve([newSrc, ...imageSize]) 136 | return 137 | } catch (e) { 138 | throw new Error(`Failed to parse SVG image: ${e.message}`) 139 | } 140 | } 141 | 142 | let imageType: string 143 | let imageSize: [number, number] 144 | 145 | const magicBytes = new Uint8Array(data.slice(0, 4)) 146 | const magicString = [...magicBytes] 147 | .map((byte) => byte.toString(16)) 148 | .join('') 149 | switch (magicString) { 150 | case '89504e47': 151 | imageType = 'image/png' 152 | imageSize = parsePNG(data) 153 | break 154 | case '47494638': 155 | imageType = 'image/gif' 156 | imageSize = parseGIF(data) 157 | break 158 | case 'ffd8ffe0': 159 | case 'ffd8ffe1': 160 | case 'ffd8ffe2': 161 | case 'ffd8ffe3': 162 | case 'ffd8ffe8': 163 | case 'ffd8ffed': 164 | case 'ffd8ffdb': 165 | imageType = 'image/jpeg' 166 | imageSize = parseJPEG(data) 167 | break 168 | } 169 | 170 | if (!ALLOWED_IMAGE_TYPES.includes(imageType)) { 171 | throw new Error(`Unsupported image type: ${imageType || 'unknown'}`) 172 | } 173 | const newSrc = `data:${imageType};base64,${arrayBufferToBase64(data)}` 174 | cache.set(src, [newSrc, ...imageSize]) 175 | resolve([newSrc, ...imageSize]) 176 | }) 177 | .catch((err) => { 178 | reject(new Error(`Can't load image ${src}: ` + err.message)) 179 | }) 180 | }) 181 | inflightRequests.set(src, promise) 182 | return promise 183 | } 184 | -------------------------------------------------------------------------------- /src/handler/inheritable.ts: -------------------------------------------------------------------------------- 1 | const list = new Set([ 2 | 'color', 3 | 'font', 4 | 'fontFamily', 5 | 'fontSize', 6 | 'fontStyle', 7 | 'fontWeight', 8 | 'letterSpacing', 9 | 'lineHeight', 10 | 'textAlign', 11 | 'textTransform', 12 | 'textShadowOffset', 13 | 'textShadowColor', 14 | 'textShadowRadius', 15 | 'textDecorationLine', 16 | 'textDecorationStyle', 17 | 'textDecorationColor', 18 | 'whiteSpace', 19 | 'transform', 20 | 'wordBreak', 21 | 22 | // Special case: SVG doesn't apply these to children elements so we need to 23 | // make it inheritable here. 24 | 'opacity', 25 | 'filter', 26 | 27 | // Special properties of Satori: 28 | '_viewportWidth', 29 | '_viewportHeight', 30 | '_inheritedClipPathId', 31 | '_inheritedMaskId', 32 | '_inheritedBackgroundClipTextPath', 33 | ]) 34 | 35 | export default function inheritable(style: Record) { 36 | const inheritedStyle: Record = {} 37 | for (const prop in style) { 38 | if (list.has(prop)) { 39 | inheritedStyle[prop] = style[prop] 40 | } 41 | } 42 | return inheritedStyle 43 | } 44 | -------------------------------------------------------------------------------- /src/handler/presets.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Pre-defined styles for elements. Here we hand pick some from Chromium's 3 | * default styles: 4 | * https://chromium.googlesource.com/chromium/blink/+/master/Source/core/css/html.css 5 | * 6 | * We try to only include commonly used, styling elements rather than senmantic elements. 7 | */ 8 | 9 | const DEFAULT_DISPLAY = 'flex' 10 | 11 | export default { 12 | // Generic block-level elements 13 | p: { 14 | display: DEFAULT_DISPLAY, 15 | marginTop: '1em', 16 | marginBottom: '1em', 17 | }, 18 | div: { 19 | display: DEFAULT_DISPLAY, 20 | }, 21 | blockquote: { 22 | display: DEFAULT_DISPLAY, 23 | marginTop: '1em', 24 | marginBottom: '1em', 25 | marginLeft: 40, 26 | marginRight: 40, 27 | }, 28 | center: { 29 | display: DEFAULT_DISPLAY, 30 | textAlign: 'center', 31 | }, 32 | hr: { 33 | display: DEFAULT_DISPLAY, 34 | marginTop: '0.5em', 35 | marginBottom: '0.5em', 36 | marginLeft: 'auto', 37 | marginRight: 'auto', 38 | borderWidth: 1, 39 | // We don't have `inset` 40 | borderStyle: 'solid', 41 | }, 42 | // Heading elements 43 | h1: { 44 | display: DEFAULT_DISPLAY, 45 | fontSize: '2em', 46 | marginTop: '0.67em', 47 | marginBottom: '0.67em', 48 | marginLeft: 0, 49 | marginRight: 0, 50 | fontWeight: 'bold', 51 | }, 52 | h2: { 53 | display: DEFAULT_DISPLAY, 54 | fontSize: '1.5em', 55 | marginTop: '0.83em', 56 | marginBottom: '0.83em', 57 | marginLeft: 0, 58 | marginRight: 0, 59 | fontWeight: 'bold', 60 | }, 61 | h3: { 62 | display: DEFAULT_DISPLAY, 63 | fontSize: '1.17em', 64 | marginTop: '1em', 65 | marginBottom: '1em', 66 | marginLeft: 0, 67 | marginRight: 0, 68 | fontWeight: 'bold', 69 | }, 70 | h4: { 71 | display: DEFAULT_DISPLAY, 72 | marginTop: '1.33em', 73 | marginBottom: '1.33em', 74 | marginLeft: 0, 75 | marginRight: 0, 76 | fontWeight: 'bold', 77 | }, 78 | h5: { 79 | display: DEFAULT_DISPLAY, 80 | fontSize: '0.83em', 81 | marginTop: '1.67em', 82 | marginBottom: '1.67em', 83 | marginLeft: 0, 84 | marginRight: 0, 85 | fontWeight: 'bold', 86 | }, 87 | h6: { 88 | display: DEFAULT_DISPLAY, 89 | fontSize: '0.67em', 90 | marginTop: '2.33em', 91 | marginBottom: '2.33em', 92 | marginLeft: 0, 93 | marginRight: 0, 94 | fontWeight: 'bold', 95 | }, 96 | // Tables 97 | // Lists 98 | // Form elements 99 | // Inline elements 100 | u: { 101 | textDecoration: 'underline', 102 | }, 103 | strong: { 104 | fontWeight: 'bold', 105 | }, 106 | b: { 107 | fontWeight: 'bold', 108 | }, 109 | i: { 110 | fontStyle: 'italic', 111 | }, 112 | em: { 113 | fontStyle: 'italic', 114 | }, 115 | code: { 116 | fontFamily: 'monospace', 117 | }, 118 | kbd: { 119 | fontFamily: 'monospace', 120 | }, 121 | pre: { 122 | display: DEFAULT_DISPLAY, 123 | fontFamily: 'monospace', 124 | whiteSpace: 'pre', 125 | marginTop: '1em', 126 | marginBottom: '1em', 127 | }, 128 | mark: { 129 | backgroundColor: 'yellow', 130 | color: 'black', 131 | }, 132 | big: { 133 | fontSize: 'larger', 134 | }, 135 | small: { 136 | fontSize: 'smaller', 137 | }, 138 | s: { 139 | textDecoration: 'line-through', 140 | }, 141 | } 142 | -------------------------------------------------------------------------------- /src/handler/tailwind.ts: -------------------------------------------------------------------------------- 1 | import * as twrnc from 'twrnc/create' 2 | 3 | const config = { 4 | plugins: [ 5 | { 6 | handler: ({ addUtilities }) => { 7 | const presets = { 8 | 'shadow-sm': { boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)' }, 9 | shadow: { 10 | boxShadow: 11 | '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', 12 | }, 13 | 'shadow-md': { 14 | boxShadow: 15 | '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', 16 | }, 17 | 'shadow-lg': { 18 | boxShadow: 19 | '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', 20 | }, 21 | 'shadow-xl': { 22 | boxShadow: 23 | '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)', 24 | }, 25 | 'shadow-2xl': { 26 | boxShadow: '0 25px 50px -12px rgb(0 0 0 / 0.25)', 27 | }, 28 | 'shadow-inner': { 29 | boxShadow: 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)', 30 | }, 31 | 'shadow-none': { boxShadow: '0 0 #0000' }, 32 | } 33 | 34 | addUtilities(presets) 35 | }, 36 | }, 37 | ], 38 | } 39 | 40 | function createTw() { 41 | return twrnc.create(config, 'web') 42 | } 43 | 44 | let tw 45 | export default function getTw({ 46 | width, 47 | height, 48 | }: { 49 | width: number 50 | height: number 51 | }) { 52 | if (!tw) { 53 | tw = createTw() 54 | } 55 | tw.setWindowDimensions({ width: +width, height: +height }) 56 | return tw 57 | } 58 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './satori' 2 | export { default } from './satori' 3 | -------------------------------------------------------------------------------- /src/language.ts: -------------------------------------------------------------------------------- 1 | // This function guesses the human language (writing system) of the given 2 | // JavaScript string, using the Unicode Alias in extended RegExp. 3 | // 4 | // You can learn more about this in: 5 | // - https://en.wikipedia.org/wiki/Script_(Unicode) 6 | // - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Unicode_Property_Escapes 7 | // - https://unicode.org/reports/tr18/#General_Category_Property 8 | // - https://tc39.es/ecma262/multipage/text-processing.html#table-unicode-script-values 9 | 10 | // Supported languages. The order matters. 11 | // Usually, this is only for "special cases" like CJKV languages as latin 12 | // characters are usually included in the base font, and can be safely fallback 13 | // to the Noto Sans font. A list of special cases we want to support can be 14 | // found here (sort by popularity): 15 | // - https://fonts.google.com/noto/fonts?sort=popularity¬o.query=sans 16 | const code = { 17 | emoji: 18 | // https://stackoverflow.com/a/68146409 19 | /\p{RI}\p{RI}|\p{Emoji}(\p{EMod}+|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?(\u{200D}\p{Emoji}(\p{EMod}+|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?)+|\p{EPres}(\p{EMod}+|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})?|\p{Emoji}(\p{EMod}+|\u{FE0F}\u{20E3}?|[\u{E0020}-\u{E007E}]+\u{E007F})/u, 20 | ja: /\p{scx=Hira}|\p{scx=Kana}|[,;:]/u, 21 | ko: /\p{scx=Hangul}/u, 22 | zh: /\p{scx=Han}/u, 23 | th: /\p{scx=Thai}/u, 24 | bn: /\p{scx=Bengali}/u, 25 | ar: /\p{scx=Arabic}/u, 26 | ta: /\p{scx=Tamil}/u, 27 | ml: /\p{scx=Malayalam}/u, 28 | he: /\p{scx=Hebrew}/u, 29 | te: /\p{scx=Telugu}/u, 30 | devanagari: /\p{scx=Devanagari}/u, 31 | } 32 | 33 | // Here we assume all characters from the passed in "segment" is in the same 34 | // written language. So if the string contains at least one matched character, 35 | // we determine it as the matched language. 36 | export function detectLanguageCode(segment: string): string { 37 | for (const c in code) { 38 | if (code[c].test(segment)) { 39 | return c 40 | } 41 | } 42 | return 'unknown' 43 | } 44 | -------------------------------------------------------------------------------- /src/layout.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module is used to calculate the layout of the current sub-tree. 3 | */ 4 | 5 | import type { ReactNode } from 'react' 6 | import type { YogaNode } from 'yoga-layout' 7 | 8 | import getYoga from './yoga' 9 | import { 10 | isReactElement, 11 | isClass, 12 | buildXMLString, 13 | SVGNodeToImage, 14 | normalizeChildren, 15 | } from './utils' 16 | import handler from './handler' 17 | import FontLoader from './font' 18 | import layoutText from './text' 19 | import rect from './builder/rect' 20 | import image from './builder/image' 21 | 22 | export interface LayoutContext { 23 | id: string 24 | parentStyle: Record 25 | inheritedStyle: Record 26 | isInheritingTransform?: boolean 27 | parent: YogaNode 28 | font: FontLoader 29 | embedFont: boolean 30 | debug?: boolean 31 | graphemeImages?: Record 32 | canLoadAdditionalAssets: boolean 33 | getTwStyles: (tw: string, style: any) => any 34 | } 35 | 36 | export default async function* layout( 37 | element: ReactNode, 38 | context: LayoutContext 39 | ): AsyncGenerator { 40 | const Yoga = getYoga() 41 | const { 42 | id, 43 | inheritedStyle, 44 | parent, 45 | font, 46 | debug, 47 | embedFont = true, 48 | graphemeImages, 49 | canLoadAdditionalAssets, 50 | getTwStyles, 51 | } = context 52 | 53 | // 1. Pre-process the node. 54 | if (element === null || typeof element === 'undefined') { 55 | yield 56 | yield 57 | return '' 58 | } 59 | 60 | // Not a normal element. 61 | if (!isReactElement(element) || typeof element.type === 'function') { 62 | let iter: ReturnType 63 | 64 | if (!isReactElement(element)) { 65 | // Process as text node. 66 | iter = layoutText(String(element), context) 67 | yield (await iter.next()).value as string[] 68 | } else { 69 | if (isClass(element.type as Function)) { 70 | throw new Error('Class component is not supported.') 71 | } 72 | // If it's a custom component, Satori strictly requires it to be pure, 73 | // stateless, and not relying on any React APIs such as hooks or suspense. 74 | // So we can safely evaluate it to render. Otherwise, an error will be 75 | // thrown by React. 76 | iter = layout((element.type as Function)(element.props), context) 77 | yield (await iter.next()).value as string[] 78 | } 79 | 80 | await iter.next() 81 | const offset = yield 82 | return (await iter.next(offset)).value as string 83 | } 84 | 85 | // Process as element. 86 | const { type, props } = element 87 | let { style, children, tw } = props || {} 88 | 89 | // Extend Tailwind styles. 90 | if (tw) { 91 | const twStyles = getTwStyles(tw, style) 92 | style = Object.assign(twStyles, style) 93 | } 94 | 95 | const node = Yoga.Node.create() 96 | parent.insertChild(node, parent.getChildCount()) 97 | 98 | const [computedStyle, newInheritableStyle] = await handler( 99 | node, 100 | type, 101 | inheritedStyle, 102 | style, 103 | props 104 | ) 105 | 106 | // Post-process styles to attach inheritable properties for Satori. 107 | 108 | // If the element is inheriting the parent `transform`, or applying its own. 109 | // This affects the coordinate system. 110 | const isInheritingTransform = 111 | computedStyle.transform === inheritedStyle.transform 112 | if (!isInheritingTransform) { 113 | ;(computedStyle.transform as any).__parent = inheritedStyle.transform 114 | } 115 | 116 | // If the element has `overflow` set to `hidden`, we need to create a clip 117 | // path and use it in all its children. 118 | if (computedStyle.overflow === 'hidden') { 119 | newInheritableStyle._inheritedClipPathId = `satori_cp-${id}` 120 | newInheritableStyle._inheritedMaskId = `satori_om-${id}` 121 | } 122 | 123 | // If the element has `background-clip: text` set, we need to create a clip 124 | // path and use it in all its children. 125 | if (computedStyle.backgroundClip === 'text') { 126 | const mutateRefValue = { value: '' } as any 127 | newInheritableStyle._inheritedBackgroundClipTextPath = mutateRefValue 128 | computedStyle._inheritedBackgroundClipTextPath = mutateRefValue 129 | } 130 | 131 | // 2. Do layout recursively for its children. 132 | const normalizedChildren = normalizeChildren(children) 133 | const iterators: ReturnType[] = [] 134 | 135 | let i = 0 136 | const segmentsMissingFont: string[] = [] 137 | for (const child of normalizedChildren) { 138 | const iter = layout(child, { 139 | id: id + '-' + i++, 140 | parentStyle: computedStyle, 141 | inheritedStyle: newInheritableStyle, 142 | isInheritingTransform: true, 143 | parent: node, 144 | font, 145 | embedFont, 146 | debug, 147 | graphemeImages, 148 | canLoadAdditionalAssets, 149 | getTwStyles, 150 | }) 151 | if (canLoadAdditionalAssets) { 152 | segmentsMissingFont.push(...(((await iter.next()).value as any) || [])) 153 | } else { 154 | await iter.next() 155 | } 156 | iterators.push(iter) 157 | } 158 | yield segmentsMissingFont 159 | for (const iter of iterators) await iter.next() 160 | 161 | // 3. Post-process the node. 162 | const [x, y] = yield 163 | 164 | let { left, top, width, height } = node.getComputedLayout() 165 | 166 | // Attach offset to the current node. 167 | left += x 168 | top += y 169 | 170 | let childrenRenderResult = '' 171 | let baseRenderResult = '' 172 | let depsRenderResult = '' 173 | 174 | // Generate the rendered markup for the current node. 175 | if (type === 'img') { 176 | const src = computedStyle.__src as string 177 | baseRenderResult = image( 178 | { 179 | id, 180 | left, 181 | top, 182 | width, 183 | height, 184 | src, 185 | isInheritingTransform, 186 | debug, 187 | }, 188 | computedStyle 189 | ) 190 | } else if (type === 'svg') { 191 | // When entering a node, we need to convert it to a with the 192 | // SVG data URL embedded. 193 | const src = SVGNodeToImage(element) 194 | baseRenderResult = image( 195 | { 196 | id, 197 | left, 198 | top, 199 | width, 200 | height, 201 | src, 202 | isInheritingTransform, 203 | debug, 204 | }, 205 | computedStyle 206 | ) 207 | } else { 208 | const display = style?.display 209 | if ( 210 | type === 'div' && 211 | children && 212 | typeof children !== 'string' && 213 | display !== 'flex' && 214 | display !== 'none' 215 | ) { 216 | throw new Error( 217 | `Expected
to have explicit "display: flex" or "display: none" if it has more than one child node.` 218 | ) 219 | } 220 | baseRenderResult = await rect( 221 | { id, left, top, width, height, isInheritingTransform, debug }, 222 | computedStyle 223 | ) 224 | } 225 | 226 | // Generate the rendered markup for the children. 227 | for (const iter of iterators) { 228 | childrenRenderResult += (await iter.next([left, top])).value 229 | } 230 | 231 | // An extra pass to generate the special background-clip shape collected from 232 | // children. 233 | if (computedStyle._inheritedBackgroundClipTextPath) { 234 | depsRenderResult += buildXMLString( 235 | 'clipPath', 236 | { 237 | id: `satori_bct-${id}`, 238 | 'clip-path': computedStyle._inheritedClipPathId 239 | ? `url(#${computedStyle._inheritedClipPathId})` 240 | : undefined, 241 | }, 242 | (computedStyle._inheritedBackgroundClipTextPath as any).value 243 | ) 244 | } 245 | 246 | return depsRenderResult + baseRenderResult + childrenRenderResult 247 | } 248 | -------------------------------------------------------------------------------- /src/satori.ts: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react' 2 | 3 | import getYoga, { init } from './yoga' 4 | import layout from './layout' 5 | import FontLoader, { FontOptions } from './font' 6 | import svg from './builder/svg' 7 | import { segment } from './utils' 8 | import { detectLanguageCode } from './language' 9 | import getTw from './handler/tailwind' 10 | 11 | // We don't need to initialize the opentype instances every time. 12 | const fontCache = new WeakMap() 13 | 14 | export interface SatoriOptions { 15 | width: number 16 | height: number 17 | fonts: FontOptions[] 18 | embedFont?: boolean 19 | debug?: boolean 20 | graphemeImages?: Record 21 | loadAdditionalAsset?: ( 22 | languageCode: string, 23 | segment: string 24 | ) => Promise 25 | } 26 | 27 | export { init } 28 | 29 | export default async function satori( 30 | element: ReactNode, 31 | options: SatoriOptions 32 | ): Promise { 33 | const Yoga = getYoga() 34 | if (!Yoga) throw new Error('Satori is not initialized.') 35 | 36 | let font: FontLoader 37 | if (fontCache.has(options.fonts)) { 38 | font = fontCache.get(options.fonts) 39 | } else { 40 | fontCache.set(options.fonts, (font = new FontLoader(options.fonts))) 41 | } 42 | 43 | const root = Yoga.Node.create() 44 | root.setWidth(options.width) 45 | root.setHeight(options.height) 46 | root.setFlexDirection(Yoga.FLEX_DIRECTION_ROW) 47 | root.setFlexWrap(Yoga.WRAP_WRAP) 48 | root.setAlignContent(Yoga.ALIGN_AUTO) 49 | root.setAlignItems(Yoga.ALIGN_FLEX_START) 50 | root.setJustifyContent(Yoga.JUSTIFY_FLEX_START) 51 | root.setOverflow(Yoga.OVERFLOW_HIDDEN) 52 | 53 | const graphemeImages = { ...options.graphemeImages } 54 | 55 | const handler = layout(element, { 56 | id: 'id', 57 | parentStyle: {}, 58 | inheritedStyle: { 59 | fontSize: 16, 60 | fontWeight: 'normal', 61 | fontFamily: 'serif', 62 | fontStyle: 'normal', 63 | lineHeight: 1.2, 64 | color: 'black', 65 | opacity: 1, 66 | whiteSpace: 'normal', 67 | 68 | // Special style properties: 69 | _viewportWidth: options.width, 70 | _viewportHeight: options.height, 71 | }, 72 | parent: root, 73 | font, 74 | embedFont: options.embedFont, 75 | debug: options.debug, 76 | graphemeImages, 77 | canLoadAdditionalAssets: !!options.loadAdditionalAsset, 78 | getTwStyles: (tw, style) => { 79 | const twToStyles = getTw({ 80 | width: options.width, 81 | height: options.height, 82 | }) 83 | const twStyles = { ...twToStyles([tw] as any) } 84 | if (typeof twStyles.lineHeight === 'number') { 85 | twStyles.lineHeight = 86 | twStyles.lineHeight / (+twStyles.fontSize || style.fontSize || 16) 87 | } 88 | if (twStyles.shadowColor && twStyles.boxShadow) { 89 | twStyles.boxShadow = (twStyles.boxShadow as string).replace( 90 | /rgba?\([^)]+\)/, 91 | twStyles.shadowColor as string 92 | ) 93 | } 94 | return twStyles 95 | }, 96 | }) 97 | 98 | let segmentsMissingFont = (await handler.next()).value as string[] 99 | 100 | if (options.loadAdditionalAsset) { 101 | if (segmentsMissingFont.length) { 102 | // Potentially CJK fonts are missing. 103 | segmentsMissingFont = Array.from( 104 | new Set(segment(segmentsMissingFont.join(''), 'grapheme')) 105 | ) 106 | 107 | const languageCodes: Record = {} 108 | segmentsMissingFont.forEach((seg) => { 109 | const code = detectLanguageCode(seg) 110 | languageCodes[code] = languageCodes[code] || [] 111 | if (code === 'emoji') { 112 | languageCodes[code].push(seg) 113 | } else { 114 | languageCodes[code][0] = (languageCodes[code][0] || '') + seg 115 | } 116 | }) 117 | 118 | const fonts: FontOptions[] = [] 119 | const images: Record = {} 120 | 121 | await Promise.all( 122 | Object.entries(languageCodes).flatMap(([code, segments]) => 123 | segments.map((segment) => 124 | options.loadAdditionalAsset(code, segment).then((asset) => { 125 | if (typeof asset === 'string') { 126 | images[segment] = asset 127 | } else if (asset) { 128 | fonts.push(asset) 129 | } 130 | }) 131 | ) 132 | ) 133 | ) 134 | 135 | // Directly mutate the font provider and the grapheme map. 136 | font.addFonts(fonts) 137 | Object.assign(graphemeImages, images) 138 | } 139 | } 140 | 141 | await handler.next() 142 | root.calculateLayout(options.width, options.height, Yoga.DIRECTION_LTR) 143 | 144 | const content = (await handler.next([0, 0])).value as string 145 | 146 | root.freeRecursive() 147 | 148 | return svg({ width: options.width, height: options.height, content }) 149 | } 150 | -------------------------------------------------------------------------------- /src/transform-origin.ts: -------------------------------------------------------------------------------- 1 | import valueParser from 'postcss-value-parser' 2 | 3 | import CssDimension from './vendor/parse-css-dimension' 4 | 5 | /** 6 | * If key for each direction is missing, assume default (50%) 7 | */ 8 | export interface ParsedTransformOrigin { 9 | /** Relative horizontal transform origin in % */ 10 | xRelative?: number 11 | /** Relative vertical transform origin in % */ 12 | yRelative?: number 13 | /** Absolute horizontal transform origin in pixels */ 14 | xAbsolute?: number 15 | /** Absolute horizontal transform origin in pixels */ 16 | yAbsolute?: number 17 | } 18 | 19 | interface ParsedUnit { 20 | /** Relative unit in % */ 21 | relative?: number 22 | /** Absolute unit in pixels */ 23 | absolute?: number 24 | } 25 | 26 | function parseUnit(word: string, baseFontSize: number): ParsedUnit { 27 | try { 28 | const parsed = new CssDimension(word) 29 | switch (parsed.unit) { 30 | case 'px': 31 | return { absolute: parsed.value } 32 | case 'em': 33 | return { absolute: parsed.value * baseFontSize } 34 | case 'rem': 35 | return { absolute: parsed.value * 16 } 36 | case '%': 37 | return { relative: parsed.value } 38 | default: 39 | return {} 40 | } 41 | } catch (e) { 42 | return {} 43 | } 44 | } 45 | 46 | function handleWord( 47 | word: string, 48 | baseFontSize: number, 49 | unitIsHorizontal: boolean 50 | ) { 51 | switch (word) { 52 | case 'top': 53 | return { yRelative: 0 } 54 | case 'left': 55 | return { xRelative: 0 } 56 | case 'right': 57 | return { xRelative: 100 } 58 | case 'bottom': 59 | return { yRelative: 100 } 60 | case 'center': 61 | return {} 62 | default: 63 | const parsedUnit = parseUnit(word, baseFontSize) 64 | return parsedUnit.absolute 65 | ? { 66 | [unitIsHorizontal ? 'xAbsolute' : 'yAbsolute']: parsedUnit.absolute, 67 | } 68 | : parsedUnit.relative 69 | ? { 70 | [unitIsHorizontal ? 'xRelative' : 'yRelative']: parsedUnit.relative, 71 | } 72 | : {} 73 | } 74 | } 75 | 76 | export default function parseTranformOrigin( 77 | value: string | number, 78 | baseFontSize: number 79 | ): ParsedTransformOrigin { 80 | // If it's a single value and a number, then it's horizontal 81 | if (typeof value === 'number') { 82 | return { xAbsolute: value } 83 | } 84 | let words: string[] 85 | try { 86 | words = valueParser(value) 87 | .nodes.filter((node) => node.type === 'word') 88 | .map((node) => node.value) 89 | } catch (e) { 90 | return {} 91 | } 92 | 93 | if (words.length === 1) { 94 | // If it's a single value and a number, then it's horizontal, so 95 | // pass `true` to `unitIsHorizontal` 96 | return handleWord(words[0], baseFontSize, true) 97 | } else if (words.length === 2) { 98 | // Make words to be [horizontal, vertical] 99 | if ( 100 | words[0] === 'top' || 101 | words[0] === 'bottom' || 102 | words[1] === 'left' || 103 | words[1] === 'right' 104 | ) { 105 | words.reverse() 106 | } 107 | 108 | return { 109 | ...handleWord(words[0], baseFontSize, true), 110 | ...handleWord(words[1], baseFontSize, false), 111 | } 112 | } else { 113 | return {} 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@shuding/opentype.js' { 2 | export = opentype 3 | } 4 | -------------------------------------------------------------------------------- /src/vendor/gradient-parser/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Rafael Carício 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. -------------------------------------------------------------------------------- /src/vendor/parse-css-dimension/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jed Mao 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 | -------------------------------------------------------------------------------- /src/vendor/parse-css-dimension/index.js: -------------------------------------------------------------------------------- 1 | var e=(t,r)=>()=>(r||t((r={exports:{}}).exports,r),r.exports);var u=e((k,g)=>{g.exports=["em","ex","ch","rem","vh","vw","vmin","vmax","px","mm","cm","in","pt","pc","mozmm"]});var a=e((z,v)=>{v.exports=["deg","grad","rad","turn"]});var c=e((L,w)=>{w.exports=["dpi","dpcm","dppx"]});var h=e(($,y)=>{y.exports=["Hz","kHz"]});var m=e((j,b)=>{b.exports=["s","ms"]});var q=u(),f=a(),p=c(),l=h(),d=m();function s(t){if(/\.\D?$/.test(t))throw new Error("The dot should be followed by a number");if(/^[+-]{2}/.test(t))throw new Error("Only one leading +/- is allowed");if(x(t)>1)throw new Error("Only one dot is allowed");if(/%$/.test(t)){this.type="percentage",this.value=o(t),this.unit="%";return}var r=O(t);if(!r){this.type="number",this.value=o(t);return}this.type=F(r),this.value=o(t.substr(0,t.length-r.length)),this.unit=r}s.prototype.valueOf=function(){return this.value};s.prototype.toString=function(){return this.value+(this.unit||"")};function U(t){return new s(t)}function x(t){var r=t.match(/\./g);return r?r.length:0}function o(t){var r=parseFloat(t);if(isNaN(r))throw new Error("Invalid number: "+t);return r}var E=[].concat(f,l,q,p,d);function O(t){var r=t.match(/\D+$/),n=r&&r[0];if(n&&E.indexOf(n)===-1)throw new Error("Invalid unit: "+n);return n}var D=Object.assign(i(f,"angle"),i(l,"frequency"),i(p,"resolution"),i(d,"time"));function i(t,r){return Object.fromEntries(t.map(n=>[n,r]))}function F(t){return D[t]||"length"}export{U as default}; 2 | -------------------------------------------------------------------------------- /src/vendor/parse-css-dimension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parse-css-dimension", 3 | "version": "0.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "esbuild src.js --bundle --outfile=index.js --format=esm --minify" 8 | }, 9 | "dependencies": { 10 | "css-angle-units": "^1.0.1", 11 | "css-frequency-units": "^1.0.1", 12 | "css-length-units": "^1.0.0", 13 | "css-resolution-units": "^1.0.1", 14 | "css-time-units": "^1.0.1" 15 | }, 16 | "devDependencies": { 17 | "esbuild": "^0.14.28" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/vendor/parse-css-dimension/src.js: -------------------------------------------------------------------------------- 1 | var cssLengthUnits = require('css-length-units') 2 | var cssAngleUnits = require('css-angle-units') 3 | var cssResolutionUnits = require('css-resolution-units') 4 | var cssFrequencyUnits = require('css-frequency-units') 5 | var cssTimeUnits = require('css-time-units') 6 | 7 | function CssDimension(value) { 8 | if (/\.\D?$/.test(value)) { 9 | throw new Error('The dot should be followed by a number') 10 | } 11 | 12 | if (/^[+-]{2}/.test(value)) { 13 | throw new Error('Only one leading +/- is allowed') 14 | } 15 | 16 | if (countDots(value) > 1) { 17 | throw new Error('Only one dot is allowed') 18 | } 19 | 20 | if (/%$/.test(value)) { 21 | this.type = 'percentage' 22 | this.value = tryParseFloat(value) 23 | this.unit = '%' 24 | return 25 | } 26 | 27 | var unit = parseUnit(value) 28 | if (!unit) { 29 | this.type = 'number' 30 | this.value = tryParseFloat(value) 31 | return 32 | } 33 | 34 | this.type = getTypeFromUnit(unit) 35 | this.value = tryParseFloat(value.substr(0, value.length - unit.length)) 36 | this.unit = unit 37 | } 38 | 39 | CssDimension.prototype.valueOf = function () { 40 | return this.value 41 | } 42 | 43 | CssDimension.prototype.toString = function () { 44 | return this.value + (this.unit || '') 45 | } 46 | 47 | export default function factory(value) { 48 | return new CssDimension(value) 49 | } 50 | 51 | function countDots(value) { 52 | var m = value.match(/\./g) 53 | return m ? m.length : 0 54 | } 55 | 56 | function tryParseFloat(value) { 57 | var result = parseFloat(value) 58 | if (isNaN(result)) { 59 | throw new Error('Invalid number: ' + value) 60 | } 61 | return result 62 | } 63 | 64 | var units = [].concat( 65 | cssAngleUnits, 66 | cssFrequencyUnits, 67 | cssLengthUnits, 68 | cssResolutionUnits, 69 | cssTimeUnits 70 | ) 71 | 72 | function parseUnit(value) { 73 | var m = value.match(/\D+$/) 74 | var unit = m && m[0] 75 | if (unit && units.indexOf(unit) === -1) { 76 | throw new Error('Invalid unit: ' + unit) 77 | } 78 | return unit 79 | } 80 | 81 | var unitTypeLookup = Object.assign( 82 | createLookups(cssAngleUnits, 'angle'), 83 | createLookups(cssFrequencyUnits, 'frequency'), 84 | createLookups(cssResolutionUnits, 'resolution'), 85 | createLookups(cssTimeUnits, 'time') 86 | ) 87 | 88 | function createLookups(list, value) { 89 | return Object.fromEntries(list.map((unit) => [unit, value])) 90 | } 91 | 92 | function getTypeFromUnit(unit) { 93 | return unitTypeLookup[unit] || 'length' 94 | } 95 | -------------------------------------------------------------------------------- /src/vendor/twrnc/deprecate.js: -------------------------------------------------------------------------------- 1 | module.exports = function deprecate(fn, message) { 2 | return function (...args) { 3 | console.warn(message) 4 | return fn(...args) 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/vendor/twrnc/log.js: -------------------------------------------------------------------------------- 1 | export default { 2 | info(key, messages) { 3 | console.info(...(Array.isArray(key) ? [key] : [messages, key])) 4 | }, 5 | warn(key, messages) { 6 | console.warn(...(Array.isArray(key) ? [key] : [messages, key])) 7 | }, 8 | risk(key, messages) { 9 | console.error(...(Array.isArray(key) ? [key] : [messages, key])) 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /src/vendor/twrnc/picocolors.js: -------------------------------------------------------------------------------- 1 | export default { 2 | yellow: (s) => s, 3 | } 4 | -------------------------------------------------------------------------------- /src/yoga/index.ts: -------------------------------------------------------------------------------- 1 | let Yoga: typeof import('yoga-layout') 2 | 3 | import YogaMod from '@yoga' 4 | 5 | // @ts-ignore 6 | Yoga = YogaMod.default 7 | 8 | export function init(yoga: typeof Yoga) { 9 | Yoga = yoga 10 | } 11 | 12 | export default function getYoga(): typeof Yoga { 13 | return Yoga 14 | } 15 | -------------------------------------------------------------------------------- /src/yoga/yoga-prebuilt.ts: -------------------------------------------------------------------------------- 1 | import * as Yoga from 'yoga-layout-prebuilt' 2 | 3 | export default Yoga 4 | -------------------------------------------------------------------------------- /src/yoga/yoga-prebuilt.wasm.ts: -------------------------------------------------------------------------------- 1 | // For WASM build, we don't include the prebuilt version of Yoga but let the 2 | // user specify the module manually with e.g.: 3 | // https://github.com/shuding/yoga-wasm-web. 4 | export default {} 5 | -------------------------------------------------------------------------------- /test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-combine-text-nodes-correctly-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-combine-text-nodes-correctly-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-render-basic-div-with-background-color-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-render-basic-div-with-background-color-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-render-basic-div-with-text-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-render-basic-div-with-text-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-render-basic-div-with-text-and-background-color-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-render-basic-div-with-text-and-background-color-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-render-empty-div-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-render-empty-div-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-support-array-in-jsx-children-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-support-array-in-jsx-children-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-support-hex-colors-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-support-hex-colors-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-support-skipping-embedded-fonts-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/basic-test-tsx-test-basic-test-tsx-2-m-22-m-basic-2-m-22-mshould-support-skipping-embedded-fonts-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-2-m-22-mshould-support-the-shorthand-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-2-m-22-mshould-support-the-shorthand-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-color-2-m-22-mshould-fallback-border-color-to-the-current-color-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-color-2-m-22-mshould-fallback-border-color-to-the-current-color-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-color-2-m-22-mshould-render-black-border-by-default-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-color-2-m-22-mshould-render-black-border-by-default-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-color-2-m-22-mshould-support-overriding-border-color-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-color-2-m-22-mshould-support-overriding-border-color-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-color-2-m-22-mshould-support-specifying-border-color-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-color-2-m-22-mshould-support-specifying-border-color-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-not-exceed-the-length-of-the-short-side-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-not-exceed-the-length-of-the-short-side-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-percentage-border-radius-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-percentage-border-radius-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-radius-for-a-certain-corner-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-radius-for-a-certain-corner-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-slash-and-2-value-corner-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-slash-and-2-value-corner-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-the-shorthand-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-the-shorthand-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-vw-vh-em-and-rem-units-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-radius-2-m-22-mshould-support-vw-vh-em-and-rem-units-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-style-2-m-22-mshould-support-dashed-border-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-style-2-m-22-mshould-support-dashed-border-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-width-2-m-22-mshould-render-border-inside-the-shape-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mborder-width-2-m-22-mshould-render-border-inside-the-shape-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mdirectional-2-m-22-mshould-support-advanced-border-with-radius-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mdirectional-2-m-22-mshould-support-advanced-border-with-radius-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mdirectional-2-m-22-mshould-support-directional-border-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mdirectional-2-m-22-mshould-support-directional-border-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mdirectional-2-m-22-mshould-support-non-complete-border-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/border-test-tsx-test-border-test-tsx-2-m-22-m-border-2-m-22-mdirectional-2-m-22-mshould-support-non-complete-border-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/font-test-tsx-test-font-test-tsx-2-m-22-m-font-2-m-22-mfont-size-2-m-22-mshould-allow-font-size-to-be-0-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/font-test-tsx-test-font-test-tsx-2-m-22-m-font-2-m-22-mfont-size-2-m-22-mshould-allow-font-size-to-be-0-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/font-test-tsx-test-font-test-tsx-2-m-22-m-font-2-m-22-mshould-not-error-when-no-font-is-specified-and-no-text-rendered-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/font-test-tsx-test-font-test-tsx-2-m-22-m-font-2-m-22-mshould-not-error-when-no-font-is-specified-and-no-text-rendered-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mlinear-gradient-2-m-22-mshould-support-linear-gradient-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mlinear-gradient-2-m-22-mshould-support-linear-gradient-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mlinear-gradient-2-m-22-mshould-support-linear-gradient-with-transparency-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mlinear-gradient-2-m-22-mshould-support-linear-gradient-with-transparency-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mlinear-gradient-2-m-22-mshould-support-repeating-linear-gradient-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mlinear-gradient-2-m-22-mshould-support-repeating-linear-gradient-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mradial-gradient-2-m-22-mshould-support-radial-gradient-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mradial-gradient-2-m-22-mshould-support-radial-gradient-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mshould-render-gradient-patterns-in-the-correct-object-space-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mshould-render-gradient-patterns-in-the-correct-object-space-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mshould-resolve-gradient-layers-in-the-correct-order-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mshould-resolve-gradient-layers-in-the-correct-order-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mshould-support-advanced-usage-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/gradient-test-tsx-test-gradient-test-tsx-2-m-22-m-gradient-2-m-22-mshould-support-advanced-usage-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mbackground-image-url-2-m-22-mshould-resolve-image-data-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mbackground-image-url-2-m-22-mshould-resolve-image-data-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-clip-content-in-the-border-and-padding-areas-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-clip-content-in-the-border-and-padding-areas-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-clip-content-in-the-border-area-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-clip-content-in-the-border-area-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-deduplicate-image-data-requests-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-deduplicate-image-data-requests-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-resolve-image-data-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-resolve-image-data-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-resolve-the-image-size-and-scale-automatically-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-resolve-the-image-size-and-scale-automatically-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-support-styles-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-support-styles-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-support-svg-images-and-percentage-with-correct-aspect-ratio-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/image-test-tsx-test-image-test-tsx-2-m-22-m-image-2-m-22-mimg-2-m-22-mshould-support-svg-images-and-percentage-with-correct-aspect-ratio-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/overflow-test-tsx-test-overflow-test-tsx-2-m-22-m-overflow-2-m-22-mshould-not-show-overflowed-text-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/overflow-test-tsx-test-overflow-test-tsx-2-m-22-m-overflow-2-m-22-mshould-not-show-overflowed-text-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/overflow-test-tsx-test-overflow-test-tsx-2-m-22-m-overflow-2-m-22-mshould-work-with-nested-border-border-radius-padding-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/overflow-test-tsx-test-overflow-test-tsx-2-m-22-m-overflow-2-m-22-mshould-work-with-nested-border-border-radius-padding-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/position-test-tsx-test-position-test-tsx-2-m-22-m-position-2-m-22-mabsolute-2-m-22-mshould-support-absolute-position-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/position-test-tsx-test-position-test-tsx-2-m-22-m-position-2-m-22-mabsolute-2-m-22-mshould-support-absolute-position-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-be-affected-by-container-opacity-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-be-affected-by-container-opacity-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-render-box-shadow-with-offset-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-render-box-shadow-with-offset-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-render-box-shadow-with-offset-and-spread-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-render-box-shadow-with-offset-and-spread-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-render-multiple-box-shadows-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-render-multiple-box-shadows-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-render-regular-box-shadow-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-render-regular-box-shadow-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-support-box-shadow-for-transparent-elements-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-support-box-shadow-for-transparent-elements-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-support-box-shadow-spread-with-transparency-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-support-box-shadow-spread-with-transparency-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-support-inset-box-shadows-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-support-inset-box-shadows-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-support-negative-spread-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/shadow-test-tsx-test-shadow-test-tsx-2-m-22-m-shadow-2-m-22-mbox-shadow-2-m-22-mshould-support-negative-spread-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/svg-test-tsx-test-svg-test-tsx-2-m-22-m-svg-2-m-22-mshould-render-svg-attributes-correctly-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/svg-test-tsx-test-svg-test-tsx-2-m-22-m-svg-2-m-22-mshould-render-svg-attributes-correctly-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/svg-test-tsx-test-svg-test-tsx-2-m-22-m-svg-2-m-22-mshould-render-svg-nodes-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/svg-test-tsx-test-svg-test-tsx-2-m-22-m-svg-2-m-22-mshould-render-svg-nodes-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/svg-test-tsx-test-svg-test-tsx-2-m-22-m-svg-2-m-22-mshould-render-svg-size-correctly-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/svg-test-tsx-test-svg-test-tsx-2-m-22-m-svg-2-m-22-mshould-render-svg-size-correctly-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mmultiple-transforms-2-m-22-mshould-support-translate-rotate-and-scale-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mmultiple-transforms-2-m-22-mshould-support-translate-rotate-and-scale-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mrotate-2-m-22-mshould-rotate-shape-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mrotate-2-m-22-mshould-rotate-shape-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mscale-2-m-22-mshould-scale-shape-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mscale-2-m-22-mshould-scale-shape-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mscale-2-m-22-mshould-scale-shape-in-two-directions-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mscale-2-m-22-mshould-scale-shape-in-two-directions-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mtranslate-2-m-22-mshould-support-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mtranslate-2-m-22-mshould-support-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mtranslate-2-m-22-mshould-translate-shape-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mtranslate-2-m-22-mshould-translate-shape-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mtranslate-2-m-22-mshould-translate-shape-in-x-axis-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mtranslate-2-m-22-mshould-translate-shape-in-x-axis-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mtranslate-2-m-22-mshould-translate-shape-in-y-axis-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/transform-test-tsx-test-transform-test-tsx-2-m-22-mtransform-2-m-22-mtranslate-2-m-22-mshould-translate-shape-in-y-axis-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-em-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-em-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-px-and-numbers-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-px-and-numbers-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-rem-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-rem-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-rgb-syntaxs-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-rgb-syntaxs-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-vh-and-vw-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/units-test-tsx-test-units-test-tsx-2-m-22-m-units-2-m-22-mshould-support-vh-and-vw-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mnormal-2-m-22-mshould-not-render-extra-line-breaks-with-white-space-normal-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mnormal-2-m-22-mshould-not-render-extra-line-breaks-with-white-space-normal-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mnormal-2-m-22-mshould-not-render-extra-spaces-with-white-space-normal-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mnormal-2-m-22-mshould-not-render-extra-spaces-with-white-space-normal-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mnormal-2-m-22-mshould-wrap-automatically-with-white-space-normal-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mnormal-2-m-22-mshould-wrap-automatically-with-white-space-normal-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mpre-2-m-22-mshould-always-preserve-extra-line-breaks-with-white-space-pre-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mpre-2-m-22-mshould-always-preserve-extra-line-breaks-with-white-space-pre-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mpre-2-m-22-mshould-always-preserve-extra-spaces-with-white-space-pre-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mpre-2-m-22-mshould-always-preserve-extra-spaces-with-white-space-pre-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mpre-2-m-22-mshould-not-wrap-with-white-space-pre-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mpre-2-m-22-mshould-not-wrap-with-white-space-pre-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mwith-white-space-nowrap-2-m-22-mshould-not-wrap-with-white-space-nowrap-and-swallow-extra-spaces-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mwith-white-space-nowrap-2-m-22-mshould-not-wrap-with-white-space-nowrap-and-swallow-extra-spaces-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mwith-white-space-pre-wrap-2-m-22-mshould-always-preserve-extra-line-breaks-with-white-space-pre-wrap-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mwith-white-space-pre-wrap-2-m-22-mshould-always-preserve-extra-line-breaks-with-white-space-pre-wrap-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mwith-white-space-pre-wrap-2-m-22-mshould-always-preserve-extra-spaces-with-white-space-pre-wrap-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mwith-white-space-pre-wrap-2-m-22-mshould-always-preserve-extra-spaces-with-white-space-pre-wrap-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mwith-white-space-pre-wrap-2-m-22-mshould-automatically-wrap-with-white-space-pre-wrap-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/__image_snapshots__/white-space-test-tsx-test-white-space-test-tsx-2-m-22-mwhite-space-2-m-22-mwith-white-space-pre-wrap-2-m-22-mshould-automatically-wrap-with-white-space-pre-wrap-1-snap.png -------------------------------------------------------------------------------- /test/assets/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/assets/Roboto-Bold.ttf -------------------------------------------------------------------------------- /test/assets/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/assets/Roboto-Regular.ttf -------------------------------------------------------------------------------- /test/assets/playfair-display.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oldhand7/satori-typescript/93debbabfa5c7470c3e6baac2c6e1a85fabc99a9/test/assets/playfair-display.ttf -------------------------------------------------------------------------------- /test/basic.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { it, describe, expect } from 'vitest' 3 | 4 | import { initFonts, toImage } from './utils' 5 | import satori from '../src' 6 | 7 | describe('Basic', () => { 8 | let fonts 9 | initFonts((f) => (fonts = f)) 10 | 11 | it('should render empty div', async () => { 12 | const svg = await satori(
, { width: 100, height: 100, fonts }) 13 | expect(toImage(svg, 100)).toMatchImageSnapshot() 14 | }) 15 | 16 | it('should render basic div with text', async () => { 17 | const svg = await satori(
Hello
, { 18 | width: 100, 19 | height: 100, 20 | fonts, 21 | }) 22 | expect(toImage(svg, 100)).toMatchImageSnapshot() 23 | }) 24 | 25 | it('should render basic div with background color', async () => { 26 | const svg = await satori( 27 |
, 30 | { 31 | width: 100, 32 | height: 100, 33 | fonts, 34 | } 35 | ) 36 | expect(toImage(svg, 100)).toMatchImageSnapshot() 37 | }) 38 | 39 | it('should render basic div with text and background color', async () => { 40 | const svg = await satori( 41 |
42 | Hello 43 |
, 44 | { 45 | width: 100, 46 | height: 100, 47 | fonts, 48 | } 49 | ) 50 | expect(toImage(svg, 100)).toMatchImageSnapshot() 51 | }) 52 | 53 | it('should support skipping embedded fonts', async () => { 54 | const svg = await satori(
Hello
, { 55 | width: 100, 56 | height: 100, 57 | fonts, 58 | embedFont: false, 59 | }) 60 | expect(toImage(svg, 100)).toMatchImageSnapshot() 61 | }) 62 | 63 | it('should support hex colors', async () => { 64 | const svg = await satori( 65 |
, 68 | { 69 | width: 100, 70 | height: 100, 71 | fonts, 72 | } 73 | ) 74 | expect(toImage(svg, 100)).toMatchImageSnapshot() 75 | }) 76 | 77 | it('should support array in JSX children', async () => { 78 | const svg = await satori( 79 |
87 |
1
88 | {[ 89 |
2{[
3
]}
, 90 |
{[4]}
, 91 | ]} 92 |
, 93 | { 94 | width: 100, 95 | height: 100, 96 | fonts, 97 | } 98 | ) 99 | expect(toImage(svg, 100)).toMatchImageSnapshot() 100 | }) 101 | 102 | it('should combine textNodes correctly', async () => { 103 | const svg = await satori( 104 |
111 | Hi {0}
hi
{0} {false} {undefined} {0} {null} {0} {true} {'x'}{' '} 112 | {0} 113 |
, 114 | { 115 | width: 100, 116 | height: 100, 117 | fonts, 118 | } 119 | ) 120 | expect(toImage(svg, 100)).toMatchImageSnapshot() 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /test/error.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { it, describe, expect } from 'vitest' 3 | 4 | import { initFonts } from './utils' 5 | import satori from '../src' 6 | 7 | describe('Error', () => { 8 | let fonts 9 | initFonts((f) => (fonts = f)) 10 | 11 | it('should throw if flex missing on div that has children', async () => { 12 | const result = satori( 13 |
14 | Test satori with space 15 |
, 16 | { 17 | width: 10, 18 | height: 10, 19 | fonts, 20 | } 21 | ) 22 | expect(result).rejects.toThrowError( 23 | `Expected
to have explicit "display: flex" or "display: none" if it has more than one child node.` 24 | ) 25 | }) 26 | 27 | it('should throw if display inline-block on div that has children', async () => { 28 | const result = satori( 29 |
30 | Test satori with space 31 |
, 32 | { 33 | width: 10, 34 | height: 10, 35 | fonts, 36 | } 37 | ) 38 | expect(result).rejects.toThrowError( 39 | `Invalid value for CSS property "display". Allowed values: "flex" | "none". Received: "inline-block".` 40 | ) 41 | }) 42 | 43 | it('should throw if using invalid values', async () => { 44 | const result = satori( 45 | // @ts-expect-error 46 |
Test
, 47 | { 48 | width: 10, 49 | height: 10, 50 | fonts, 51 | } 52 | ) 53 | expect(result).rejects.toThrowError( 54 | `Invalid value for CSS property "position". Allowed values: "absolute" | "relative". Received: "fixed".` 55 | ) 56 | }) 57 | 58 | it('should not throw if display none on div that has children', async () => { 59 | const svg = await satori( 60 |
61 | Test satori with space 62 |
, 63 | { 64 | width: 10, 65 | height: 10, 66 | fonts, 67 | } 68 | ) 69 | expect(typeof svg).toBe('string') 70 | }) 71 | 72 | it('should not throw if flex missing on span that has children', async () => { 73 | const svg = await satori( 74 | 75 | Test satori with space 76 | , 77 | { 78 | width: 10, 79 | height: 10, 80 | fonts, 81 | } 82 | ) 83 | expect(typeof svg).toBe('string') 84 | }) 85 | 86 | it('should not throw if flex missing on div without children', async () => { 87 | const svg = await satori(
, { 88 | width: 10, 89 | height: 10, 90 | fonts, 91 | }) 92 | expect(typeof svg).toBe('string') 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /test/font.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { it, describe, expect } from 'vitest' 3 | 4 | import { initFonts, toImage } from './utils' 5 | import satori from '../src' 6 | 7 | describe('Font', () => { 8 | let fonts 9 | initFonts((f) => (fonts = f)) 10 | 11 | it('should error when no font is specified', async () => { 12 | try { 13 | await satori(
hello
, { 14 | width: 100, 15 | height: 100, 16 | fonts: [], 17 | }) 18 | } catch (e) { 19 | expect(e.message).toMatchInlineSnapshot( 20 | '"No fonts are loaded. At least one font is required to calculate the layout."' 21 | ) 22 | } 23 | }) 24 | 25 | it('should not error when no font is specified and no text rendered', async () => { 26 | const svg = await satori(
, { 27 | width: 100, 28 | height: 100, 29 | fonts: [], 30 | }) 31 | expect(toImage(svg, 100)).toMatchImageSnapshot() 32 | }) 33 | 34 | describe('font-size', () => { 35 | it('should allow font-size to be 0', async () => { 36 | const svg = await satori(
hi
, { 37 | width: 100, 38 | height: 100, 39 | fonts, 40 | }) 41 | expect(toImage(svg, 100)).toMatchImageSnapshot() 42 | }) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /test/gradient.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { it, describe, expect } from 'vitest' 3 | 4 | import { initFonts, toImage } from './utils' 5 | import satori from '../src' 6 | 7 | describe('Gradient', () => { 8 | let fonts 9 | initFonts((f) => (fonts = f)) 10 | 11 | describe('linear-gradient', () => { 12 | it('should support linear-gradient', async () => { 13 | const svg = await satori( 14 |
, 22 | { 23 | width: 100, 24 | height: 100, 25 | fonts, 26 | } 27 | ) 28 | expect(toImage(svg, 100)).toMatchImageSnapshot() 29 | }) 30 | 31 | it('should support repeating linear-gradient', async () => { 32 | const svg = await satori( 33 |
, 42 | { 43 | width: 100, 44 | height: 100, 45 | fonts, 46 | } 47 | ) 48 | expect(toImage(svg, 100)).toMatchImageSnapshot() 49 | }) 50 | 51 | it('should support linear-gradient with transparency', async () => { 52 | const svg = await satori( 53 |
, 61 | { 62 | width: 100, 63 | height: 100, 64 | fonts, 65 | } 66 | ) 67 | expect(toImage(svg, 100)).toMatchImageSnapshot() 68 | }) 69 | }) 70 | 71 | describe('radial-gradient', () => { 72 | it('should support radial-gradient', async () => { 73 | const svg = await satori( 74 |
, 83 | { 84 | width: 100, 85 | height: 100, 86 | fonts, 87 | } 88 | ) 89 | expect(toImage(svg, 100)).toMatchImageSnapshot() 90 | }) 91 | }) 92 | 93 | it('should support advanced usage', async () => { 94 | const svg = await satori( 95 |
, 106 | { 107 | width: 100, 108 | height: 100, 109 | fonts, 110 | } 111 | ) 112 | expect(toImage(svg, 100)).toMatchImageSnapshot() 113 | }) 114 | 115 | it('should resolve gradient layers in the correct order', async () => { 116 | const svg = await satori( 117 |
, 128 | { 129 | width: 100, 130 | height: 100, 131 | fonts, 132 | } 133 | ) 134 | expect(toImage(svg, 100)).toMatchImageSnapshot() 135 | }) 136 | 137 | it('should render gradient patterns in the correct object space', async () => { 138 | const svg = await satori( 139 |
148 |
155 |
, 156 | { 157 | width: 100, 158 | height: 100, 159 | fonts, 160 | } 161 | ) 162 | expect(toImage(svg, 100)).toMatchImageSnapshot() 163 | }) 164 | }) 165 | -------------------------------------------------------------------------------- /test/image.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { it, describe, expect, beforeEach, afterEach } from 'vitest' 3 | 4 | import { initFonts, toImage } from './utils' 5 | import satori from '../src' 6 | 7 | describe('Image', () => { 8 | let fonts 9 | initFonts((f) => (fonts = f)) 10 | 11 | let requests = [] 12 | 13 | beforeEach(() => { 14 | // Polyfill fetch 15 | requests = [] 16 | ;(globalThis as any).fetch = async (url) => { 17 | requests.push(url) 18 | if (url.endsWith('.svg')) { 19 | return { 20 | headers: { 21 | get: () => 'image/svg+xml', 22 | }, 23 | text: async () => 24 | '', 25 | } 26 | } 27 | 28 | return { 29 | headers: { 30 | get: (key) => { 31 | if (key === 'content-type') return 'image/png' 32 | }, 33 | }, 34 | arrayBuffer: async () => { 35 | // 1x1 #00F blue image. 36 | const binary_string = atob( 37 | `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPj/HwADBwIAMCbHYQAAAABJRU5ErkJggg==` 38 | ) 39 | const len = binary_string.length 40 | const bytes = new Uint8Array(len) 41 | for (let i = 0; i < len; i++) { 42 | bytes[i] = binary_string.charCodeAt(i) 43 | } 44 | return bytes.buffer 45 | }, 46 | } 47 | } 48 | }) 49 | 50 | afterEach(() => { 51 | delete globalThis.fetch 52 | }) 53 | 54 | describe('img', () => { 55 | it('should resolve image data', async () => { 56 | const svg = await satori( 57 |
65 | 70 |
, 71 | { width: 100, height: 100, fonts } 72 | ) 73 | expect(toImage(svg, 100)).toMatchImageSnapshot() 74 | 75 | expect(requests).toEqual(['https://via.placeholder.com/150']) 76 | }) 77 | 78 | it('should deduplicate image data requests', async () => { 79 | const svg = await satori( 80 |
88 | 89 | 90 |
, 91 | { width: 100, height: 100, fonts } 92 | ) 93 | expect(toImage(svg, 100)).toMatchImageSnapshot() 94 | 95 | expect(requests).toEqual(['https://via.placeholder.com/200']) 96 | }) 97 | 98 | it('should resolve the image size and scale automatically', async () => { 99 | const svg = await satori( 100 |
108 | 109 |
, 110 | { width: 100, height: 100, fonts } 111 | ) 112 | expect(toImage(svg, 100)).toMatchImageSnapshot() 113 | }) 114 | 115 | it('should support styles', async () => { 116 | const svg = await satori( 117 |
124 | 133 |
, 134 | { width: 100, height: 100, fonts } 135 | ) 136 | expect(toImage(svg, 100)).toMatchImageSnapshot() 137 | }) 138 | 139 | it('should support SVG images and percentage with correct aspect ratio', async () => { 140 | const svg = await satori( 141 |
148 | 149 |
, 150 | { width: 100, height: 100, fonts } 151 | ) 152 | expect(toImage(svg, 100)).toMatchImageSnapshot() 153 | }) 154 | 155 | it('should clip content in the border area', async () => { 156 | const svg = await satori( 157 |
164 | 173 |
, 174 | { width: 100, height: 100, fonts } 175 | ) 176 | expect(toImage(svg, 100)).toMatchImageSnapshot() 177 | }) 178 | 179 | it('should clip content in the border and padding areas', async () => { 180 | const svg = await satori( 181 |
188 | 198 |
, 199 | { width: 100, height: 100, fonts } 200 | ) 201 | expect(toImage(svg, 100)).toMatchImageSnapshot() 202 | }) 203 | }) 204 | 205 | describe('background-image: url()', () => { 206 | it('should resolve image data', async () => { 207 | const svg = await satori( 208 |
, 217 | { width: 100, height: 100, fonts } 218 | ) 219 | 220 | expect(toImage(svg, 100)).toMatchImageSnapshot() 221 | 222 | expect(requests).toEqual(['https://via.placeholder.com/300']) 223 | }) 224 | }) 225 | }) 226 | -------------------------------------------------------------------------------- /test/language.test.tsx: -------------------------------------------------------------------------------- 1 | import { it, describe, expect } from 'vitest' 2 | 3 | import { detectLanguageCode } from '../src/language' 4 | 5 | describe('detectLanguageCode', () => { 6 | 7 | it('should detect emoji', async () => { 8 | expect(detectLanguageCode('🔺')).toBe('emoji') 9 | expect(detectLanguageCode('😀')).toBe('emoji') 10 | expect(detectLanguageCode('㊗️')).toBe('emoji') 11 | expect(detectLanguageCode('🧑🏻‍💻')).toBe('emoji') 12 | expect(detectLanguageCode('hello 🌍')).toBe('emoji') 13 | expect(detectLanguageCode('👋 vs 🌊')).toBe('emoji') 14 | }) 15 | 16 | it('should detect japanese', async () => { 17 | expect(detectLanguageCode('こんにちは')).toBe('ja') 18 | }) 19 | 20 | it('should detect korean', async () => { 21 | expect(detectLanguageCode('안녕하세요')).toBe('ko') 22 | }) 23 | 24 | it('should detect simplified chinese', async () => { 25 | expect(detectLanguageCode('我知道怎么说中文')).toBe('zh') 26 | }) 27 | 28 | it('should detect traditional chinese', async () => { 29 | expect(detectLanguageCode('我知道怎麼說中文')).toBe('zh') 30 | }) 31 | 32 | it('should detect thai', async () => { 33 | expect(detectLanguageCode('สวัสดี')).toBe('th') 34 | }) 35 | 36 | it('should detect arabic', async () => { 37 | expect(detectLanguageCode('مرحبا')).toBe('ar') 38 | }) 39 | 40 | it('should detect tamil', async () => { 41 | expect(detectLanguageCode('வணக்கம்')).toBe('ta') 42 | }) 43 | 44 | it('should detect bengali', async () => { 45 | expect(detectLanguageCode('হ্যালো')).toBe('bn') 46 | }) 47 | 48 | it('should detect malayalam', async () => { 49 | expect(detectLanguageCode('ഹായ്')).toBe('ml') 50 | }) 51 | 52 | it('should detect hebrew', async () => { 53 | expect(detectLanguageCode('שלום')).toBe('he') 54 | }) 55 | 56 | it('should detect telegu', async () => { 57 | expect(detectLanguageCode('హలో')).toBe('te') 58 | }) 59 | 60 | it('should detect devanagari', async () => { 61 | expect(detectLanguageCode('नमस्ते')).toBe('devanagari') 62 | }); 63 | 64 | it('should detect unknown', async () => { 65 | expect(detectLanguageCode('wat')).toBe('unknown') 66 | }); 67 | }) 68 | -------------------------------------------------------------------------------- /test/overflow.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { it, describe, expect } from 'vitest' 3 | 4 | import { initFonts, toImage } from './utils' 5 | import satori from '../src' 6 | 7 | describe('Overflow', () => { 8 | let fonts 9 | initFonts((f) => (fonts = f)) 10 | 11 | it('should not show overflowed text', async () => { 12 | const svg = await satori( 13 |
21 | Hello 22 |
, 23 | { 24 | width: 100, 25 | height: 100, 26 | fonts, 27 | } 28 | ) 29 | expect(toImage(svg, 100)).toMatchImageSnapshot() 30 | }) 31 | 32 | it('should work with nested border, border-radius, padding', async () => { 33 | const svg = await satori( 34 |
46 |
57 |
58 | Satori 59 |
60 |
61 |
, 62 | { 63 | width: 100, 64 | height: 100, 65 | fonts, 66 | } 67 | ) 68 | expect(toImage(svg, 100)).toMatchImageSnapshot() 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /test/position.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { it, describe, expect } from 'vitest' 3 | 4 | import { initFonts, toImage } from './utils' 5 | import satori from '../src' 6 | 7 | describe('Position', () => { 8 | let fonts 9 | initFonts((f) => (fonts = f)) 10 | 11 | describe('absolute', () => { 12 | it('should support absolute position', async () => { 13 | const svg = await satori( 14 |
21 |
31 |
, 32 | { width: 100, height: 100, fonts } 33 | ) 34 | expect(toImage(svg, 100)).toMatchImageSnapshot() 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /test/shadow.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { it, describe, expect } from 'vitest' 3 | 4 | import { initFonts, toImage } from './utils' 5 | import satori from '../src' 6 | 7 | describe('Shadow', () => { 8 | let fonts 9 | initFonts((f) => (fonts = f)) 10 | 11 | describe('box-shadow', () => { 12 | it('should render regular box shadow', async () => { 13 | const svg = await satori( 14 |
, 23 | { width: 100, height: 100, fonts } 24 | ) 25 | expect(toImage(svg, 100)).toMatchImageSnapshot() 26 | }) 27 | 28 | it('should render box shadow with offset', async () => { 29 | const svg = await satori( 30 |
, 39 | { width: 100, height: 100, fonts } 40 | ) 41 | expect(toImage(svg, 100)).toMatchImageSnapshot() 42 | }) 43 | 44 | it('should render box shadow with offset and spread', async () => { 45 | const svg = await satori( 46 |
, 55 | { width: 100, height: 100, fonts } 56 | ) 57 | expect(toImage(svg, 100)).toMatchImageSnapshot() 58 | }) 59 | 60 | it('should render multiple box shadows', async () => { 61 | const svg = await satori( 62 |
, 71 | { width: 100, height: 100, fonts } 72 | ) 73 | expect(toImage(svg, 100)).toMatchImageSnapshot() 74 | }) 75 | 76 | it('should support negative spread', async () => { 77 | const svg = await satori( 78 |
, 88 | { width: 100, height: 100, fonts } 89 | ) 90 | expect(toImage(svg, 100)).toMatchImageSnapshot() 91 | }) 92 | 93 | it('should support box shadow for transparent elements', async () => { 94 | const svg = await satori( 95 |
, 104 | { width: 100, height: 100, fonts } 105 | ) 106 | expect(toImage(svg, 100)).toMatchImageSnapshot() 107 | }) 108 | 109 | it('should support box shadow spread with transparency', async () => { 110 | const svg = await satori( 111 |
, 120 | { width: 100, height: 100, fonts } 121 | ) 122 | expect(toImage(svg, 100)).toMatchImageSnapshot() 123 | }) 124 | 125 | it('should support inset box shadows', async () => { 126 | const svg = await satori( 127 |
, 136 | { width: 100, height: 100, fonts } 137 | ) 138 | expect(toImage(svg, 100)).toMatchImageSnapshot() 139 | }) 140 | 141 | it('should be affected by container opacity', async () => { 142 | const svg = await satori( 143 |
, 153 | { width: 100, height: 100, fonts } 154 | ) 155 | expect(toImage(svg, 100)).toMatchImageSnapshot() 156 | }) 157 | }) 158 | }) 159 | -------------------------------------------------------------------------------- /test/svg.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { it, describe, expect } from 'vitest' 3 | 4 | import { initFonts, toImage } from './utils' 5 | import satori from '../src' 6 | 7 | describe('SVG', () => { 8 | let fonts 9 | initFonts((f) => (fonts = f)) 10 | 11 | it('should render svg nodes', async () => { 12 | const svg = await satori( 13 |
21 | 22 | 30 | 31 |
, 32 | { width: 100, height: 100, fonts } 33 | ) 34 | expect(toImage(svg, 100)).toMatchImageSnapshot() 35 | }) 36 | 37 | it('should render svg attributes correctly', async () => { 38 | const svg = await satori( 39 |
47 | 52 | 60 | 61 |
, 62 | { width: 100, height: 100, fonts } 63 | ) 64 | expect(toImage(svg, 100)).toMatchImageSnapshot() 65 | }) 66 | 67 | it('should render svg size correctly', async () => { 68 | const svg = await satori( 69 |
77 | 83 | 91 | 92 |
, 93 | { width: 100, height: 100, fonts } 94 | ) 95 | expect(toImage(svg, 100)).toMatchImageSnapshot() 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /test/transform.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { it, describe, expect } from 'vitest' 3 | 4 | import { initFonts, toImage } from './utils' 5 | import satori from '../src' 6 | 7 | describe('transform', () => { 8 | let fonts 9 | initFonts((f) => (fonts = f)) 10 | 11 | describe('translate', () => { 12 | it('should translate shape', async () => { 13 | const svg = await satori( 14 |
, 22 | { 23 | width: 100, 24 | height: 100, 25 | fonts, 26 | } 27 | ) 28 | expect(toImage(svg, 100)).toMatchImageSnapshot() 29 | }) 30 | 31 | it('should translate shape in x-axis', async () => { 32 | const svg = await satori( 33 |
, 41 | { 42 | width: 100, 43 | height: 100, 44 | fonts, 45 | } 46 | ) 47 | expect(toImage(svg, 100)).toMatchImageSnapshot() 48 | }) 49 | 50 | it('should translate shape in y-axis', async () => { 51 | const svg = await satori( 52 |
, 60 | { 61 | width: 100, 62 | height: 100, 63 | fonts, 64 | } 65 | ) 66 | expect(toImage(svg, 100)).toMatchImageSnapshot() 67 | }) 68 | 69 | it('should support %', async () => { 70 | const svg = await satori( 71 |
80 |
88 |
, 89 | { 90 | width: 100, 91 | height: 100, 92 | fonts, 93 | } 94 | ) 95 | expect(toImage(svg, 100)).toMatchImageSnapshot() 96 | }) 97 | }) 98 | 99 | describe('rotate', () => { 100 | it('should rotate shape', async () => { 101 | const svg = await satori( 102 |
, 110 | { 111 | width: 100, 112 | height: 100, 113 | fonts, 114 | } 115 | ) 116 | expect(toImage(svg, 100)).toMatchImageSnapshot() 117 | }) 118 | }) 119 | 120 | describe('scale', () => { 121 | it('should scale shape', async () => { 122 | const svg = await satori( 123 |
, 131 | { 132 | width: 100, 133 | height: 100, 134 | fonts, 135 | } 136 | ) 137 | expect(toImage(svg, 100)).toMatchImageSnapshot() 138 | }) 139 | 140 | it('should scale shape in two directions', async () => { 141 | const svg = await satori( 142 |
, 150 | { 151 | width: 100, 152 | height: 100, 153 | fonts, 154 | } 155 | ) 156 | expect(toImage(svg, 100)).toMatchImageSnapshot() 157 | }) 158 | }) 159 | 160 | describe('multiple transforms', () => { 161 | it('should support translate rotate and scale', async () => { 162 | const svg = await satori( 163 |
, 171 | { 172 | width: 100, 173 | height: 100, 174 | fonts, 175 | } 176 | ) 177 | expect(toImage(svg, 100)).toMatchImageSnapshot() 178 | }) 179 | }) 180 | }) 181 | -------------------------------------------------------------------------------- /test/units.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { it, describe, expect } from 'vitest' 3 | 4 | import { initFonts, toImage } from './utils' 5 | import satori from '../src' 6 | 7 | describe('Units', () => { 8 | let fonts 9 | initFonts((f) => (fonts = f)) 10 | 11 | it('should support %', async () => { 12 | const svg = await satori( 13 |
, 20 | { 21 | width: 100, 22 | height: 100, 23 | fonts, 24 | } 25 | ) 26 | expect(toImage(svg, 100)).toMatchImageSnapshot() 27 | }) 28 | 29 | it('should support em', async () => { 30 | const svg = await satori( 31 |
, 39 | { 40 | width: 100, 41 | height: 100, 42 | fonts, 43 | } 44 | ) 45 | expect(toImage(svg, 100)).toMatchImageSnapshot() 46 | }) 47 | 48 | it('should support vh and vw', async () => { 49 | const svg = await satori( 50 |
, 57 | { 58 | width: 100, 59 | height: 100, 60 | fonts, 61 | } 62 | ) 63 | expect(toImage(svg, 100)).toMatchImageSnapshot() 64 | }) 65 | 66 | it('should support rem', async () => { 67 | const svg = await satori( 68 |
, 76 | { 77 | width: 100, 78 | height: 100, 79 | fonts, 80 | } 81 | ) 82 | expect(toImage(svg, 100)).toMatchImageSnapshot() 83 | }) 84 | 85 | it('should support px and numbers', async () => { 86 | const svg = await satori( 87 |
, 95 | { 96 | width: 100, 97 | height: 100, 98 | fonts, 99 | } 100 | ) 101 | expect(toImage(svg, 100)).toMatchImageSnapshot() 102 | }) 103 | 104 | it('should support rgb syntaxs', async () => { 105 | const svg = await satori( 106 |
107 |
114 |
121 |
128 |
, 129 | { 130 | width: 100, 131 | height: 100, 132 | fonts, 133 | } 134 | ) 135 | expect(toImage(svg, 100)).toMatchImageSnapshot() 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /test/utils.tsx: -------------------------------------------------------------------------------- 1 | import { beforeAll, expect } from 'vitest' 2 | import fs from 'fs/promises' 3 | import { join } from 'path' 4 | import { Resvg } from '@resvg/resvg-js' 5 | import { toMatchImageSnapshot } from 'jest-image-snapshot' 6 | 7 | import { SatoriOptions } from '../src' 8 | 9 | export function initFonts(callback: (fonts: SatoriOptions['fonts']) => void) { 10 | beforeAll(async () => { 11 | const fontPath = join(process.cwd(), 'test', 'assets', 'Roboto-Regular.ttf') 12 | const fontData = await fs.readFile(fontPath) 13 | callback([ 14 | { 15 | name: 'Roboto', 16 | data: fontData, 17 | weight: 400, 18 | style: 'normal', 19 | }, 20 | ]) 21 | }) 22 | } 23 | 24 | export function toImage(svg: string, width: number = 100) { 25 | const resvg = new Resvg(svg, { 26 | fitTo: { 27 | mode: 'width', 28 | value: width, 29 | }, 30 | font: { 31 | // As system fallback font 32 | fontFiles: [ 33 | join(process.cwd(), 'test', 'assets', 'playfair-display.ttf'), 34 | ], 35 | loadSystemFonts: false, 36 | defaultFontFamily: 'Playfair Display', 37 | }, 38 | }) 39 | const pngData = resvg.render() 40 | return pngData.asPng() 41 | } 42 | 43 | declare global { 44 | namespace jest { 45 | interface Matchers { 46 | toMatchImageSnapshot(): R 47 | } 48 | } 49 | } 50 | 51 | expect.extend({ toMatchImageSnapshot }) 52 | -------------------------------------------------------------------------------- /test/white-space.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { it, describe, expect } from 'vitest' 3 | 4 | import { initFonts, toImage } from './utils' 5 | import satori from '../src' 6 | 7 | describe('white-space', () => { 8 | let fonts 9 | initFonts((f) => (fonts = f)) 10 | 11 | describe('normal', () => { 12 | it('should not render extra spaces with `white-space: normal`', async () => { 13 | const svg = await satori( 14 |
19 | {' hello '} 20 |
, 21 | { 22 | width: 100, 23 | height: 100, 24 | fonts, 25 | } 26 | ) 27 | 28 | expect(toImage(svg, 100)).toMatchImageSnapshot() 29 | }) 30 | 31 | it('should not render extra line breaks with `white-space: normal`', async () => { 32 | const svg = await satori( 33 |
38 | {' hello \n world'} 39 |
, 40 | { 41 | width: 100, 42 | height: 100, 43 | fonts, 44 | } 45 | ) 46 | expect(toImage(svg, 100)).toMatchImageSnapshot() 47 | }) 48 | 49 | it('should wrap automatically with `white-space: normal`', async () => { 50 | const svg = await satori( 51 |
56 | hello, world 57 |
, 58 | { 59 | width: 20, 60 | height: 100, 61 | fonts, 62 | } 63 | ) 64 | expect(toImage(svg, 20)).toMatchImageSnapshot() 65 | }) 66 | }) 67 | 68 | describe('pre', () => { 69 | it('should always preserve extra spaces with `white-space: pre`', async () => { 70 | const svg = await satori( 71 |
76 | {' hello '} 77 |
, 78 | { 79 | width: 100, 80 | height: 100, 81 | fonts, 82 | } 83 | ) 84 | expect(toImage(svg, 100)).toMatchImageSnapshot() 85 | }) 86 | 87 | it('should always preserve extra line breaks with `white-space: pre`', async () => { 88 | const svg = await satori( 89 |
94 | {' hello \n world '} 95 |
, 96 | { 97 | width: 100, 98 | height: 100, 99 | fonts, 100 | embedFont: false, 101 | } 102 | ) 103 | expect(toImage(svg, 100)).toMatchImageSnapshot() 104 | }) 105 | 106 | it('should not wrap with `white-space: pre`', async () => { 107 | const svg = await satori( 108 |
113 | hello, world 114 |
, 115 | { 116 | width: 20, 117 | height: 100, 118 | fonts, 119 | embedFont: false, 120 | } 121 | ) 122 | expect(toImage(svg, 20)).toMatchImageSnapshot() 123 | }) 124 | }) 125 | 126 | describe('with `white-space: pre-wrap`', () => { 127 | it('should always preserve extra spaces with `white-space: pre-wrap`', async () => { 128 | const svg = await satori( 129 |
134 | {' hello '} 135 |
, 136 | { 137 | width: 100, 138 | height: 100, 139 | fonts, 140 | } 141 | ) 142 | expect(toImage(svg, 100)).toMatchImageSnapshot() 143 | }) 144 | 145 | it('should always preserve extra line breaks with `white-space: pre-wrap`', async () => { 146 | const svg = await satori( 147 |
152 | {' hello \n world'} 153 |
, 154 | { 155 | width: 100, 156 | height: 100, 157 | fonts, 158 | embedFont: false, 159 | } 160 | ) 161 | expect(toImage(svg, 100)).toMatchImageSnapshot() 162 | }) 163 | 164 | it('should automatically wrap with `white-space: pre-wrap`', async () => { 165 | const svg = await satori( 166 |
171 | hello, world 172 |
, 173 | { 174 | width: 20, 175 | height: 100, 176 | fonts, 177 | embedFont: false, 178 | } 179 | ) 180 | expect(toImage(svg, 20)).toMatchImageSnapshot() 181 | }) 182 | }) 183 | 184 | describe('with `white-space: nowrap`', () => { 185 | it('should not wrap with `white-space: nowrap` and swallow extra spaces', async () => { 186 | const svg = await satori( 187 |
192 | {` hello, world `} 193 |
, 194 | { 195 | width: 20, 196 | height: 100, 197 | fonts, 198 | embedFont: false, 199 | } 200 | ) 201 | expect(toImage(svg, 20)).toMatchImageSnapshot() 202 | }) 203 | }) 204 | }) 205 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "moduleResolution": "node", 5 | "esModuleInterop": true, 6 | "target": "ES2020", 7 | "lib": ["esnext", "dom"], 8 | "baseUrl": ".", 9 | "paths": { 10 | "@yoga": ["src/yoga/yoga-prebuilt.ts"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.wasm.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "moduleResolution": "node", 5 | "esModuleInterop": true, 6 | "lib": ["esnext", "dom"], 7 | "baseUrl": ".", 8 | "paths": { 9 | "@yoga": ["src/yoga/yoga-prebuilt.wasm.ts"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This configuration ensures that the prebuilt Yoga (asm.js) is not included in 3 | * the WASM bundle. 4 | */ 5 | 6 | import { defineConfig } from 'tsup' 7 | import { join } from 'path' 8 | import { replace } from 'esbuild-plugin-replace' 9 | 10 | export default defineConfig({ 11 | entry: ['src/index.ts'], 12 | splitting: false, 13 | sourcemap: true, 14 | dts: process.env.NODE_ENV !== 'development', 15 | minify: process.env.NODE_ENV !== 'development', 16 | legacyOutput: true, 17 | format: ['esm'], 18 | noExternal: ['twrnc'], 19 | esbuildOptions(options) { 20 | if (process.env.WASM) { 21 | options.outExtension = { 22 | '.js': '.wasm.js', 23 | } 24 | } 25 | options.tsconfig = process.env.WASM ? 'tsconfig.wasm.json' : 'tsconfig.json' 26 | options.legalComments = 'external' 27 | }, 28 | esbuildPlugins: [ 29 | { 30 | name: 'optimize tailwind', 31 | setup(build) { 32 | // Get rid of chalk 33 | // https://github.com/tailwindlabs/tailwindcss/blob/b8cda161dd0993083dcef1e2a03988c70be0ce93/src/util/log.js 34 | build.onResolve({ filter: /\/log$/ }, (args) => { 35 | if (args.importer.includes('/tailwindcss/')) { 36 | return { 37 | path: join(__dirname, 'src', 'vendor', 'twrnc', 'log.js'), 38 | } 39 | } 40 | }) 41 | 42 | // Get rid of picocolors 43 | // https://github.com/tailwindlabs/tailwindcss/blob/bf4494104953b13a5f326b250d7028074815e77e/src/featureFlags.js 44 | build.onResolve({ filter: /^picocolors$/ }, () => { 45 | return { 46 | path: join(__dirname, 'src', 'vendor', 'twrnc', 'picocolors.js'), 47 | } 48 | }) 49 | 50 | // Get rid of util-deprecate/node.js 51 | build.onResolve({ filter: /util-deprecate/ }, () => { 52 | return { 53 | path: join(__dirname, 'src', 'vendor', 'twrnc', 'deprecate.js'), 54 | } 55 | }) 56 | }, 57 | }, 58 | // We don't like `Function`. 59 | // https://github.com/tailwindlabs/tailwindcss/blob/bf4494104953b13a5f326b250d7028074815e77e/src/util/getAllConfigs.js#L8 60 | replace({ 61 | 'preset instanceof Function': 'typeof preset === "function"', 62 | }), 63 | ], 64 | }) 65 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { defineConfig } from 'vitest/config' 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: [ 7 | { 8 | find: '@yoga', 9 | replacement: path.resolve(__dirname, 'src', 'yoga', 'yoga-prebuilt.ts'), 10 | }, 11 | ], 12 | }, 13 | }) 14 | --------------------------------------------------------------------------------