├── demo ├── src │ ├── env.d.ts │ ├── bgPattern.png │ ├── astro-docs-logo.png │ ├── pages │ │ ├── local-font-test │ │ │ ├── _fonts │ │ │ │ └── ibm-plex-mono-latin-400-normal.ttf │ │ │ ├── index.md │ │ │ └── [path].ts │ │ ├── nested │ │ │ └── index.md │ │ ├── formats │ │ │ ├── index.md │ │ │ └── [path].ts │ │ ├── og │ │ │ └── [...route].ts │ │ ├── index.md │ │ ├── font-test │ │ │ ├── index.astro │ │ │ ├── _pages.ts │ │ │ ├── _pages-copy.ts │ │ │ └── [path].ts │ │ └── background-test │ │ │ ├── index.md │ │ │ └── [path].ts │ └── layouts │ │ └── Layout.astro ├── tsconfig.json ├── public │ └── favicon.ico ├── astro.config.mjs ├── README.md └── package.json ├── pnpm-workspace.yaml ├── .prettierrc ├── packages └── astro-og-canvas │ ├── src │ ├── index.ts │ ├── routing.ts │ ├── shorthash.ts │ ├── queue.ts │ ├── assetLoaders.ts │ ├── types.ts │ └── generateOpenGraphImage.ts │ ├── tsconfig.json │ ├── package.json │ ├── CHANGELOG.md │ └── README.md ├── .gitignore ├── .changeset ├── config.json └── README.md ├── README.md ├── package.json ├── test └── astro-auto-import.ts └── .github └── workflows ├── ci.yml └── release.yml /demo/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/base" 3 | } -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - 'packages/**/*' 3 | - 'demo' 4 | -------------------------------------------------------------------------------- /demo/src/bgPattern.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delucis/astro-og-canvas/HEAD/demo/src/bgPattern.png -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delucis/astro-og-canvas/HEAD/demo/public/favicon.ico -------------------------------------------------------------------------------- /demo/src/astro-docs-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delucis/astro-og-canvas/HEAD/demo/src/astro-docs-logo.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "useTabs": false 6 | } 7 | -------------------------------------------------------------------------------- /demo/astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config'; 2 | 3 | // https://astro.build/config 4 | export default defineConfig({}); 5 | -------------------------------------------------------------------------------- /packages/astro-og-canvas/src/index.ts: -------------------------------------------------------------------------------- 1 | export { generateOpenGraphImage } from './generateOpenGraphImage'; 2 | export { OGImageRoute } from './routing'; 3 | -------------------------------------------------------------------------------- /demo/src/pages/local-font-test/_fonts/ibm-plex-mono-latin-400-normal.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/delucis/astro-og-canvas/HEAD/demo/src/pages/local-font-test/_fonts/ibm-plex-mono-latin-400-normal.ttf -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # Demo Site 2 | 3 | This is a demo Astro project to test the `astro-og-canvas` package. 4 | 5 | `cd` into this directory and run `pnpm start` to run the project with a development server or `pnpm build` to build it. 6 | -------------------------------------------------------------------------------- /demo/src/layouts/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | 5 | 6 | 7 | astro-og-canvas 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # dependencies 5 | node_modules/ 6 | 7 | # logs 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | pnpm-debug.log* 12 | 13 | 14 | # environment variables 15 | .env 16 | .env.production 17 | 18 | # macOS-specific files 19 | .DS_Store 20 | -------------------------------------------------------------------------------- /packages/astro-og-canvas/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strictest", 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "declaration": true, 6 | "noEmit": false, 7 | "allowImportingTsExtensions": false, 8 | "outDir": "./dist", 9 | "skipLibCheck": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json", 3 | "changelog": ["@changesets/changelog-github", { "repo": "delucis/astro-og-canvas" }], 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "latest", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /demo/src/pages/local-font-test/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Local Font Test 3 | description: Test page demonstrating that a local font file can be loaded and used. 4 | layout: ../../layouts/Layout.astro 5 | --- 6 | 7 | # Astro OpenGraph Images with CanvasKit 8 | 9 | This test loads a local font file to render its text. 10 | 11 | ![Example image](/local-font-test/local.png) 12 | 13 | - [Home](/) 14 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@demo/astro-og-canvas", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro build", 9 | "preview": "astro preview" 10 | }, 11 | "devDependencies": { 12 | "astro": "^5.15.9", 13 | "astro-og-canvas": "workspace:*", 14 | "canvaskit-wasm": "^0.40.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /demo/src/pages/nested/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Nested Page 3 | description: Test page demonstrating that a generated image shows up correctly — this page is nested 4 | layout: ../../layouts/Layout.astro 5 | --- 6 | 7 | # Astro OpenGraph Images with CanvasKit 8 | 9 | This test project uses `astro-og-canvas` to generate OpenGraph images from code. 10 | 11 | ![Example image](/og/nested.png) 12 | 13 | - [Home](/) 14 | -------------------------------------------------------------------------------- /demo/src/pages/formats/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Different formats 3 | description: Test page demonstrating that images render correctly in different formats 4 | layout: ../../layouts/Layout.astro 5 | --- 6 | 7 | # Astro OpenGraph Images with CanvasKit 8 | 9 | This test renders the same image to different formats. 10 | 11 | ![Example PNG](/formats/png.png) 12 | 13 | ![Example JPEG](/formats/jpeg.jpeg) 14 | 15 | ![Example WEBP](/formats/webp.webp) 16 | 17 | - [Home](/) 18 | -------------------------------------------------------------------------------- /demo/src/pages/og/[...route].ts: -------------------------------------------------------------------------------- 1 | import { OGImageRoute } from 'astro-og-canvas'; 2 | 3 | export const { getStaticPaths, GET } = OGImageRoute({ 4 | param: 'route', 5 | pages: await import.meta.glob('/src/pages/**/*.md', { eager: true }), 6 | getImageOptions: (_path, page) => ({ 7 | title: page.frontmatter.title, 8 | description: page.frontmatter.description, 9 | logo: { path: './src/astro-docs-logo.png', size: [350] }, 10 | border: { width: 10 }, 11 | padding: 40, 12 | }), 13 | }); 14 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /demo/src/pages/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Test Page 3 | description: Test page demonstrating that a generated image shows up correctly 4 | layout: ../layouts/Layout.astro 5 | --- 6 | 7 | # Astro OpenGraph Images with CanvasKit 8 | 9 | This test project uses `astro-og-canvas` to generate OpenGraph images from code. 10 | 11 | ![Example image](/og/index.png) 12 | 13 | - [Nested](/nested) 14 | - [Font Tests](/font-test) 15 | - [Local font test](/local-font-test) 16 | - [Background image tests](/background-test) 17 | - [Different formats](/formats) 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Astro OG Canvas 2 | 3 | ## 🔗 Looking for the main package? 4 | 5 | [Jump to `astro-og-canvas` →](/packages/astro-og-canvas/) 6 | 7 | ## 🚀 Project Structure 8 | 9 | This project uses **workspaces** to develop a single package from `packages/astro-og-canvas`. It also includes a `demo` Astro site for testing and demonstrating the integration. 10 | 11 | ## 🧞 Commands 12 | 13 | All commands are run from the root of the project, from a terminal: 14 | 15 | | Command | Action | 16 | | :------------- | :-------------------- | 17 | | `pnpm install` | Installs dependencies | 18 | | `pnpm test` | Run unit tests | 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@root/astro-og-canvas", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "build:demo": "pnpm --filter \"@demo/astro-og-canvas\" build", 7 | "prepublish": "pnpm --filter \"astro-og-canvas\" build", 8 | "pretest": "pnpm prepublish && pnpm build:demo", 9 | "test": "tsm node_modules/uvu/bin.js test" 10 | }, 11 | "devDependencies": { 12 | "@changesets/changelog-github": "^0.5.1", 13 | "@changesets/cli": "^2.29.7", 14 | "astro": "^5.15.9", 15 | "tsm": "^2.3.0", 16 | "typescript": "^5.0.0", 17 | "uvu": "^0.5.6" 18 | }, 19 | "packageManager": "pnpm@10.18.2" 20 | } 21 | -------------------------------------------------------------------------------- /demo/src/pages/local-font-test/[path].ts: -------------------------------------------------------------------------------- 1 | import { OGImageRoute } from 'astro-og-canvas'; 2 | 3 | export const { getStaticPaths, GET } = OGImageRoute({ 4 | param: 'path', 5 | pages: { 6 | 'local.md': { 7 | title: 'Local fonts', 8 | description: 'Loads IBM Plex Mono from a local font file', 9 | }, 10 | }, 11 | getImageOptions: (_path, page) => ({ 12 | ...page, 13 | border: { color: [100, 100, 100], width: 20, side: 'block-end' }, 14 | bgGradient: [[0, 0, 0]], 15 | padding: 60, 16 | font: { 17 | title: { size: 90, families: ['IBM Plex Mono'], weight: 'Normal' }, 18 | description: { families: ['IBM Plex Mono'] }, 19 | }, 20 | fonts: ['./src/pages/local-font-test/_fonts/ibm-plex-mono-latin-400-normal.ttf'], 21 | }), 22 | }); 23 | -------------------------------------------------------------------------------- /demo/src/pages/font-test/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '../../layouts/Layout.astro'; 3 | import { pages } from './_pages-copy'; 4 | --- 5 | 6 | 7 |
8 | { 9 | Object.entries(pages).map(([key, { title, description, dir }]) => ( 10 |
11 |
12 |

13 |

{description}

14 |

15 | 16 |
17 | )) 18 | } 19 |
20 |
21 | 22 | 39 | -------------------------------------------------------------------------------- /demo/src/pages/formats/[path].ts: -------------------------------------------------------------------------------- 1 | import { OGImageRoute } from 'astro-og-canvas'; 2 | 3 | export const { getStaticPaths, GET } = OGImageRoute({ 4 | param: 'path', 5 | pages: { 6 | 'png.md': { 7 | title: 'Test image (PNG)', 8 | description: 'Renders an Open Graph image to a PNG file', 9 | format: 'PNG', 10 | }, 11 | 'jpeg.md': { 12 | title: 'Test image (JPEG)', 13 | description: 'Renders an Open Graph image to a JPEG file', 14 | format: 'JPEG', 15 | }, 16 | 'webp.md': { 17 | title: 'Test image (WEBP)', 18 | description: 'Renders an Open Graph image to a WEBP file', 19 | format: 'WEBP', 20 | }, 21 | }, 22 | getSlug(path, { format }) { 23 | const ext = format.toLowerCase(); 24 | path = path.replace(/^\/src\/pages\//, ''); 25 | path = path.replace(/\.[^.]*$/, '') + '.' + ext; 26 | return path; 27 | }, 28 | getImageOptions: (_path, page) => ({ 29 | ...page, 30 | }), 31 | }); 32 | -------------------------------------------------------------------------------- /test/astro-auto-import.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import { readFileSync } from 'fs'; 4 | 5 | function loadRoute(path: string): string { 6 | const rel = `../demo/dist/${path}/index.html`.replaceAll(/\/{2,}/g, '/'); 7 | return readFileSync(new URL(rel, import.meta.url), 'utf-8'); 8 | } 9 | 10 | function loadImage(path: string) { 11 | const rel = `../demo/dist/${path}`.replaceAll(/\/{2,}/g, '/'); 12 | return readFileSync(new URL(rel, import.meta.url)); 13 | } 14 | 15 | test('it should have created an image in the build output', () => { 16 | assert.not.throws(()=>loadImage('/og/index.png')); 17 | }); 18 | 19 | test('it should have built the index page correctly', () => { 20 | const page = loadRoute('/'); 21 | assert.match(page, /Example image/); 22 | }); 23 | 24 | test('it should have rendered a JPEG correctly', () => { 25 | const buff = loadImage('/formats/jpeg.jpeg'); 26 | assert.not.equal(buff.length, 0); 27 | }) 28 | 29 | test.run(); -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | permissions: {} 3 | 4 | on: 5 | push: 6 | branches: [latest] 7 | pull_request: 8 | branches: [latest] 9 | 10 | # Automatically cancel in-progress actions on the same branch 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.event_name == 'pull_request_target' && github.head_ref || github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | test: 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, windows-latest] 20 | astro: [^3, ^4, ^5] 21 | runs-on: ${{ matrix.os }} 22 | steps: 23 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 24 | with: 25 | persist-credentials: false 26 | 27 | - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 28 | 29 | - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 30 | with: 31 | node-version: 18.x 32 | 33 | - run: pnpm i 34 | 35 | - run: pnpm i --workspace-root astro@${{ matrix.astro }} 36 | - run: pnpm i astro@${{ matrix.astro }} 37 | working-directory: packages/astro-og-canvas 38 | - run: pnpm i astro@${{ matrix.astro }} 39 | working-directory: demo 40 | 41 | - name: Test 42 | run: pnpm t 43 | -------------------------------------------------------------------------------- /packages/astro-og-canvas/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-og-canvas", 3 | "description": "Generate OpenGraph images for your Astro site", 4 | "version": "0.7.2", 5 | "type": "module", 6 | "types": "./dist/index.d.ts", 7 | "author": "Chris Swithinbank (http://chrisswithinbank.net/)", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/delucis/astro-og-canvas.git", 12 | "directory": "packages/astro-og-canvas" 13 | }, 14 | "bugs": "https://github.com/delucis/astro-og-canvas/issues", 15 | "exports": { 16 | ".": "./dist/index.js" 17 | }, 18 | "files": [ 19 | "dist" 20 | ], 21 | "scripts": { 22 | "build": "tsc", 23 | "prepublish": "pnpm build" 24 | }, 25 | "keywords": [ 26 | "withastro", 27 | "astro-integration", 28 | "open-graph", 29 | "seo", 30 | "images" 31 | ], 32 | "devDependencies": { 33 | "@types/node": "^18.19.130", 34 | "astro": "^5.15.9", 35 | "typescript": "^5.0.0" 36 | }, 37 | "dependencies": { 38 | "canvaskit-wasm": "^0.40.0", 39 | "deterministic-object-hash": "^2.0.2", 40 | "entities": "^7.0.0" 41 | }, 42 | "peerDependencies": { 43 | "astro": "^3.0.0 || ^4.0.0 || ^5.0.0" 44 | }, 45 | "engines": { 46 | "node": ">=18.14.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | permissions: {} 3 | 4 | on: 5 | push: 6 | branches: 7 | - latest 8 | 9 | concurrency: ${{ github.workflow }}-${{ github.ref }} 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | if: ${{ github.repository_owner == 'delucis' }} 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | pull-requests: write 19 | id-token: write 20 | steps: 21 | - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 22 | with: 23 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 24 | fetch-depth: 0 25 | persist-credentials: false 26 | 27 | - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 28 | 29 | - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 30 | with: 31 | node-version: 24 32 | 33 | - run: pnpm i 34 | 35 | - name: Create Release Pull Request 36 | uses: changesets/action@e0145edc7d9d8679003495b11f87bd8ef63c0cba # v1.5.3 37 | with: 38 | publish: npx changeset publish 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | NPM_TOKEN: "" # See https://github.com/changesets/changesets/issues/1152#issuecomment-3190884868 -------------------------------------------------------------------------------- /demo/src/pages/background-test/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Background Test 3 | description: Test page demonstrating a background image. 4 | layout: ../../layouts/Layout.astro 5 | --- 6 | 7 | # Astro OpenGraph Images with CanvasKit 8 | 9 | This test loads a background image with various settings. 10 | 11 |
12 | Example image 13 | Example image 14 | Example image 15 | Example image 16 | Example image 17 | Example image 18 | Example image 19 | Example image 20 | Example image 21 | Example image 22 | Example image 23 | Example image 24 |
25 | 26 | - [Home](/) 27 | 28 | 39 | -------------------------------------------------------------------------------- /packages/astro-og-canvas/src/routing.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute, GetStaticPaths } from 'astro'; 2 | import { generateOpenGraphImage } from './generateOpenGraphImage'; 3 | import type { OGImageOptions } from './types'; 4 | 5 | const pathToSlug = (path: string): string => { 6 | path = path.replace(/^\/src\/pages\//, ''); 7 | path = path.replace(/\.[^\.]*$/, '') + '.png'; 8 | path = path.replace(/\/index\.png$/, '.png'); 9 | return path; 10 | }; 11 | 12 | function makeGetStaticPaths({ 13 | pages, 14 | param, 15 | getSlug = pathToSlug, 16 | }: OGImageRouteConfig): GetStaticPaths { 17 | const slugs = Object.entries(pages).map((page) => getSlug(...page)); 18 | const paths = slugs.map((slug) => ({ params: { [param]: slug } })); 19 | return function getStaticPaths() { 20 | return paths; 21 | }; 22 | } 23 | 24 | function createOGImageEndpoint({ getSlug = pathToSlug, ...opts }: OGImageRouteConfig): APIRoute { 25 | return async function getOGImage({ params }) { 26 | const pageEntry = Object.entries(opts.pages).find( 27 | (page) => { 28 | const slug = getSlug(...page); 29 | return slug === params[opts.param] || slug.replace(/^\//, "") === params[opts.param]; 30 | } 31 | ); 32 | if (!pageEntry) return new Response('Page not found', { status: 404 }); 33 | 34 | return new Response(await generateOpenGraphImage( 35 | await opts.getImageOptions(...pageEntry) 36 | )); 37 | }; 38 | } 39 | 40 | export function OGImageRoute(opts: OGImageRouteConfig): { 41 | getStaticPaths: GetStaticPaths; 42 | GET: APIRoute; 43 | } { 44 | return { 45 | getStaticPaths: makeGetStaticPaths(opts), 46 | GET: createOGImageEndpoint(opts), 47 | }; 48 | } 49 | 50 | interface OGImageRouteConfig { 51 | pages: { [path: string]: any }; 52 | param: string; 53 | getSlug?: (path: string, page: any) => string; 54 | getImageOptions: (path: string, page: any) => OGImageOptions | Promise; 55 | } 56 | -------------------------------------------------------------------------------- /demo/src/pages/font-test/_pages.ts: -------------------------------------------------------------------------------- 1 | export const pages: Record = { 2 | 'ar-editor-setup.md': { 3 | title: 'إعداد البيئة البرمجية', 4 | description: 'أعِد محرر الشفرة لبناء المشاريع مع Astro', 5 | dir: 'rtl', 6 | }, 7 | 'markdown.md': { 8 | title: 'Markdown & MDX', 9 | description: 'Learn how to create content using Markdown or MDX with Astro', 10 | }, 11 | 'components.md': { 12 | title: 'Components', 13 | description: 'An intro to the .astro component syntax.', 14 | }, 15 | 'project-structure.md': { 16 | title: 'Project Structure', 17 | description: 'Learn how to structure a project with Astro.', 18 | }, 19 | 'why-astro.md': { 20 | title: 'Why Astro?', 21 | description: 22 | 'Astro is an all-in-one web framework for building fast, content-focused websites. Learn more.', 23 | }, 24 | 'install-auto.md': { 25 | title: 'Install Astro with the Automatic CLI', 26 | description: 'How to install Astro with NPM, PNPM, or Yarn via the create-astro CLI tool.', 27 | }, 28 | 'zh-editor-setup.md': { 29 | title: '编辑器配置', 30 | description: '配置与 Astro 一同使用的编辑器', 31 | }, 32 | 'ru-getting-started.md': { 33 | title: 'Начало Работы', 34 | description: 'Основное введение в Astro.', 35 | }, 36 | 'de-config-reference.md': { 37 | title: 'Konfigurations­referenz', 38 | description: '', 39 | }, 40 | 'en-concepts-mpa-vs-spa.md': { 41 | title: 'MPAs vs. SPAs', 42 | description: 43 | 'Understanding the tradeoffs between Multi-Page Application (MPA) and Single-Page Application (SPA) architecture is key to understanding what makes Astro different from other web frameworks.', 44 | }, 45 | 'es-guides-deploy-flightcontrol.md': { 46 | title: 'Despliega tu proyecto de Astro en AWS con Flightcontrol', 47 | description: 'Cómo desplegar tu proyecto de Astro en AWS con Flightcontrol', 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /demo/src/pages/font-test/_pages-copy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An Astro bug means importing the same `_pages.ts` into both `index.astro` 3 | * and `[path].ts` triggers an error. Duplicating the data so that each route 4 | * imports its own copy works fine. 5 | */ 6 | export const pages: Record = { 7 | 'ar-editor-setup.md': { 8 | title: 'إعداد البيئة البرمجية', 9 | description: 'أعِد محرر الشفرة لبناء المشاريع مع Astro', 10 | dir: 'rtl', 11 | }, 12 | 'markdown.md': { 13 | title: 'Markdown & MDX', 14 | description: 'Learn how to create content using Markdown or MDX with Astro', 15 | }, 16 | 'components.md': { 17 | title: 'Components', 18 | description: 'An intro to the .astro component syntax.', 19 | }, 20 | 'project-structure.md': { 21 | title: 'Project Structure', 22 | description: 'Learn how to structure a project with Astro.', 23 | }, 24 | 'why-astro.md': { 25 | title: 'Why Astro?', 26 | description: 27 | 'Astro is an all-in-one web framework for building fast, content-focused websites. Learn more.', 28 | }, 29 | 'install-auto.md': { 30 | title: 'Install Astro with the Automatic CLI', 31 | description: 'How to install Astro with NPM, PNPM, or Yarn via the create-astro CLI tool.', 32 | }, 33 | 'zh-editor-setup.md': { 34 | title: '编辑器配置', 35 | description: '配置与 Astro 一同使用的编辑器', 36 | }, 37 | 'ru-getting-started.md': { 38 | title: 'Начало Работы', 39 | description: 'Основное введение в Astro.', 40 | }, 41 | 'de-config-reference.md': { 42 | title: 'Konfigurations­referenz', 43 | description: '', 44 | }, 45 | 'en-concepts-mpa-vs-spa.md': { 46 | title: 'MPAs vs. SPAs', 47 | description: 48 | 'Understanding the tradeoffs between Multi-Page Application (MPA) and Single-Page Application (SPA) architecture is key to understanding what makes Astro different from other web frameworks.', 49 | }, 50 | 'es-guides-deploy-flightcontrol.md': { 51 | title: 'Despliega tu proyecto de Astro en AWS con Flightcontrol', 52 | description: 'Cómo desplegar tu proyecto de Astro en AWS con Flightcontrol', 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /demo/src/pages/font-test/[path].ts: -------------------------------------------------------------------------------- 1 | import { OGImageRoute } from 'astro-og-canvas'; 2 | import { pages } from './_pages'; 3 | 4 | export const { getStaticPaths, GET } = OGImageRoute({ 5 | param: 'path', 6 | pages, 7 | getImageOptions: (_path, page) => ({ 8 | title: page.title, 9 | description: page.description, 10 | dir: page.dir, 11 | logo: { path: './src/astro-docs-logo.png', size: [400] }, 12 | border: { color: [255, 93, 1], width: 20, side: 'inline-start' }, 13 | bgGradient: [ 14 | [42, 35, 62], 15 | [23, 20, 36], 16 | ], 17 | padding: 60, 18 | font: { 19 | title: { 20 | size: 78, 21 | families: [ 22 | 'Work Sans', 23 | 'Noto Sans Black', 24 | 'Noto Sans Arabic', 25 | 'Noto Sans SC Black', 26 | 'Noto Sans TC Black', 27 | 'Noto Sans JP Black', 28 | ], 29 | weight: 'ExtraBold', 30 | }, 31 | description: { 32 | size: 45, 33 | lineHeight: 1.25, 34 | families: [ 35 | 'Work Sans', 36 | 'Noto Sans', 37 | 'Noto Sans Arabic', 38 | 'Noto Sans SC', 39 | 'Noto Sans TC', 40 | 'Noto Sans JP', 41 | ], 42 | weight: 'Normal', 43 | }, 44 | }, 45 | fonts: [ 46 | 'https://api.fontsource.org/v1/fonts/work-sans/latin-400-normal.ttf', 47 | 'https://api.fontsource.org/v1/fonts/work-sans/latin-800-normal.ttf', 48 | 49 | 'https://api.fontsource.org/v1/fonts/noto-sans/cyrillic-400-normal.ttf', 50 | 'https://api.fontsource.org/v1/fonts/noto-sans/cyrillic-900-normal.ttf', 51 | 52 | 'https://api.fontsource.org/v1/fonts/noto-sans-sc/chinese-simplified-400-normal.ttf', 53 | 'https://api.fontsource.org/v1/fonts/noto-sans-sc/chinese-simplified-900-normal.ttf', 54 | 55 | 'https://api.fontsource.org/v1/fonts/noto-sans-tc/chinese-traditional-400-normal.ttf', 56 | 'https://api.fontsource.org/v1/fonts/noto-sans-tc/chinese-traditional-900-normal.ttf', 57 | 58 | 'https://api.fontsource.org/v1/fonts/noto-sans-jp/japanese-400-normal.ttf', 59 | 'https://api.fontsource.org/v1/fonts/noto-sans-jp/japanese-900-normal.ttf', 60 | 61 | 'https://api.fontsource.org/v1/fonts/noto-sans-arabic/arabic-400-normal.ttf', 62 | 'https://api.fontsource.org/v1/fonts/noto-sans-arabic/arabic-800-normal.ttf', 63 | ], 64 | }), 65 | }); 66 | -------------------------------------------------------------------------------- /packages/astro-og-canvas/src/shorthash.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * shorthash - https://github.com/bibig/node-shorthash 3 | * 4 | * @license 5 | * 6 | * (The MIT License) 7 | * 8 | * Copyright (c) 2013 Bibig 9 | * 10 | * Permission is hereby granted, free of charge, to any person 11 | * obtaining a copy of this software and associated documentation 12 | * files (the "Software"), to deal in the Software without 13 | * restriction, including without limitation the rights to use, 14 | * copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | * copies of the Software, and to permit persons to whom the 16 | * Software is furnished to do so, subject to the following 17 | * conditions: 18 | * 19 | * The above copyright notice and this permission notice shall be 20 | * included in all copies or substantial portions of the Software. 21 | * 22 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 23 | * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 24 | * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 25 | * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 26 | * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 27 | * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 28 | * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 29 | * OTHER DEALINGS IN THE SOFTWARE. 30 | */ 31 | 32 | const dictionary = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXY'; 33 | const binary = dictionary.length; 34 | 35 | // refer to: http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/ 36 | function bitwise(str: string) { 37 | let hash = 0; 38 | if (str.length === 0) return hash; 39 | for (let i = 0; i < str.length; i++) { 40 | const ch = str.charCodeAt(i); 41 | hash = (hash << 5) - hash + ch; 42 | hash = hash & hash; // Convert to 32bit integer 43 | } 44 | return hash; 45 | } 46 | 47 | export function shorthash(text: string) { 48 | let num: number; 49 | let result = ''; 50 | 51 | let integer = bitwise(text); 52 | const sign = integer < 0 ? 'Z' : ''; // If it's negative, start with Z, which isn't in the dictionary 53 | 54 | integer = Math.abs(integer); 55 | 56 | while (integer >= binary) { 57 | num = integer % binary; 58 | integer = Math.floor(integer / binary); 59 | result = dictionary[num] + result; 60 | } 61 | 62 | if (integer > 0) { 63 | result = dictionary[integer] + result; 64 | } 65 | 66 | return sign + result; 67 | } 68 | -------------------------------------------------------------------------------- /packages/astro-og-canvas/src/queue.ts: -------------------------------------------------------------------------------- 1 | // Derived from https://github.com/sindresorhus/p-limit under MIT License 2 | // p-limit is copyright (c) Sindre Sorhus (https://sindresorhus.com) 3 | 4 | type ResolveCallback = (val: T | PromiseLike) => void; 5 | 6 | /** 7 | * This is a queuing/limiting system based on the `p-limit` npm package, but heavily simplified 8 | * to a minimal API surface. All it does is ensure tasks run one at a time, sequentially. 9 | * 10 | * @example 11 | * const queue = pQueue(); 12 | * const input = [ 13 | * limit(() => fetchSomething('foo')), 14 | * limit(() => fetchSomething('bar')), 15 | * limit(() => doSomething()) 16 | * ]; 17 | * // Each promise is run sequentially rather than in parallel. 18 | * await Promise.all(input); 19 | */ 20 | export function pQueue() { 21 | const queue: Array<(val?: unknown) => void> = []; 22 | let activeCount = 0; 23 | 24 | /** Process the next queued function if we're under the concurrency limit. */ 25 | const resumeNext = () => { 26 | if (activeCount < 1 && queue.length > 0) { 27 | activeCount++; 28 | queue.shift()!(); 29 | } 30 | }; 31 | 32 | const run = async (task: () => T, resolve: ResolveCallback) => { 33 | // Execute the function and capture the result promise 34 | const result = (async () => task())(); 35 | 36 | // Resolve immediately with the promise (don't wait for completion) 37 | resolve(result); 38 | 39 | // Wait for the function to complete (success or failure) 40 | // We catch errors here to prevent unhandled rejections, 41 | // but the original promise rejection is preserved for the caller 42 | try { 43 | await result; 44 | } catch {} 45 | 46 | // Decrement active count and process next queued function 47 | activeCount--; 48 | resumeNext(); 49 | }; 50 | 51 | const enqueue = (task: () => T, resolve: ResolveCallback) => { 52 | // Queue the internal resolve function instead of the run function 53 | // to preserve the asynchronous execution context. 54 | new Promise((internalResolve) => { 55 | queue.push(internalResolve); 56 | }).then(() => run(task, resolve)); 57 | 58 | // Start processing immediately if we haven't reached the concurrency limit 59 | if (activeCount < 1) resumeNext(); 60 | }; 61 | 62 | /** Run `task` when any previously enqueued tasks have completed. */ 63 | const generator = (task: () => T) => 64 | new Promise((resolve) => { 65 | enqueue(task, resolve); 66 | }); 67 | 68 | return generator; 69 | } 70 | -------------------------------------------------------------------------------- /demo/src/pages/background-test/[path].ts: -------------------------------------------------------------------------------- 1 | import { OGImageRoute } from 'astro-og-canvas'; 2 | 3 | const logoPath = './src/astro-docs-logo.png'; 4 | const bgPath = './src/bgPattern.png'; 5 | 6 | export const { getStaticPaths, GET } = OGImageRoute({ 7 | param: 'path', 8 | pages: { 9 | 'contain.md': { 10 | bgImage: { path: bgPath, fit: 'contain' }, 11 | title: 'bgImage: { fit: "contain" }', 12 | }, 13 | 'cover.md': { 14 | bgImage: { path: bgPath, fit: 'cover' }, 15 | title: 'bgImage: { fit: "cover" }', 16 | }, 17 | 'fill.md': { 18 | bgImage: { path: bgPath, fit: 'fill' }, 19 | title: 'bgImage: { fit: "fill" }', 20 | }, 21 | 'default.md': { 22 | bgImage: { path: bgPath, fit: 'none' }, 23 | title: 'bgImage: { fit: "none" }', 24 | }, 25 | 'logo-contain.md': { 26 | bgImage: { path: logoPath, fit: 'contain' }, 27 | title: 'bgImage: { fit: "contain" }', 28 | font: { title: { color: [0, 0, 0], weight: 'Bold' } }, 29 | }, 30 | 'logo-cover.md': { 31 | bgImage: { path: logoPath, fit: 'cover' }, 32 | title: 'bgImage: { fit: "cover" }', 33 | font: { title: { color: [0, 0, 0], weight: 'Bold' } }, 34 | }, 35 | 'logo-fill.md': { 36 | bgImage: { path: logoPath, fit: 'fill' }, 37 | title: 'bgImage: { fit: "fill" }', 38 | font: { title: { color: [0, 0, 0], weight: 'Bold' } }, 39 | }, 40 | 'logo-default.md': { 41 | bgImage: { path: logoPath, fit: 'none' }, 42 | title: 'bgImage: { fit: "none" }', 43 | font: { title: { color: [0, 0, 0], weight: 'Bold' } }, 44 | }, 45 | 'logo-start.md': { 46 | bgImage: { path: logoPath, position: 'start' }, 47 | title: 'bgImage: { position: "start" }', 48 | font: { title: { color: [0, 0, 0], weight: 'Bold' } }, 49 | }, 50 | 'logo-end.md': { 51 | bgImage: { path: logoPath, position: 'end' }, 52 | title: 'bgImage: { position: "end" }', 53 | font: { title: { color: [0, 0, 0], weight: 'Bold' } }, 54 | }, 55 | 'logo-center-end.md': { 56 | bgImage: { path: logoPath, position: ['center', 'end'] }, 57 | title: 'bgImage: {\n position: ["center", "end"],\n}', 58 | font: { title: { color: [0, 0, 0], weight: 'Bold' } }, 59 | }, 60 | 'logo-end-center.md': { 61 | bgImage: { path: logoPath, position: ['end', 'center'] }, 62 | title: 'bgImage: {\n position: ["end", "center"],\n}', 63 | font: { title: { color: [0, 0, 0], weight: 'Bold' } }, 64 | }, 65 | }, 66 | getImageOptions: (_path, page) => ({ 67 | title: '', 68 | description: '', 69 | logo: { path: logoPath, size: [350] }, 70 | bgGradient: [ 71 | [255, 0, 0], 72 | [0, 255, 0], 73 | [0, 0, 255], 74 | ], 75 | font: { title: { color: [0, 255, 0], weight: 'Bold' } }, 76 | ...page, 77 | }), 78 | }); 79 | -------------------------------------------------------------------------------- /packages/astro-og-canvas/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # astro-og-canvas 2 | 3 | ## 0.7.2 4 | 5 | ### Patch Changes 6 | 7 | - [#100](https://github.com/delucis/astro-og-canvas/pull/100) [`a5f25e2`](https://github.com/delucis/astro-og-canvas/commit/a5f25e2667d42a89afc5503be9b82d8626c2bf52) Thanks [@delucis](https://github.com/delucis)! - This package is now published using OIDC trusted publishing and provenance guarantees 8 | 9 | - [#99](https://github.com/delucis/astro-og-canvas/pull/99) [`6fc6258`](https://github.com/delucis/astro-og-canvas/commit/6fc62589cac2b1d4067e7730f4afb981d79c0ac8) Thanks [@delucis](https://github.com/delucis)! - Updates `entities` and `canvaskit-wasm` internal dependencies 10 | 11 | ## 0.7.1 12 | 13 | ### Patch Changes 14 | 15 | - e8bb055: Improves handling of cases where OG images are requested to be generated in parallel 16 | 17 | ## 0.7.0 18 | 19 | ### Minor Changes 20 | 21 | - 5dcccb4: Reverts v0.6.0 changes. NPM was failing to pack bundled dependencies as expected, so this release reverts things to the v0.5.x state until we have time to figure this out. 22 | 23 | ## 0.6.0 24 | 25 | ### Minor Changes 26 | 27 | - 330c56c: Bundles `canvaskit-wasm` to avoid users with strict package managers like PNPM needing to install it directly 28 | 29 | ## 0.5.6 30 | 31 | ### Patch Changes 32 | 33 | - bb13312: Fixes a README code example 34 | 35 | ## 0.5.5 36 | 37 | ### Patch Changes 38 | 39 | - ceeecc1: Expands peer dependencies to support Astro v5 40 | 41 | ## 0.5.4 42 | 43 | ### Patch Changes 44 | 45 | - 252e840: Adds an explicit `Buffer` import for Deno compatibility 46 | 47 | ## 0.5.3 48 | 49 | ### Patch Changes 50 | 51 | - 0bea94b: Refactors CanvasKit initialization to log more helpful error in PNPM projects without `canvaskit-wasm` installed 52 | 53 | ## 0.5.2 54 | 55 | ### Patch Changes 56 | 57 | - 9fec927: Fixes image generation for slugs with a leading slash in Astro ≥4.10.2 58 | 59 | ## 0.5.1 60 | 61 | ### Patch Changes 62 | 63 | - c7d3a7a: Improves README 64 | 65 | ## 0.5.0 66 | 67 | ### Minor Changes 68 | 69 | - e66e580: Switches to using the “full” `canvaskit-wasm` build to generate images. This fixes support for rendering as JPEG or WEBP instead of the default PNG. 70 | - e66e580: Updates `canvaskit-wasm` to the latest release 71 | 72 | **Note:** pnpm users may need to manually update in their project too: 73 | 74 | ```sh 75 | pnpm i canvaskit-wasm@^0.39.1 76 | ``` 77 | 78 | ## 0.4.2 79 | 80 | ### Patch Changes 81 | 82 | - be0c969: Adds missing comma in README 83 | 84 | ## 0.4.1 85 | 86 | ### Patch Changes 87 | 88 | - 0cbcfa3: Support Astro v4 89 | 90 | ## 0.4.0 91 | 92 | ### Minor Changes 93 | 94 | - c9b3dc9: Adds support for rendering a background image 95 | 96 | ## 0.3.2 97 | 98 | ### Patch Changes 99 | 100 | - 8416369: Update `deterministic-object-hash` from 1.3.1 to 2.0.2 101 | 102 | ## 0.3.1 103 | 104 | ### Patch Changes 105 | 106 | - 6432706: Update README docs 107 | 108 | ## 0.3.0 109 | 110 | ### Minor Changes 111 | 112 | - 1b83057: Add support for loading local font files 113 | - 19f025a: Cache images across builds 114 | 115 | ## 0.2.1 116 | 117 | ### Patch Changes 118 | 119 | - 467523f: Add note about `canvaskit-wasm` for pnpm users to README 120 | 121 | ## 0.2.0 122 | 123 | ### Minor Changes 124 | 125 | - 2f8952c: Add support for Astro 3.0 and remove support for Astro 1.0 and 2.0. 126 | 127 | When upgrading, update your OpenGraph routes to use `GET` instead instead of lowercase `get`: 128 | 129 | ```diff 130 | import { OGImageRoute } from 'astro-og-canvas'; 131 | 132 | - export const { getStaticPaths, get } = OGImageRoute({ 133 | + export const { getStaticPaths, GET } = OGImageRoute({ 134 | // ... 135 | }); 136 | ``` 137 | 138 | ## 0.1.8 139 | 140 | ### Patch Changes 141 | 142 | - a598023: Fix unexpected slug truncation for paths without extension 143 | - ed36da8: Bump dev dependencies 144 | 145 | ## 0.1.7 146 | 147 | ### Patch Changes 148 | 149 | - 8c10732: Manually free memory after generating an image 150 | 151 | ## 0.1.6 152 | 153 | ### Patch Changes 154 | 155 | - c063c32: Allow installation in Astro v2 projects 156 | 157 | ## 0.1.5 158 | 159 | ### Patch Changes 160 | 161 | - 98be213: Handle index files in default slugifier (e.g. `/foo/index.md` now becomes `/foo.png` instead of `/foo/index.png`) 162 | 163 | ## 0.1.4 164 | 165 | ### Patch Changes 166 | 167 | - cadcdb5: Improve layout logic to better handle long text 168 | 169 | ## 0.1.3 170 | 171 | ### Patch Changes 172 | 173 | - 819977a: Support HTML entities in title & description 174 | - be5c57f: Remove unused array in font manager 175 | - cd14cbe: Fix bug causing font manager to return previous manager instance 176 | 177 | ## 0.1.2 178 | 179 | ### Patch Changes 180 | 181 | - 641bbe9: Fix debug logging prefix 182 | - 479a011: Ship compiled JavaScript output instead of uncompiled TypeScript. 183 | - 7fd6d5b: Support async `getImageOptions` 184 | 185 | ## 0.1.1 186 | 187 | ### Patch Changes 188 | 189 | - 373b227: Work around memory leak by avoiding reinstantiations of `CanvasKit.FontMgr` 190 | - e8f3952: Avoid top-level `await` for better support in different environments 191 | 192 | ## 0.1.0 193 | 194 | ### Minor Changes 195 | 196 | - 6c99108: Initial release 197 | -------------------------------------------------------------------------------- /packages/astro-og-canvas/src/assetLoaders.ts: -------------------------------------------------------------------------------- 1 | import type { CanvasKit, FontMgr } from 'canvaskit-wasm/full'; 2 | import { Buffer } from 'node:buffer'; 3 | import fs from 'node:fs/promises'; 4 | import { createRequire } from 'node:module'; 5 | import { pQueue } from './queue'; 6 | import { shorthash } from './shorthash'; 7 | const { resolve } = createRequire(import.meta.url); 8 | 9 | const debug = (...args: any[]) => console.debug('[astro-og-canvas]', ...args); 10 | const error = (...args: any[]) => console.error('[astro-og-canvas]', ...args); 11 | 12 | /** CanvasKit singleton. */ 13 | let canvasKitSingleton: CanvasKit; 14 | export async function getCanvasKit() { 15 | if (!canvasKitSingleton) { 16 | try { 17 | const { default: init } = await import('canvaskit-wasm/full'); 18 | canvasKitSingleton = await init({ 19 | // TODO: Figure how to reliably resolve this without depending on Node. 20 | locateFile: (file) => resolve(`canvaskit-wasm/bin/full/${file}`), 21 | }); 22 | } catch (e) { 23 | throw formatCanvasKitInitError(e); 24 | } 25 | } 26 | return canvasKitSingleton; 27 | } 28 | 29 | function formatCanvasKitInitError(e: unknown) { 30 | if (e instanceof Error && e.message.includes('__dirname is not defined')) { 31 | e.message += 32 | '\n\n' + 33 | 'This error is often thrown when using PNPM without installing `canvaskit-wasm` directly.\n' + 34 | 'Install this required dependency by running `pnpm add canvaskit-wasm`\n'; 35 | } 36 | return e; 37 | } 38 | 39 | class FontManager { 40 | /** Font data cache to avoid repeat downloads. */ 41 | readonly #cache = new Map(); 42 | readonly #hashCache = new Map(); 43 | /** Queue to co-ordinate `#get` calls to run sequentially. */ 44 | #queue = pQueue(); 45 | /** Current `CanvasKit.FontMgr` instance. */ 46 | #manager?: FontMgr; 47 | 48 | /** Get a `CanvasKit.FontMgr` instance with all the currently cached fonts, creating a new one if needed. */ 49 | async #getOrCreateManager(shouldUpdate: boolean): Promise { 50 | if (!shouldUpdate && this.#manager) { 51 | return this.#manager; 52 | } 53 | 54 | const CanvasKit = await getCanvasKit(); 55 | const fontData = Array.from(this.#cache.values()).filter((v) => !!v) as ArrayBuffer[]; 56 | this.#manager = CanvasKit.FontMgr.FromData(...fontData)!; 57 | 58 | // Log to the terminal which font families have been loaded. 59 | // Mostly useful so users can see the name of families as parsed by CanvasKit. 60 | const fontCount = this.#manager.countFamilies(); 61 | const fontFamilies = []; 62 | for (let i = 0; i < fontCount; i++) fontFamilies.push(this.#manager.getFamilyName(i)); 63 | debug('Loaded', fontCount, 'font families:\n' + fontFamilies.join(', ')); 64 | 65 | return this.#manager; 66 | } 67 | 68 | /** 69 | * Get a font manager instance for the provided fonts. 70 | * 71 | * Fonts are backed by an in-memory cache, so fonts are only downloaded once. 72 | * 73 | * Tries to avoid repeated instantiation of `CanvasKit.FontMgr` due to a memory leak 74 | * in their implementation. Will only reinstantiate if it sees a new font in the 75 | * `fontUrls` array. 76 | * 77 | * @param fontUrls Array of URLs to remote font files (TTF recommended). 78 | * @returns A font manager for all fonts loaded up until now. 79 | */ 80 | async get(fontUrls: string[]): Promise { 81 | return this.#queue(async () => { 82 | let hasNew = false; 83 | for (const url of fontUrls) { 84 | if (this.#cache.has(url)) continue; 85 | hasNew = true; 86 | debug('Loading', url); 87 | if (/^https?:\/\//.test(url)) { 88 | const response = await fetch(url); 89 | if (response.ok) { 90 | this.#cache.set(url, await response.arrayBuffer()); 91 | } else { 92 | this.#cache.set(url, undefined); 93 | error(response.status, response.statusText, '—', url); 94 | } 95 | } else { 96 | const file = await fs.readFile(url); 97 | this.#cache.set(url, file); 98 | } 99 | } 100 | return this.#getOrCreateManager(hasNew); 101 | }); 102 | } 103 | 104 | /** Get a short hash for a given font resource. */ 105 | getHash(url: string): string { 106 | let hash = this.#hashCache.get(url) || ''; 107 | if (hash) return hash; 108 | const buffer = this.#cache.get(url); 109 | hash = buffer ? shorthash(Buffer.from(buffer).toString()) : ''; 110 | this.#hashCache.set(url, hash); 111 | return hash; 112 | } 113 | } 114 | export const fontManager = new FontManager(); 115 | 116 | interface LoadedImage { 117 | /** Pixel buffer for the loaded image. */ 118 | buffer: Buffer; 119 | /** Short hash of the image’s buffer. */ 120 | hash: string; 121 | } 122 | 123 | const images = { cache: new Map(), queue: pQueue() }; 124 | 125 | /** 126 | * Load an image. Backed by an in-memory cache to avoid repeat disk-reads. 127 | * @param path Path to an image file, e.g. `./src/logo.png`. 128 | * @returns Buffer containing the image contents. 129 | */ 130 | export const loadImage = async (path: string): Promise => 131 | images.queue(async () => { 132 | const cached = images.cache.get(path); 133 | if (cached) { 134 | return cached; 135 | } else { 136 | // TODO: Figure out if there’s deno-compatible way to load images. 137 | const buffer = await fs.readFile(path); 138 | const image = { buffer, hash: shorthash(buffer.toString()) }; 139 | images.cache.set(path, image); 140 | return image; 141 | } 142 | }); 143 | -------------------------------------------------------------------------------- /packages/astro-og-canvas/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { CanvasKit } from 'canvaskit-wasm/full'; 2 | 3 | export type RGBColor = [r: number, g: number, b: number]; 4 | export type XYWH = [x: number, y: number, w: number, h: number]; 5 | export type LogicalSide = 'block-start' | 'inline-end' | 'block-end' | 'inline-start'; 6 | export type IllogicalSide = 'top' | 'right' | 'bottom' | 'left'; 7 | export type LogicalPosition = 'start' | 'center' | 'end'; 8 | 9 | export interface FontConfig { 10 | /** RGB text color. Default: `[255, 255, 255]` */ 11 | color?: RGBColor; 12 | /** Font size. Title default is `70`, description default is `40`. */ 13 | size?: number; 14 | /** Font weight. Make sure you provide a URL for the matching font weight. */ 15 | weight?: Exclude; 16 | /** Line height, a.k.a. leading. */ 17 | lineHeight?: number; 18 | /** 19 | * Font families to use to render this text. These must be loaded using the 20 | * top-level `fonts` config option. 21 | * 22 | * Similar to CSS, this operates as a “font stack”. The first family in the 23 | * list will be preferred with next entries used if a glyph isn’t in earlier 24 | * families. Useful for providing fallbacks for different alphabets etc. 25 | * 26 | * Example: `['Noto Sans', 'Noto Sans Arabic']` 27 | */ 28 | families?: string[]; 29 | } 30 | 31 | export interface OGImageOptions { 32 | /** 33 | * Directory to use for caching images across builds. 34 | * Set to `false` to disable caching. 35 | * Default: `./node_modules/.astro-og-canvas` 36 | */ 37 | cacheDir?: string | false; 38 | /** Page title. */ 39 | title: string; 40 | /** Short page description. */ 41 | description?: string; 42 | /** Writing direction. Default: `'ltr'`. Set to `'rtl'` for Arabic, Hebrew, etc. */ 43 | dir?: 'rtl' | 'ltr'; 44 | /** Optional site logo. Displayed at the top of the card. */ 45 | logo?: { 46 | /** Path to the logo image file, e.g. `'./src/logo.png'` */ 47 | path: string; 48 | /** 49 | * Size to display logo at. 50 | * - `undefined` — Use original image file dimensions. (Default) 51 | * - `[width]` — Resize to the specified width, height will be resize proportionally. 52 | * - `[width, height]` — Resized to the specified width and height. 53 | */ 54 | size?: [width?: number, height?: number]; 55 | }; 56 | /** 57 | * Array of `[R, G, B]` colors to use in the background gradient, 58 | * e.g. `[[255, 0, 0], [0, 0, 255]]` (red to blue). 59 | * For a solid color, only include a single color, e.g. `[[0, 0, 0]]` 60 | */ 61 | bgGradient?: RGBColor[]; 62 | 63 | /** 64 | * Optional background image. 65 | */ 66 | bgImage?: { 67 | /** Path to the background image file, e.g. `'./src/background.png'`. */ 68 | path: string; 69 | /** 70 | * How the background image should resize to fit the container. 71 | * 72 | * Default: `'none'` 73 | * 74 | * Options: 75 | * - `'none'` — The image is displayed at original size even if that’s larger or smaller than the container 76 | * - `'cover'` — The image is sized to maintain its aspect ratio while filling the entire container. 77 | * If the image’s aspect ratio does not match the aspect ratio of the container, then the image will be clipped to fit. 78 | * - `'fill'` — The image is sized to fill the entire container. 79 | * If the image’s aspect ratio does not match the aspect ratio of the container, then the image will be stretched to fit. 80 | * - `'contain'` — The image is scaled to maintain its aspect ratio while fitting within the container. 81 | * The image will be “letter-boxed” if its aspect ratio does not match the aspect ratio of the container. 82 | */ 83 | fit?: 'cover' | 'contain' | 'none' | 'fill'; 84 | /** 85 | * The position of the background image. 86 | * 87 | * Default: `'center'` 88 | * 89 | * The value is either a shorthand for both block and inline directions, e.g. `'center'`, 90 | * or a tuple of `[blockPosition, inlinePosition]`, e.g. `['end', 'center']`. 91 | * 92 | * Examples: 93 | * - `'start'` — place the image at the top-left (or top-right for RTL languages) 94 | * - `'center'` (default) — center the image horizontally and vertically 95 | * - `['start', 'end']` — place the image at the top 96 | */ 97 | position?: LogicalPosition | [LogicalPosition, LogicalPosition]; 98 | }; 99 | 100 | /** Border config. Highlights a single edge of the image. */ 101 | border?: { 102 | /** RGB border color, e.g. `[0, 255, 0]`. */ 103 | color?: RGBColor; 104 | /** Border width. Default: `0`. */ 105 | width?: number; 106 | /** Side of the image to draw the border on. Inline start/end respects writing direction. */ 107 | side?: LogicalSide; 108 | }; 109 | /** Amount of padding between the image edge and text. Default: `60`. */ 110 | padding?: number; 111 | /** Font styles. */ 112 | font?: { 113 | /** Font style for the page title. */ 114 | title?: FontConfig; 115 | /** Font style for the page description. */ 116 | description?: FontConfig; 117 | }; 118 | /** 119 | * Array of font URLs or file paths to load and use when rendering text. 120 | * 121 | * @example 122 | * { 123 | * fonts: [ 124 | * // Local font file path relative to the project root: 125 | * './src/fonts/local-font.ttf', 126 | * // URL to a font file on a remote web server: 127 | * 'https://example.com/cdn/remote-font.ttf' 128 | * ], 129 | * } 130 | */ 131 | fonts?: string[]; 132 | /** Image format to save to. Default: `'PNG'` */ 133 | format?: Exclude; 134 | /** Image quality between `0` (very lossy) and `100` (least lossy). Not used by all formats. */ 135 | quality?: number; 136 | } 137 | -------------------------------------------------------------------------------- /packages/astro-og-canvas/README.md: -------------------------------------------------------------------------------- 1 | # astro-og-canvas 2 | 3 | This package provides utlities to generate OpenGraph images for the pages on your Astro site. 4 | 5 | ## Installation 6 | 7 | ```shell 8 | npm i astro-og-canvas 9 | ``` 10 | 11 | **Using `pnpm`?** `pnpm` users will also need to install `canvaskit-wasm` as a direct dependency: 12 | 13 | ```shell 14 | pnpm i canvaskit-wasm 15 | ``` 16 | 17 | ## Version compatibility 18 | 19 | | astro | astro-og-canvas | 20 | | ------ | --------------------------------------------------------------------------------------------- | 21 | | `≤2.x` | [`0.1.x`](https://github.com/delucis/astro-og-canvas/blob/astro-og-canvas%400.1.8/README.md) | 22 | | `≥3.x` | [`≥0.2.x`](https://github.com/delucis/astro-og-canvas/blob/astro-og-canvas%400.2.0/README.md) | 23 | 24 | ## Usage 25 | 26 | ### Creating an OpenGraph image endpoint 27 | 28 | 1. Create a new file in your `src/pages/` directory. For example, `src/pages/open-graph/[...route].ts`. 29 | 30 | 2. Use the `OGImageRoute` helper to create `getStaticPaths` and `GET` functions for you: 31 | 32 | ```js 33 | // src/pages/open-graph/[...route].ts 34 | 35 | import { OGImageRoute } from 'astro-og-canvas'; 36 | 37 | export const { getStaticPaths, GET } = OGImageRoute({ 38 | // Tell us the name of your dynamic route segment. 39 | // In this case it’s `route`, because the file is named `[...route].ts`. 40 | param: 'route', 41 | 42 | // A collection of pages to generate images for. 43 | // The keys of this object are used to generate the path for that image. 44 | // In this example, we generate one image at `/open-graph/example.png`. 45 | pages: { 46 | 'example': { 47 | title: 'Example Page', 48 | description: 'Description of this page shown in smaller text', 49 | } 50 | }, 51 | 52 | // For each page, this callback will be used to customize the OpenGraph image. 53 | getImageOptions: (path, page) => ({ 54 | title: page.title, 55 | description: page.description, 56 | logo: { 57 | path: './src/astro-docs-logo.png', 58 | }, 59 | // There are a bunch more options you can use here! 60 | }), 61 | }); 62 | ``` 63 | 64 | #### Generating `pages` from a content collection 65 | 66 | If you want to generate images for every file in a content collection, use `getCollection()` to load your content entries and convert the entries array to an object. 67 | 68 | The following example assumes a content collection schema with `title` and `description` keys in the frontmatter. 69 | 70 | ```js 71 | import { getCollection } from 'astro:content'; 72 | import { OGImageRoute } from 'astro-og-canvas'; 73 | 74 | const collectionEntries = await getCollection('my-collection'); 75 | 76 | // Map the array of content collection entries to create an object. 77 | // Converts [{ id: 'post.md', data: { title: 'Example', description: '' } }] 78 | // to { 'post.md': { title: 'Example', description: '' } } 79 | const pages = Object.fromEntries(collectionEntries.map(({ slug, data }) => [slug, data])); 80 | 81 | export const { getStaticPaths, GET } = OGImageRoute({ 82 | // Tell us the name of your dynamic route segment. 83 | // In this case it’s `route`, because the file is named `[...route].ts`. 84 | param: 'route', 85 | 86 | pages: pages, 87 | 88 | getImageOptions: (path, page) => ({ 89 | title: page.title, 90 | description: page.description, 91 | }), 92 | }); 93 | ``` 94 | 95 | #### Generating `pages` from Markdown files 96 | 97 | If you have a folder of Markdown files with `title` and `description` fields in their frontmatter, use `import.meta.glob()` to load and pass these to the `pages` option of `OGImageRoute`. 98 | 99 | In the following example, every Markdown file in the project’s `src/pages/` directory is loaded and will have an image generated for them: 100 | 101 | ```js 102 | import { OGImageRoute } from 'astro-og-canvas'; 103 | 104 | export const { getStaticPaths, GET } = OGImageRoute({ 105 | // Tell us the name of your dynamic route segment. 106 | // In this case it’s `route`, because the file is named `[...route].ts`. 107 | param: 'route', 108 | 109 | // Pass the glob result to pages 110 | pages: await import.meta.glob('/src/pages/**/*.md', { eager: true }), 111 | 112 | // Extract `title` and `description` from the glob result’s `frontmatter` property 113 | getImageOptions: (path, page) => ({ 114 | title: page.frontmatter.title, 115 | description: page.frontmatter.description, 116 | }), 117 | 118 | // ... 119 | }); 120 | ``` 121 | 122 | #### Generating `pages` from other data sources 123 | 124 | `pages` can be any object you want. Its keys are used to generate the final route, but other than this, how you use it is up to you, so you can generate images from any data you like. 125 | 126 |
127 | Pokémon API example 128 | 129 | The following example fetches some data about Pokémon using the [PokéAPI](https://pokeapi.co/) and then uses that to generate images: 130 | 131 | ```js 132 | import { OGImageRoute } from 'astro-og-canvas'; 133 | 134 | // Fetch first 20 Pokémon from the PokéAPI. 135 | const { results } = await fetch('https://pokeapi.co/api/v2/pokemon/').then((res) => res.json()); 136 | // Fetch details for each Pokémon. 137 | const pokemon = {}; 138 | for (const { url } of results) { 139 | const details = await fetch(url).then((res) => res.json()); 140 | pokemon[details.name] = details; 141 | } 142 | 143 | export const { getStaticPaths, GET } = OGImageRoute({ 144 | pages: pokemon, 145 | 146 | getImageOptions: (path, page) => ({ 147 | title: page.name, 148 | description: `Pokémon #${page.order}`, 149 | }), 150 | }); 151 | ``` 152 | 153 |
154 | 155 | ### Image Options 156 | 157 | Your `getImageOptions` callback should return an object configuring the image to render. Almost all options are optional. 158 | 159 | ```ts 160 | export interface OGImageOptions { 161 | /** Page title. */ 162 | title: string; 163 | 164 | /** Short page description. */ 165 | description?: string; 166 | 167 | /** Writing direction. Default: `'ltr'`. Set to `'rtl'` for Arabic, Hebrew, etc. */ 168 | dir?: 'rtl' | 'ltr'; 169 | 170 | /** Optional site logo. Displayed at the top of the card. */ 171 | logo?: { 172 | /** Path to the logo image file, e.g. `'./src/logo.png'` */ 173 | path: string; 174 | 175 | /** 176 | * Size to display logo at. 177 | * - `undefined` — Use original image file dimensions. (Default) 178 | * - `[width]` — Resize to the specified width, height will be 179 | * resized proportionally. 180 | * - `[width, height]` — Resized to the specified width and height. 181 | */ 182 | size?: [width?: number, height?: number]; 183 | }; 184 | 185 | /** 186 | * Array of `[R, G, B]` colors to use in the background gradient, 187 | * e.g. `[[255, 0, 0], [0, 0, 255]]` (red to blue). 188 | * For a solid color, only include a single color, e.g. `[[0, 0, 0]]` 189 | */ 190 | bgGradient?: RGBColor[]; 191 | 192 | /** Border config. Highlights a single edge of the image. */ 193 | border?: { 194 | /** RGB border color, e.g. `[0, 255, 0]`. */ 195 | color?: RGBColor; 196 | 197 | /** Border width. Default: `0`. */ 198 | width?: number; 199 | 200 | /** Side of the image to draw the border on. Inline start/end respects writing direction. */ 201 | side?: LogicalSide; 202 | }; 203 | 204 | /** Optional background image. */ 205 | bgImage?: { 206 | /** Path to the background image file, e.g. `'./src/background.png'`. */ 207 | path: string; 208 | 209 | /** How the background image should resize to fit the container. (Default: `'none'`) */ 210 | fit?: 'cover' | 'contain' | 'none' | 'fill'; 211 | 212 | /** 213 | * How the background image should be positioned in the container. (Default: `'center'`) 214 | * 215 | * The value is either a shorthand for both block and inline directions, e.g. `'center'`, 216 | * or a tuple of `[blockPosition, inlinePosition]`, e.g. `['end', 'center']`. 217 | */ 218 | position?: LogicalPosition | [LogicalPosition, LogicalPosition]; 219 | }; 220 | 221 | /** Amount of padding between the image edge and text. Default: `60`. */ 222 | padding?: number; 223 | 224 | /** Font styles. */ 225 | font?: { 226 | /** Font style for the page title. */ 227 | title?: FontConfig; 228 | 229 | /** Font style for the page description. */ 230 | description?: FontConfig; 231 | }; 232 | 233 | /** 234 | * Array of font URLs or file paths to load and use when rendering text, 235 | * e.g. `['./src/fonts/local-font.ttf', 'https://example.com/cdn/remote-font.ttf']` 236 | * Local font paths are specified relative to your project’s root. 237 | */ 238 | fonts?: string[]; 239 | 240 | /** 241 | * Directory to cache images in across builds. 242 | * Default: `./node_modules/.astro-og-canvas` 243 | * Set to `false` to disable caching. 244 | */ 245 | cacheDir?: string | false; 246 | } 247 | ``` 248 | 249 | #### `FontConfig` 250 | 251 | ```ts 252 | export interface FontConfig { 253 | /** RGB text color. Default: `[255, 255, 255]` */ 254 | color?: RGBColor; 255 | 256 | /** Font size. Title default is `70`, description default is `40`. */ 257 | size?: number; 258 | 259 | /** Font weight. Make sure you provide a URL for the matching font weight. */ 260 | weight?: Exclude; 261 | 262 | /** Line height, a.k.a. leading. */ 263 | lineHeight?: number; 264 | 265 | /** 266 | * Font families to use to render this text. These must be loaded using the 267 | * top-level `fonts` config option. 268 | * 269 | * Similar to CSS, this operates as a “font stack”. The first family in the 270 | * list will be preferred with next entries used if a glyph isn’t in earlier 271 | * families. Useful for providing fallbacks for different alphabets etc. 272 | * 273 | * Example: `['Noto Sans', 'Noto Sans Arabic']` 274 | */ 275 | families?: string[]; 276 | } 277 | ``` 278 | 279 | ## License 280 | 281 | MIT 282 | -------------------------------------------------------------------------------- /packages/astro-og-canvas/src/generateOpenGraphImage.ts: -------------------------------------------------------------------------------- 1 | import { deterministicString } from 'deterministic-object-hash'; 2 | import { decodeHTMLStrict } from 'entities'; 3 | import fs from 'node:fs/promises'; 4 | import path from 'node:path'; 5 | import { Buffer } from 'node:buffer'; 6 | import { getCanvasKit, fontManager, loadImage } from './assetLoaders'; 7 | import { shorthash } from './shorthash'; 8 | import type { 9 | FontConfig, 10 | IllogicalSide, 11 | LogicalSide, 12 | OGImageOptions, 13 | RGBColor, 14 | XYWH, 15 | } from './types'; 16 | 17 | const [width, height] = [1200, 630]; 18 | const edges: Record = { 19 | top: [0, 0, width, 0], 20 | bottom: [0, height, width, height], 21 | left: [0, 0, 0, height], 22 | right: [width, 0, width, height], 23 | }; 24 | const defaults: { 25 | border: NonNullable>; 26 | font: Record<'title' | 'description', Required>; 27 | } = { 28 | border: { 29 | color: [255, 255, 255] as RGBColor, 30 | width: 0, 31 | side: 'inline-start' as LogicalSide, 32 | }, 33 | font: { 34 | title: { 35 | color: [255, 255, 255], 36 | size: 70, 37 | lineHeight: 1, 38 | weight: 'Normal', 39 | families: ['Noto Sans'], 40 | }, 41 | description: { 42 | color: [255, 255, 255], 43 | size: 40, 44 | lineHeight: 1.3, 45 | weight: 'Normal', 46 | families: ['Noto Sans'], 47 | }, 48 | }, 49 | }; 50 | 51 | class ImageCache { 52 | #dirCache = new Set(); 53 | /** Ensure the requested directory exists. */ 54 | async #mkdir(dir: string) { 55 | if (this.#dirCache.has(dir)) return; 56 | try { 57 | await fs.mkdir(dir, { recursive: true }); 58 | this.#dirCache.add(dir); 59 | } catch {} 60 | } 61 | /** Retrieve an image from the file system cache if it exists. */ 62 | async get(cachePath: string): Promise { 63 | await this.#mkdir(path.dirname(cachePath)); 64 | return await fs.readFile(cachePath).catch(() => undefined); 65 | } 66 | /** Write an image to the file system cache. */ 67 | async set(cachePath: string, image: Buffer): Promise { 68 | await this.#mkdir(path.dirname(cachePath)); 69 | await fs.writeFile(cachePath, image).catch(() => undefined); 70 | } 71 | } 72 | const imageCache = new ImageCache(); 73 | 74 | export async function generateOpenGraphImage({ 75 | cacheDir = './node_modules/.astro-og-canvas', 76 | title, 77 | description = '', 78 | dir = 'ltr', 79 | bgGradient = [[0, 0, 0]], 80 | bgImage, 81 | border: borderConfig = {}, 82 | padding = 60, 83 | logo, 84 | font: fontConfig = {}, 85 | fonts = ['https://api.fontsource.org/v1/fonts/noto-sans/latin-400-normal.ttf'], 86 | format = 'PNG', 87 | quality = 90, 88 | }: OGImageOptions) { 89 | // Load and configure font families. 90 | const fontMgr = await fontManager.get(fonts); 91 | const loadedLogo = logo && (await loadImage(logo.path)); 92 | const loadedBg = bgImage && (await loadImage(bgImage.path)); 93 | 94 | /** A deterministic hash based on inputs. */ 95 | const hash = shorthash( 96 | deterministicString([ 97 | title, 98 | description, 99 | dir, 100 | bgGradient, 101 | bgImage, 102 | borderConfig, 103 | padding, 104 | logo, 105 | fontConfig, 106 | fonts, 107 | quality, 108 | loadedLogo?.hash, 109 | loadedBg?.hash, 110 | fonts.map((font) => fontManager.getHash(font)), 111 | ]) 112 | ); 113 | 114 | let cacheFilePath: string | undefined; 115 | if (cacheDir) { 116 | cacheFilePath = path.join(cacheDir, `${hash}.${format.toLowerCase()}`); 117 | const cached = await imageCache.get(cacheFilePath); 118 | if (cached) return cached; 119 | } 120 | 121 | const border = { ...defaults.border, ...borderConfig }; 122 | const font = { 123 | title: { ...defaults.font.title, ...fontConfig.title }, 124 | description: { ...defaults.font.description, ...fontConfig.description }, 125 | }; 126 | 127 | const isRtl = dir === 'rtl'; 128 | const margin: Record = { 129 | 'block-start': padding, 130 | 'block-end': padding, 131 | 'inline-start': padding, 132 | 'inline-end': padding, 133 | }; 134 | margin[border.side] += border.width; 135 | 136 | const CanvasKit = await getCanvasKit(); 137 | 138 | const textStyle = (fontConfig: Required) => ({ 139 | color: CanvasKit.Color(...fontConfig.color), 140 | fontFamilies: fontConfig.families, 141 | fontSize: fontConfig.size, 142 | fontStyle: { weight: CanvasKit.FontWeight[fontConfig.weight] }, 143 | heightMultiplier: fontConfig.lineHeight, 144 | }); 145 | 146 | // Set up. 147 | const surface = CanvasKit.MakeSurface(width, height)!; 148 | const canvas = surface.getCanvas(); 149 | 150 | // Draw background gradient. 151 | const bgRect = CanvasKit.XYWHRect(0, 0, width, height); 152 | const bgPaint = new CanvasKit.Paint(); 153 | bgPaint.setShader( 154 | CanvasKit.Shader.MakeLinearGradient( 155 | [0, 0], 156 | [0, height], 157 | bgGradient.map((rgb) => CanvasKit.Color(...rgb)), 158 | null, 159 | CanvasKit.TileMode.Clamp 160 | ) 161 | ); 162 | canvas.drawRect(bgRect, bgPaint); 163 | 164 | // Draw border. 165 | if (border.width) { 166 | const borderStyle = new CanvasKit.Paint(); 167 | borderStyle.setStyle(CanvasKit.PaintStyle.Stroke); 168 | borderStyle.setColor(CanvasKit.Color(...border.color)); 169 | borderStyle.setStrokeWidth(border.width * 2); 170 | const borders: Record = { 171 | 'block-start': edges.top, 172 | 'block-end': edges.bottom, 173 | 'inline-start': isRtl ? edges.right : edges.left, 174 | 'inline-end': isRtl ? edges.left : edges.right, 175 | }; 176 | canvas.drawLine(...borders[border.side], borderStyle); 177 | } 178 | 179 | // Draw background image. 180 | if (bgImage && loadedBg?.buffer) { 181 | const bgImg = CanvasKit.MakeImageFromEncoded(loadedBg.buffer); 182 | if (bgImg) { 183 | let { position = 'center', fit = 'none' } = bgImage; 184 | if (typeof position === 'string') position = [position, position]; 185 | 186 | const [bgW, bgH] = [bgImg.width(), bgImg.height()]; 187 | let [targetW, targetH] = [bgW, bgH]; 188 | if (fit === 'fill') { 189 | [targetW, targetH] = [width, height]; 190 | } else if (fit === 'cover') { 191 | const ratio = bgW / width < bgH / height ? width / bgW : height / bgH; 192 | [targetW, targetH] = [bgW * ratio, bgH * ratio]; 193 | } else if (fit === 'contain') { 194 | const ratio = bgW / width > bgH / height ? width / bgW : height / bgH; 195 | [targetW, targetH] = [bgW * ratio, bgH * ratio]; 196 | } 197 | 198 | const [blockAlign, inlineAlign] = position; 199 | const targetX = 200 | inlineAlign === 'start' 201 | ? 0 202 | : inlineAlign === 'end' 203 | ? width - targetW 204 | : (width - targetW) / 2; 205 | const targetY = 206 | blockAlign === 'start' 207 | ? 0 208 | : blockAlign === 'end' 209 | ? height - targetH 210 | : (height - targetH) / 2; 211 | 212 | // Draw image 213 | const srcRect = CanvasKit.XYWHRect(0, 0, bgW, bgH); 214 | const destRect = CanvasKit.XYWHRect(targetX, targetY, targetW, targetH); 215 | canvas.drawImageRect(bgImg, srcRect, destRect, new CanvasKit.Paint()); 216 | } 217 | } 218 | 219 | // Draw logo. 220 | let logoHeight = 0; 221 | if (logo && loadedLogo?.buffer) { 222 | const img = CanvasKit.MakeImageFromEncoded(loadedLogo.buffer); 223 | if (img) { 224 | const logoH = img.height(); 225 | const logoW = img.width(); 226 | const targetW = logo.size?.[0] ?? logoW; 227 | const targetH = logo.size?.[1] ?? (targetW / logoW) * logoH; 228 | const xRatio = targetW / logoW; 229 | const yRatio = targetH / logoH; 230 | logoHeight = targetH; 231 | 232 | // Matrix transform to scale the logo to the desired size. 233 | const imagePaint = new CanvasKit.Paint(); 234 | imagePaint.setImageFilter( 235 | CanvasKit.ImageFilter.MakeMatrixTransform( 236 | CanvasKit.Matrix.scaled(xRatio, yRatio), 237 | { filter: CanvasKit.FilterMode.Linear }, 238 | null 239 | ) 240 | ); 241 | 242 | const imageLeft = isRtl 243 | ? (1 / xRatio) * (width - margin['inline-start']) - logoW 244 | : (1 / xRatio) * margin['inline-start']; 245 | 246 | canvas.drawImage(img, imageLeft, (1 / yRatio) * margin['block-start'], imagePaint); 247 | } 248 | } 249 | 250 | if (fontMgr) { 251 | // Create paragraph with initial styles and add title. 252 | const paragraphStyle = new CanvasKit.ParagraphStyle({ 253 | textAlign: isRtl ? CanvasKit.TextAlign.Right : CanvasKit.TextAlign.Left, 254 | textStyle: textStyle(font.title), 255 | textDirection: isRtl ? CanvasKit.TextDirection.RTL : CanvasKit.TextDirection.LTR, 256 | }); 257 | const paragraphBuilder = CanvasKit.ParagraphBuilder.Make(paragraphStyle, fontMgr); 258 | paragraphBuilder.addText(decodeHTMLStrict(title)); 259 | 260 | // Add small empty line betwen title & description. 261 | paragraphBuilder.pushStyle( 262 | new CanvasKit.TextStyle({ fontSize: padding / 3, heightMultiplier: 1 }) 263 | ); 264 | paragraphBuilder.addText('\n\n'); 265 | 266 | // Add description. 267 | paragraphBuilder.pushStyle(new CanvasKit.TextStyle(textStyle(font.description))); 268 | paragraphBuilder.addText(decodeHTMLStrict(description)); 269 | 270 | // Draw paragraph to canvas. 271 | const para = paragraphBuilder.build(); 272 | const paraWidth = width - margin['inline-start'] - margin['inline-end'] - padding; 273 | para.layout(paraWidth); 274 | const paraLeft = isRtl 275 | ? width - margin['inline-start'] - para.getMaxWidth() 276 | : margin['inline-start']; 277 | const minTop = margin['block-start'] + logoHeight + (logoHeight ? padding : 0); 278 | const maxTop = minTop + (logoHeight ? padding : 0); 279 | const naturalTop = height - margin['block-end'] - para.getHeight(); 280 | const paraTop = Math.max(minTop, Math.min(maxTop, naturalTop)); 281 | canvas.drawParagraph(para, paraLeft, paraTop); 282 | } 283 | 284 | // Render canvas to a buffer. 285 | const image = surface.makeImageSnapshot(); 286 | const imageBytes = 287 | image.encodeToBytes(CanvasKit.ImageFormat[format], quality) || new Uint8Array(); 288 | 289 | // Free any memory our surface might be hanging onto. 290 | surface.dispose(); 291 | 292 | const imgBuffer = Buffer.from(imageBytes); 293 | 294 | if (cacheFilePath) await imageCache.set(cacheFilePath, imgBuffer); 295 | return imgBuffer; 296 | } 297 | --------------------------------------------------------------------------------