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