diff --git a/.changeset/svg-download-inline-images.md b/.changeset/svg-download-inline-images.md
new file mode 100644
index 00000000..fa46c06e
--- /dev/null
+++ b/.changeset/svg-download-inline-images.md
@@ -0,0 +1,5 @@
+---
+'@lglab/react-qr-code': patch
+---
+
+Embed images in SVG downloads as data URLs, so a downloaded SVG with an image still shows it offline and when printed. If the image can't be fetched (for example because of CORS), the original URL is kept.
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 3c6679d4..6e2ab5fb 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -15,6 +15,7 @@
"@radix-ui/react-accordion": "^1.2.21",
"@radix-ui/react-checkbox": "^1.3.12",
"@radix-ui/react-dialog": "^1.2.0",
+ "@radix-ui/react-dropdown-menu": "^2.1.25",
"@radix-ui/react-label": "^2.1.16",
"@radix-ui/react-popover": "^1.2.0",
"@radix-ui/react-select": "^2.3.8",
diff --git a/apps/docs/public/images/logo.svg b/apps/docs/public/images/logo.svg
new file mode 100644
index 00000000..5eb8b912
--- /dev/null
+++ b/apps/docs/public/images/logo.svg
@@ -0,0 +1 @@
+
diff --git a/apps/docs/src/components/demo/demo.tsx b/apps/docs/src/components/demo/demo.tsx
index 3fb164f0..5da536af 100644
--- a/apps/docs/src/components/demo/demo.tsx
+++ b/apps/docs/src/components/demo/demo.tsx
@@ -1,11 +1,13 @@
'use client'
import {
+ type DownloadFileFormat,
type FrameSettings,
ReactQRCode,
type ReactQRCodeProps,
type ReactQRCodeRef,
} from '@lglab/react-qr-code'
+import { ChevronDown } from 'lucide-react'
import { type PropsWithChildren, useRef, useState } from 'react'
import {
@@ -15,6 +17,12 @@ import {
AccordionTrigger,
} from '@/components/ui/accordion'
import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
import { Colors } from './colors'
import { DataModules } from './data-modules'
@@ -25,7 +33,7 @@ import { ImageSettingsDemo } from './image-settings'
import { MainSettings } from './main-settings'
export const defaultImageSettings = {
- src: 'https://reactqrcode.com/images/logo-60.png',
+ src: 'https://reactqrcode.com/images/logo.svg',
width: 60,
height: 60,
excavate: true,
@@ -88,6 +96,9 @@ export const Demo = () => {
imageSettings: defaultImageSettings,
})
+ const download = (format: DownloadFileFormat) =>
+ qrRef.current?.download({ name: 'qr-code', format, size: 1000 })
+
return (
@@ -138,13 +149,25 @@ export const Demo = () => {
-
+
+
+
+
+
+
+
+ download('png')}>PNG
+ download('svg')}>SVG
+
+
+
)
diff --git a/apps/docs/src/components/ui/dropdown-menu.tsx b/apps/docs/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 00000000..ff053050
--- /dev/null
+++ b/apps/docs/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,45 @@
+'use client'
+
+import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
+import * as React from 'react'
+
+import { cn } from '@/lib/utils'
+
+const DropdownMenu = DropdownMenuPrimitive.Root
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+
+
+))
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+
+export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem }
diff --git a/packages/react-qr-code/src/react-qr-code.tsx b/packages/react-qr-code/src/react-qr-code.tsx
index 4b05192a..563d92b9 100644
--- a/packages/react-qr-code/src/react-qr-code.tsx
+++ b/packages/react-qr-code/src/react-qr-code.tsx
@@ -66,7 +66,7 @@ const ReactQRCode = forwardRef((props, ref) =>
if (!svgRef.current) return
if (fileFormat === 'svg') {
- downloadSVG({ svgRef, fileSize, fileName })
+ void downloadSVG({ svgRef, fileSize, fileName })
} else {
downloadRaster({
svgRef,
diff --git a/packages/react-qr-code/src/utils/download.test.ts b/packages/react-qr-code/src/utils/download.test.ts
index c51c2f80..fc3adaa2 100644
--- a/packages/react-qr-code/src/utils/download.test.ts
+++ b/packages/react-qr-code/src/utils/download.test.ts
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { DEFAULT_BGCOLOR } from '../constants'
import type { DownloadFileFormat } from '../types/lib'
-import { downloadRaster, getFileHeight } from './download'
+import { downloadRaster, getFileHeight, inlineImages } from './download'
const svgWithViewBox = (width: number, height: number) =>
({ viewBox: { baseVal: { width, height } } }) as unknown as SVGSVGElement
@@ -70,3 +70,70 @@ describe('downloadRaster', () => {
expect(ctx.fillRect).not.toHaveBeenCalled()
})
})
+
+describe('inlineImages', () => {
+ const svgWithImage = (href: string) => {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const image = document.createElementNS('http://www.w3.org/2000/svg', 'image')
+ image.setAttribute('href', href)
+ svg.appendChild(image)
+ return { svg, image }
+ }
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('replaces linked images with data URLs', async () => {
+ const blob = new Blob([''], { type: 'image/svg+xml' })
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => ({ ok: true, blob: async () => blob })),
+ )
+ const { svg, image } = svgWithImage('https://example.com/logo.svg')
+ await inlineImages(svg)
+ expect(image.getAttribute('href')).toMatch(/^data:image\/svg\+xml;base64,/)
+ })
+
+ it('keeps the original href when the fetch fails', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ throw new TypeError('Failed to fetch')
+ }),
+ )
+ const { svg, image } = svgWithImage('https://example.com/logo.svg')
+ await inlineImages(svg)
+ expect(image.getAttribute('href')).toBe('https://example.com/logo.svg')
+ })
+
+ it('keeps the original href on an HTTP error', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => ({ ok: false, status: 404 })),
+ )
+ const { svg, image } = svgWithImage('https://example.com/missing.svg')
+ await inlineImages(svg)
+ expect(image.getAttribute('href')).toBe('https://example.com/missing.svg')
+ })
+
+ it('sends credentials when the image uses use-credentials', async () => {
+ const blob = new Blob([''], { type: 'image/svg+xml' })
+ const fetchMock = vi.fn(async () => ({ ok: true, blob: async () => blob }))
+ vi.stubGlobal('fetch', fetchMock)
+ const { svg, image } = svgWithImage('https://example.com/logo.svg')
+ image.setAttribute('crossorigin', 'use-credentials')
+ await inlineImages(svg)
+ expect(fetchMock).toHaveBeenCalledWith('https://example.com/logo.svg', {
+ credentials: 'include',
+ })
+ })
+
+ it('leaves data URLs untouched', async () => {
+ const fetchMock = vi.fn()
+ vi.stubGlobal('fetch', fetchMock)
+ const { svg } = svgWithImage('data:image/png;base64,AAAA')
+ await inlineImages(svg)
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/react-qr-code/src/utils/download.ts b/packages/react-qr-code/src/utils/download.ts
index 36337f02..12a56f71 100644
--- a/packages/react-qr-code/src/utils/download.ts
+++ b/packages/react-qr-code/src/utils/download.ts
@@ -7,12 +7,45 @@ export const getFileHeight = (svg: SVGSVGElement, fileSize: number) => {
return width && height ? Math.round((fileSize * height) / width) : fileSize
}
-export const downloadSVG = ({ svgRef, fileSize, fileName }: DownloadSVGProps) => {
+const toDataURL = async (src: string, credentials: RequestCredentials) => {
+ const response = await fetch(src, { credentials })
+ if (!response.ok) throw new Error(`Failed to fetch ${src}: ${response.status}`)
+ const blob = await response.blob()
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => resolve(reader.result as string)
+ reader.onerror = () => reject(reader.error)
+ reader.readAsDataURL(blob)
+ })
+}
+
+// Embed linked images so the downloaded file works offline; keep the link if the fetch fails (e.g. CORS).
+export const inlineImages = async (svg: SVGSVGElement) => {
+ const images = Array.from(svg.querySelectorAll('image'))
+ await Promise.all(
+ images.map(async (image) => {
+ const href = image.getAttribute('href')
+ if (!href || href.startsWith('data:')) return
+ try {
+ const credentials =
+ image.getAttribute('crossorigin') === 'use-credentials'
+ ? 'include'
+ : 'same-origin'
+ image.setAttribute('href', await toDataURL(href, credentials))
+ } catch {
+ // Keep the original href
+ }
+ }),
+ )
+}
+
+export const downloadSVG = async ({ svgRef, fileSize, fileName }: DownloadSVGProps) => {
if (!svgRef.current) return
const clonedSvg = svgRef.current.cloneNode(true) as SVGSVGElement
clonedSvg.setAttribute('width', fileSize.toString())
clonedSvg.setAttribute('height', getFileHeight(svgRef.current, fileSize).toString())
+ await inlineImages(clonedSvg)
const serializer = new XMLSerializer()
const svgBlob = new Blob([serializer.serializeToString(clonedSvg)], {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fc417dad..95b5b9ca 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -83,6 +83,9 @@ importers:
'@radix-ui/react-dialog':
specifier: ^1.2.0
version: 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ '@radix-ui/react-dropdown-menu':
+ specifier: ^2.1.25
+ version: 2.1.25(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
'@radix-ui/react-label':
specifier: ^2.1.16
version: 2.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
@@ -1280,6 +1283,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-dropdown-menu@2.1.25':
+ resolution: {integrity: sha512-45QO76jn/a+cRl6OPgZKCKnIMdfYZaOa6SuAJeKHFiM3orX6bnscuFQJDx3qS4Agp/RE+eKv+9gmUL8t3Ma7bw==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-focus-guards@1.1.6':
resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==}
peerDependencies:
@@ -1324,6 +1340,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-menu@2.1.25':
+ resolution: {integrity: sha512-e8JaQc0ijBbTx37YVI09Nfq8ThUWJ4L1rvZ3Hvaufehco9/ug9+U3PHz9ue6w3gKd91UcKf9fgDWejeSO+VSKQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-popover@1.2.0':
resolution: {integrity: sha512-e70Nh+0iQ+hRAIM2nAGib5BhaDL2Rjlzxw9kIzsU2nz+t3/t82Z5qtPhY5nTnfj8FbmrU6RRIKH8RF3ynCe+vw==}
peerDependencies:
@@ -4134,6 +4163,21 @@ snapshots:
'@types/react': 19.3.0
'@types/react-dom': 19.3.0(@types/react@19.3.0)
+ '@radix-ui/react-dropdown-menu@2.1.25(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.7
+ '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0)
+ '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0)
+ '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0)
+ '@radix-ui/react-menu': 2.1.25(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0)
+ react: 19.3.0
+ react-dom: 19.3.0(react@19.3.0)
+ optionalDependencies:
+ '@types/react': 19.3.0
+ '@types/react-dom': 19.3.0(@types/react@19.3.0)
+
'@radix-ui/react-focus-guards@1.1.6(@types/react@19.3.0)(react@19.3.0)':
dependencies:
react: 19.3.0
@@ -4167,6 +4211,32 @@ snapshots:
'@types/react': 19.3.0
'@types/react-dom': 19.3.0(@types/react@19.3.0)
+ '@radix-ui/react-menu@2.1.25(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.7
+ '@radix-ui/react-collection': 1.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0)
+ '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0)
+ '@radix-ui/react-direction': 1.1.5(@types/react@19.3.0)(react@19.3.0)
+ '@radix-ui/react-dismissable-layer': 1.1.20(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.3.0)(react@19.3.0)
+ '@radix-ui/react-focus-scope': 1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0)
+ '@radix-ui/react-popper': 1.3.8(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ '@radix-ui/react-portal': 1.1.18(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ '@radix-ui/react-presence': 1.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ '@radix-ui/react-primitive': 2.1.11(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ '@radix-ui/react-roving-focus': 1.1.20(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ '@radix-ui/react-slot': 1.4.0(@types/react@19.3.0)(react@19.3.0)
+ '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.3.0)(react@19.3.0)
+ aria-hidden: 1.2.6
+ react: 19.3.0
+ react-dom: 19.3.0(react@19.3.0)
+ react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.0)
+ optionalDependencies:
+ '@types/react': 19.3.0
+ '@types/react-dom': 19.3.0(@types/react@19.3.0)
+
'@radix-ui/react-popover@1.2.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)':
dependencies:
'@radix-ui/primitive': 1.1.7