diff --git a/.changeset/jpeg-download-background.md b/.changeset/jpeg-download-background.md new file mode 100644 index 00000000..17b9ae69 --- /dev/null +++ b/.changeset/jpeg-download-background.md @@ -0,0 +1,5 @@ +--- +'@lglab/react-qr-code': patch +--- + +Fix JPEG downloads of QR codes without a background: transparent areas were exported as black, making the code unscannable. JPEG downloads now use a white background; PNG downloads stay transparent. diff --git a/.changeset/qr-code-frames.md b/.changeset/qr-code-frames.md new file mode 100644 index 00000000..fe6f64a0 --- /dev/null +++ b/.changeset/qr-code-frames.md @@ -0,0 +1,5 @@ +--- +'@lglab/react-qr-code': minor +--- + +Add `frameSettings` to draw a frame with a call-to-action label around the QR code. Available styles: `banner-bottom`, `banner-top`, `ticket`, `bubble` and `border`. diff --git a/README.md b/README.md index dd12d08b..20b3ea8b 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A highly customizable and lightweight QR code generator for React applications. ## Quick Features - **Highly Customizable** - Style the finder patterns, modules, and colors exactly how you want. +- **Frames** - Wrap the QR code in a frame with a call-to-action label like "Scan me!". - **Performance Optimized** - Generates QR codes efficiently without sacrificing quality. - **SVG-Based Rendering** - Crisp and scalable output for web and print - **Developer-Friendly** - Built with TypeScript, easy to use, and well-documented. diff --git a/apps/docs/public/llms-full.txt b/apps/docs/public/llms-full.txt index 7f2fbd94..b081531c 100644 --- a/apps/docs/public/llms-full.txt +++ b/apps/docs/public/llms-full.txt @@ -24,6 +24,7 @@ The main component exported by the library. | `finderPatternOuterSettings` | `FinderPatternOuterSettings` | - | Configuration for outer finder patterns. | | `finderPatternInnerSettings` | `FinderPatternInnerSettings` | - | Configuration for inner finder patterns. | | `imageSettings` | `ImageSettings` | - | Settings for an embedded logo or image. | +| `frameSettings` | `FrameSettings` | - | Draws a frame with a call-to-action label around the QR code. `size` then applies to the frame width; the height grows to fit the label. | | `svgProps` | `React.SVGProps` | - | Additional props passed to the root SVG element. | --- @@ -75,6 +76,21 @@ Includes all `FinderPatternOuterStyle` plus: `'diamond'`, `'star'`, `'heart'`, ` | `opacity` | `number` | `1` | Image opacity (0-1). | | `crossOrigin` | `CrossOrigin` | - | CORS setting for the image. | +### FrameSettings + +| Property | Type | Default | Description | +| --- | --- | --- | --- | +| `style` | `FrameStyle` | - | **Required.** Style of the frame. | +| `text` | `string` | `'Scan me!'` | Label rendered in the frame. Long labels are condensed to fit. An empty string hides it and the frame wraps the code evenly. | +| `color` | `string` | `'#000000'` | Frame color. | +| `textColor` | `string` | - | Label color. Defaults to white on filled frames and to `color` on `'border'`. | +| `fontFamily` | `string` | `'sans-serif'` | Label font family (CSS font-family list). Web fonts loaded by the page work on screen, but downloads only keep the name: SVG files need the font installed where they are opened, and PNG/JPEG exports cannot use page web fonts, only fonts installed on the device. End the list with a generic family. | + +**Available Styles (`FrameStyle`):** +`'banner-bottom'`, `'banner-top'`, `'ticket'`, `'bubble'`, `'border'` + +When a frame is set, the QR code sits on a light panel (the `background` color when it is a string, white otherwise) with a quiet zone of at least 2 modules, so it stays scannable on dark frames. + ### GradientSettings | Property | Type | Default | Description | @@ -137,3 +153,17 @@ import { ReactQRCode } from '@lglab/react-qr-code'; /> ``` +### Frame +```tsx + +``` + diff --git a/apps/docs/public/llms.txt b/apps/docs/public/llms.txt index efd13a25..09e77872 100644 --- a/apps/docs/public/llms.txt +++ b/apps/docs/public/llms.txt @@ -17,6 +17,7 @@ This site serves as the central hub for the `@lglab/react-qr-code` library, prov - [Finder Pattern Outer Settings](https://reactqrcode.com/finder-pattern-outer-settings) - [Gradient Settings](https://reactqrcode.com/gradient-settings) - [Image Settings](https://reactqrcode.com/image-settings) +- [Frame Settings](https://reactqrcode.com/frame-settings) - [Examples](https://reactqrcode.com/examples) ## External Links diff --git a/apps/docs/src/app/examples/frame/page.tsx b/apps/docs/src/app/examples/frame/page.tsx new file mode 100644 index 00000000..eb542b49 --- /dev/null +++ b/apps/docs/src/app/examples/frame/page.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from 'next' +import Link from 'next/link' + +import { + DemoCustom, + DemoStyles, + customCodeBlock, + stylesCodeBlock, +} from '@/components/demos/frame' +import { PageHeading } from '@/components/page-heading' +import { ExampleTabs } from '@/components/ui/example-tabs' +import { TypographyH3, TypographyP } from '@/components/ui/typography' + +export const metadata: Metadata = { + title: 'Frame Example', + description: + 'Example showing how to add a frame with a "Scan me!" call-to-action label around QR codes using the FrameSettings options in @lglab/react-qr-code.', +} + +export default function Page() { + return ( + <> + + + The @lglab/react-qr-code library can draw a frame with a call-to-action label + around your QR codes, making them easier to spot on print and screens. Frames are + part of the SVG, so they are included when you download the QR code. See{' '} + + Frame Settings + {' '} + api reference for more information. + + + Styles + } /> + +
+ + Custom label and colors + } /> + + ) +} diff --git a/apps/docs/src/app/frame-settings/page.tsx b/apps/docs/src/app/frame-settings/page.tsx new file mode 100644 index 00000000..4cee8df3 --- /dev/null +++ b/apps/docs/src/app/frame-settings/page.tsx @@ -0,0 +1,94 @@ +import type { Metadata } from 'next' + +import { PageHeading } from '@/components/page-heading' +import { PropsTable } from '@/components/props-table' +import { + TypographyH2, + TypographyInlineCode, + TypographyP, +} from '@/components/ui/typography' +import type { Prop } from '@/types/props' + +export const metadata: Metadata = { + title: 'FrameSettings', + description: + 'Configuration options for FrameSettings in @lglab/react-qr-code, used to draw a frame with a call-to-action label such as "Scan me!" around QR codes.', +} + +const props: Prop[] = [ + { + name: 'style', + type: 'FrameStyle', + description: 'The style of the frame.', + required: true, + possibleValues: ['banner-bottom', 'banner-top', 'border', 'bubble', 'ticket'], + }, + { + name: 'text', + type: 'string', + description: + 'The label rendered in the frame. Long labels are condensed to fit the frame width. Pass an empty string to hide the label, the frame then wraps the QR Code evenly.', + defaultValue: 'Scan me!', + }, + { + name: 'color', + type: 'string', + description: 'The frame color.', + defaultValue: '#000000', + }, + { + name: 'textColor', + type: 'string', + description: + 'The label color. Defaults to white on filled frames (banner-bottom, banner-top, ticket, bubble) and to the frame color on border.', + }, + { + name: 'fontFamily', + type: 'string', + description: + 'The label font family, as a CSS font-family value. See the font family section below for how it behaves in downloads.', + defaultValue: 'sans-serif', + }, +] + +export default function Page() { + return ( + <> + + + These are the properties you can use to draw a frame with a call-to-action label + around the QR Code. When a frame is set, the size prop applies to the frame width + and the height grows to fit the label. The QR Code sits on a light panel (the + background color, white by default) with a quiet zone of at least 2 modules, so it + stays scannable on dark frames. + + + + Font family + + The fontFamily value is set as the font-family of the SVG label, so it accepts any + CSS font-family list such as{' '} + 'Georgia, serif'. The label + is always bold. Which font is actually used depends on where the QR Code is + displayed: + + + On the page, the label can use system fonts and any web font your page has already + loaded, for example through Google Fonts, next/font or @font-face, as long as the + name matches. + + + Downloads only keep the font name, not the font file. An SVG download shows the + font if it is installed where the file is opened. PNG and JPEG downloads are + rendered as an isolated image that cannot see your page's web fonts, so they + use the font only if it is installed on the user's device. + + + In every case a missing font falls back to the next font in the list, so always + end it with a generic family such as sans-serif or serif. For labels that must + look the same everywhere, prefer widely installed fonts like Arial, Helvetica, + Georgia or Verdana. + + + ) +} diff --git a/apps/docs/src/app/react-qr-code-props/page.tsx b/apps/docs/src/app/react-qr-code-props/page.tsx index 2ccf5252..23d9c2d3 100644 --- a/apps/docs/src/app/react-qr-code-props/page.tsx +++ b/apps/docs/src/app/react-qr-code-props/page.tsx @@ -105,6 +105,19 @@ const props: Prop[] = [ ), }, + { + name: 'frameSettings', + type: 'FrameSettings', + description: ( + <> + Draws a frame with a call-to-action label around the QR code. When set, size + applies to the frame width and the height grows to fit the label. See{' '} + + FrameSettings + + + ), + }, { name: 'level', type: 'ErrorCorrectionLevel', diff --git a/apps/docs/src/app/sitemap.ts b/apps/docs/src/app/sitemap.ts index 1778be58..b7f68b0d 100644 --- a/apps/docs/src/app/sitemap.ts +++ b/apps/docs/src/app/sitemap.ts @@ -17,12 +17,14 @@ const PAGES = [ { path: '/finder-pattern-inner-settings', priority: 0.7 }, { path: '/gradient-settings', priority: 0.7 }, { path: '/image-settings', priority: 0.7 }, + { path: '/frame-settings', priority: 0.7 }, { path: '/ref-api', priority: 0.7 }, { path: '/examples/basic', priority: 0.7 }, { path: '/examples/data-modules', priority: 0.7 }, { path: '/examples/finder-patterns', priority: 0.7 }, { path: '/examples/background', priority: 0.7 }, { path: '/examples/image', priority: 0.7 }, + { path: '/examples/frame', priority: 0.7 }, { path: '/examples/download', priority: 0.7 }, ] diff --git a/apps/docs/src/components/demo/demo.tsx b/apps/docs/src/components/demo/demo.tsx index 5e0f42ca..3fb164f0 100644 --- a/apps/docs/src/components/demo/demo.tsx +++ b/apps/docs/src/components/demo/demo.tsx @@ -1,6 +1,7 @@ 'use client' import { + type FrameSettings, ReactQRCode, type ReactQRCodeProps, type ReactQRCodeRef, @@ -19,6 +20,7 @@ import { Colors } from './colors' import { DataModules } from './data-modules' import { FinderPatternInner } from './finder-pattern-inner' import { FinderPatternOuter } from './finder-pattern-outer' +import { FrameSettingsDemo } from './frame-settings' import { ImageSettingsDemo } from './image-settings' import { MainSettings } from './main-settings' @@ -32,6 +34,12 @@ export const defaultImageSettings = { y: undefined, } +export const defaultFrameSettings: FrameSettings = { + style: 'ticket', + text: 'Scan me!', + color: '#000000', +} + export const defaultBgColor = '#FFFFFF' export const defaultBgGradient = { type: 'linear', @@ -120,6 +128,12 @@ export const Demo = () => { + + Frame + + + +
diff --git a/apps/docs/src/components/demo/frame-settings.tsx b/apps/docs/src/components/demo/frame-settings.tsx new file mode 100644 index 00000000..38e512db --- /dev/null +++ b/apps/docs/src/components/demo/frame-settings.tsx @@ -0,0 +1,104 @@ +import { + type FrameSettings, + type FrameStyle, + type ReactQRCodeProps, +} from '@lglab/react-qr-code' +import { type Dispatch, useState } from 'react' + +import { FormCheckbox, FormField } from '@/components/ui/form-fields' +import { Input } from '@/components/ui/input' + +import { Button } from '../ui/button' +import { ColorPicker } from '../ui/color-picker' +import { defaultFrameSettings } from './demo' + +interface FrameSettingsDemoProps { + qrProps: ReactQRCodeProps + setQrProps: Dispatch> +} + +const styles: FrameStyle[] = ['banner-bottom', 'banner-top', 'border', 'bubble', 'ticket'] + +export const FrameSettingsDemo = ({ qrProps, setQrProps }: FrameSettingsDemoProps) => { + const [includeFrame, setIncludeFrame] = useState(!!qrProps.frameSettings) + const [customTextColor, setCustomTextColor] = useState( + !!qrProps.frameSettings?.textColor, + ) + + const onValueChange = (key: keyof FrameSettings, value: string | undefined) => { + setQrProps((prevProps) => ({ + ...prevProps, + frameSettings: { + ...(prevProps.frameSettings ?? defaultFrameSettings), + [key]: value, + } as FrameSettings, + })) + } + + const onIncludeFrameChange = (checked: boolean) => { + setIncludeFrame(checked) + setQrProps((prevProps) => ({ + ...prevProps, + frameSettings: checked ? defaultFrameSettings : undefined, + })) + } + + const onCustomTextColorChange = (checked: boolean) => { + setCustomTextColor(checked) + onValueChange('textColor', checked ? '#FFFFFF' : undefined) + } + + return ( + <> + onIncludeFrameChange(checked as boolean)} + /> + +
+ {styles.map((style) => ( + + ))} +
+
+ + onValueChange('text', e.target.value)} + /> + + + onValueChange('color', value)} + /> + + onCustomTextColorChange(checked as boolean)} + /> + + onValueChange('textColor', value)} + /> + + + ) +} diff --git a/apps/docs/src/components/demos/frame.tsx b/apps/docs/src/components/demos/frame.tsx new file mode 100644 index 00000000..967c4bcd --- /dev/null +++ b/apps/docs/src/components/demos/frame.tsx @@ -0,0 +1,137 @@ +'use client' + +import { type FrameStyle, ReactQRCode, type ReactQRCodeProps } from '@lglab/react-qr-code' + +const styles: FrameStyle[] = ['banner-bottom', 'banner-top', 'ticket', 'bubble', 'border'] + +export const stylesCodeBlock = ` +import { type FrameStyle, ReactQRCode } from '@lglab/react-qr-code' + +const styles: FrameStyle[] = ['banner-bottom', 'banner-top', 'ticket', 'bubble', 'border'] + +export const Demo = () => { + return ( +
+ {styles.map((style) => ( + + ))} +
+ ) +} +` + +export const DemoStyles = () => { + return ( +
+ {styles.map((style) => ( + + ))} +
+ ) +} + +export const customCodeBlock = ` +import { ReactQRCode, type ReactQRCodeProps } from '@lglab/react-qr-code' + +const examples: Omit[] = [ + { + frameSettings: { style: 'ticket', text: 'View menu', color: '#4f46e5' }, + dataModulesSettings: { style: 'circle', color: '#4f46e5' }, + finderPatternOuterSettings: { style: 'rounded-lg', color: '#4f46e5' }, + finderPatternInnerSettings: { style: 'circle', color: '#4f46e5' }, + }, + { + frameSettings: { style: 'bubble', text: 'Follow us', color: '#18181b' }, + dataModulesSettings: { style: 'rounded', color: '#18181b' }, + finderPatternOuterSettings: { style: 'rounded-lg', color: '#18181b' }, + finderPatternInnerSettings: { style: 'rounded', color: '#00a3ab' }, + }, + { + frameSettings: { style: 'banner-top', text: 'Free Wi-Fi', color: '#0e7490' }, + gradient: { + type: 'linear', + rotation: 45, + stops: [ + { offset: '0%', color: '#0e7490' }, + { offset: '100%', color: '#2563eb' }, + ], + }, + dataModulesSettings: { style: 'leaf' }, + finderPatternOuterSettings: { style: 'leaf-lg' }, + finderPatternInnerSettings: { style: 'leaf' }, + }, +] + +export const Demo = () => { + return ( +
+ {examples.map((props, i) => ( + + ))} +
+ ) +} +` + +const examples: Omit[] = [ + { + frameSettings: { style: 'ticket', text: 'View menu', color: '#4f46e5' }, + dataModulesSettings: { style: 'circle', color: '#4f46e5' }, + finderPatternOuterSettings: { style: 'rounded-lg', color: '#4f46e5' }, + finderPatternInnerSettings: { style: 'circle', color: '#4f46e5' }, + }, + { + frameSettings: { style: 'bubble', text: 'Follow us', color: '#18181b' }, + dataModulesSettings: { style: 'rounded', color: '#18181b' }, + finderPatternOuterSettings: { style: 'rounded-lg', color: '#18181b' }, + finderPatternInnerSettings: { style: 'rounded', color: '#00a3ab' }, + }, + { + frameSettings: { style: 'banner-top', text: 'Free Wi-Fi', color: '#0e7490' }, + gradient: { + type: 'linear', + rotation: 45, + stops: [ + { offset: '0%', color: '#0e7490' }, + { offset: '100%', color: '#2563eb' }, + ], + }, + dataModulesSettings: { style: 'leaf' }, + finderPatternOuterSettings: { style: 'leaf-lg' }, + finderPatternInnerSettings: { style: 'leaf' }, + }, +] + +export const DemoCustom = () => { + return ( +
+ {examples.map((props, i) => ( + + ))} +
+ ) +} diff --git a/apps/docs/src/components/prop-type-tag.tsx b/apps/docs/src/components/prop-type-tag.tsx index d491aaf8..0b9002b1 100644 --- a/apps/docs/src/components/prop-type-tag.tsx +++ b/apps/docs/src/components/prop-type-tag.tsx @@ -30,6 +30,8 @@ export const PropTypeTag = ({ type }: { type: PropType }) => { '(options: DownloadOptions) => void': 'bg-purple-100 text-purple-800', 'React.RefObject': 'bg-purple-100 text-purple-800', ImageSettings: 'bg-purple-100 text-purple-800', + FrameSettings: 'bg-purple-100 text-purple-800', + FrameStyle: 'bg-purple-100 text-purple-800', } return ( diff --git a/apps/docs/src/config/navigation.ts b/apps/docs/src/config/navigation.ts index 32b79a68..650a66d9 100644 --- a/apps/docs/src/config/navigation.ts +++ b/apps/docs/src/config/navigation.ts @@ -55,6 +55,10 @@ export const mainNav: MenuItemProps[] = [ title: 'ImageSettings', url: '/image-settings', }, + { + title: 'FrameSettings', + url: '/frame-settings', + }, { title: 'ReactQRCodeRef', url: '/ref-api', @@ -85,6 +89,10 @@ export const mainNav: MenuItemProps[] = [ title: 'Image', url: '/examples/image', }, + { + title: 'Frame', + url: '/examples/frame', + }, { title: 'Download', url: '/examples/download', diff --git a/apps/docs/src/types/props.ts b/apps/docs/src/types/props.ts index 36563930..3dc711a2 100644 --- a/apps/docs/src/types/props.ts +++ b/apps/docs/src/types/props.ts @@ -27,6 +27,8 @@ export type PropType = | 'React.RefObject' | 'SVGSVGElement' | 'ImageSettings' + | 'FrameSettings' + | 'FrameStyle' export interface Prop { name: string diff --git a/packages/react-qr-code/README.md b/packages/react-qr-code/README.md index 51b49b3e..ebc75dd2 100644 --- a/packages/react-qr-code/README.md +++ b/packages/react-qr-code/README.md @@ -10,6 +10,7 @@ A highly customizable and lightweight QR code generator for React applications. ## Quick Features - **Highly Customizable** - Style the finder patterns, modules, and colors exactly how you want. +- **Frames** - Wrap the QR code in a frame with a call-to-action label like "Scan me!". - **Performance Optimized** - Generates QR codes efficiently without sacrificing quality. - **SVG-Based Rendering** - Crisp and scalable output for web and print - **Developer-Friendly** - Built with TypeScript, easy to use, and well-documented. diff --git a/packages/react-qr-code/src/components/frame.tsx b/packages/react-qr-code/src/components/frame.tsx new file mode 100644 index 00000000..88c7c758 --- /dev/null +++ b/packages/react-qr-code/src/components/frame.tsx @@ -0,0 +1,139 @@ +import { DEFAULT_FRAME_COLOR, DEFAULT_FRAME_TEXT } from '../constants' +import type { FrameSettings } from '../types/lib' +import { type FrameLayout, isFilledFrame } from '../utils/frame' + +interface FrameProps { + settings: FrameSettings + layout: FrameLayout + maskId: string +} + +// Rough average glyph width relative to font size for bold sans-serif fonts. +const GLYPH_WIDTH_RATIO = 0.6 + +export const FrameBack = ({ settings, layout, maskId }: FrameProps) => { + const color = settings.color ?? DEFAULT_FRAME_COLOR + const { width, height, radius, padding, strokeWidth, labelHeight } = layout + + const outline = ( + + ) + const filled = ( + + ) + const hasLabel = labelHeight > 0 + + switch (settings.style) { + case 'banner-bottom': + case 'banner-top': + return filled + case 'ticket': { + if (!hasLabel) return filled + const notchRadius = padding * 0.8 + return ( + <> + + + + + + + + + + ) + } + case 'bubble': { + if (!hasLabel) return outline + const top = height - labelHeight + const r = labelHeight / 2 + const pointerSize = padding * 1.1 + const cx = width / 2 + // Pill and pointer as a single path, so no anti-aliasing seam shows between them. + const bubble = [ + `M${r},${top}`, + `H${cx - pointerSize}`, + `L${cx},${top - pointerSize}`, + `L${cx + pointerSize},${top}`, + `H${width - r}`, + `A${r},${r} 0 0 1 ${width - r},${height}`, + `H${r}`, + `A${r},${r} 0 0 1 ${r},${top}`, + 'Z', + ].join(' ') + return ( + + {outline} + + + ) + } + case 'border': + return outline + default: + return null + } +} + +export const FrameLabel = ({ settings, layout }: Omit) => { + const color = settings.color ?? DEFAULT_FRAME_COLOR + const text = settings.text ?? DEFAULT_FRAME_TEXT + const textColor = + settings.textColor ?? + (isFilledFrame(settings.style) || settings.style === 'bubble' ? '#FFFFFF' : color) + const { width, padding, labelHeight, labelY } = layout + + const fontSize = labelHeight * 0.5 + const maxTextWidth = width - padding * 3 + const overflows = text.length * fontSize * GLYPH_WIDTH_RATIO > maxTextWidth + + return ( + <> + {settings.style === 'ticket' && text && ( + + )} + {text && ( + + {text} + + )} + + ) +} diff --git a/packages/react-qr-code/src/constants.ts b/packages/react-qr-code/src/constants.ts index 8dd46c67..7ebc6d1d 100644 --- a/packages/react-qr-code/src/constants.ts +++ b/packages/react-qr-code/src/constants.ts @@ -39,6 +39,12 @@ export const DEFAULT_FILENAME = 'react-qr-code' export const GRADIENT_ID = 'react-qr-code-gradient' export const BG_GRADIENT_ID = 'react-qr-code-bg-gradient' +export const FRAME_MASK_ID = 'react-qr-code-frame-mask' + +export const DEFAULT_FRAME_TEXT = 'Scan me!' +export const DEFAULT_FRAME_COLOR = '#000000' +// Minimum light area, in modules, kept between the QR code and the frame. +export const FRAME_MIN_QUIET_ZONE = 2 // This is *very* rough estimate of max amount of QRCode allowed to be covered. // It is "wrong" in a lot of ways (area is a terrible way to estimate, it diff --git a/packages/react-qr-code/src/hooks/use-ids.tsx b/packages/react-qr-code/src/hooks/use-ids.tsx index 76aa257f..e9cde972 100644 --- a/packages/react-qr-code/src/hooks/use-ids.tsx +++ b/packages/react-qr-code/src/hooks/use-ids.tsx @@ -1,6 +1,6 @@ import { useId } from 'react' -import { BG_GRADIENT_ID, GRADIENT_ID } from '../constants' +import { BG_GRADIENT_ID, FRAME_MASK_ID, GRADIENT_ID } from '../constants' export const useIds = () => { const uuid = useId() @@ -8,5 +8,6 @@ export const useIds = () => { return { gradientId: `${GRADIENT_ID}-${uuid}`, bgGradientId: `${BG_GRADIENT_ID}-${uuid}`, + frameMaskId: `${FRAME_MASK_ID}-${uuid}`, } } diff --git a/packages/react-qr-code/src/react-qr-code.test.tsx b/packages/react-qr-code/src/react-qr-code.test.tsx index 8942b3cb..b964888f 100644 --- a/packages/react-qr-code/src/react-qr-code.test.tsx +++ b/packages/react-qr-code/src/react-qr-code.test.tsx @@ -4,7 +4,10 @@ import { describe, expect, it, vi } from 'vitest' import { BG_GRADIENT_ID, + DEFAULT_BGCOLOR, + DEFAULT_FRAME_TEXT, DEFAULT_MARGIN_SIZE, + FRAME_MIN_QUIET_ZONE, DEFAULT_SIZE, GRADIENT_ID, } from './constants' @@ -14,6 +17,7 @@ import type { DataModulesSettings, FinderPatternInnerSettings, FinderPatternOuterSettings, + FrameStyle, GradientSettings, GradientSettingsType, ReactQRCodeRef, @@ -293,6 +297,191 @@ describe('ReactQRCode', () => { }) }) + describe('Frame settings', () => { + const styles: FrameStyle[] = [ + 'banner-bottom', + 'banner-top', + 'ticket', + 'bubble', + 'border', + ] + + it('renders no frame when not provided', () => { + render() + + expect(screen.queryByTestId('frame')).not.toBeInTheDocument() + expect(screen.queryByTestId('frame-label')).not.toBeInTheDocument() + }) + + it.each(styles)('renders the %s frame with the default label', (style) => { + render() + + expect(screen.getByTestId('frame')).toBeInTheDocument() + expect(screen.getByTestId('frame-label')).toHaveTextContent(DEFAULT_FRAME_TEXT) + }) + + it.each(styles)('keeps the width and grows the height for %s', (style) => { + render() + + const svg = screen.getByRole('img') + const [, , vbWidth, vbHeight] = svg.getAttribute('viewBox')!.split(' ').map(Number) + expect(svg).toHaveAttribute('width', '200') + expect(Number(svg.getAttribute('height'))).toBeCloseTo((200 * vbHeight) / vbWidth) + expect(vbHeight).toBeGreaterThan(vbWidth) + }) + + it('renders a custom label and colors', () => { + render( + , + ) + + expect(screen.getByTestId('frame')).toHaveAttribute('fill', '#0b4d3c') + const label = screen.getByTestId('frame-label') + expect(label).toHaveTextContent('Menu') + expect(label).toHaveAttribute('fill', '#ffcc00') + expect(label).toHaveAttribute('font-family', 'serif') + }) + + it('draws the bubble and its pointer as a single path', () => { + const { container } = render( + , + ) + + const bubble = screen.getByTestId('frame-bubble') + expect(bubble).toHaveAttribute('fill', '#c40000') + expect(bubble.getAttribute('d')!.match(/M/g)).toHaveLength(1) + expect(container.querySelectorAll('path[fill="#c40000"]')).toHaveLength(1) + }) + + it('uses the frame color for the label of outlined frames', () => { + render( + , + ) + + expect(screen.getByTestId('frame')).toHaveAttribute('stroke', '#c40000') + expect(screen.getByTestId('frame-label')).toHaveAttribute('fill', '#c40000') + }) + + it.each(styles)( + 'renders a square %s frame without a label when text is empty', + (style) => { + const { container } = render( + , + ) + + const [, , vbWidth, vbHeight] = screen + .getByRole('img') + .getAttribute('viewBox')! + .split(' ') + .map(Number) + expect(vbHeight).toBeCloseTo(vbWidth) + expect(screen.getByTestId('frame')).toBeInTheDocument() + expect(screen.queryByTestId('frame-label')).not.toBeInTheDocument() + expect(container.querySelector('mask')).not.toBeInTheDocument() + expect(container.querySelector('line')).not.toBeInTheDocument() + }, + ) + + it('fits long labels to the frame width', () => { + render( + , + ) + + const label = screen.getByTestId('frame-label') + expect(label).toHaveAttribute('textLength') + expect(label).toHaveAttribute('lengthAdjust', 'spacingAndGlyphs') + }) + + it('renders a white panel behind the code by default', () => { + render() + + expect(screen.getByTestId('frame-panel')).toHaveAttribute('fill', DEFAULT_BGCOLOR) + expect(screen.queryByTestId('background')).not.toBeInTheDocument() + }) + + it('uses a solid background for the panel', () => { + render( + , + ) + + expect(screen.getByTestId('frame-panel')).toHaveAttribute('fill', '#eeeeee') + expect(screen.getByTestId('background')).toHaveAttribute('fill', '#eeeeee') + }) + + it('keeps a quiet zone around codes without a margin', () => { + render( + , + ) + + const panel = screen.getByTestId('frame-panel') + const [, x, y] = screen + .getByTestId('data-modules') + .closest('g')! + .getAttribute('transform')! + .match(/translate\(([\d.]+) ([\d.]+)\)/)! + .map(Number) + expect(x - Number(panel.getAttribute('x'))).toBeCloseTo(FRAME_MIN_QUIET_ZONE) + expect(y - Number(panel.getAttribute('y'))).toBeCloseTo(FRAME_MIN_QUIET_ZONE) + }) + + it('offsets the code inside the frame', () => { + render() + + const group = screen.getByTestId('data-modules').closest('g') + expect(group?.getAttribute('transform')).toMatch(/^translate\(/) + }) + + it('uses unique mask ids for ticket frames', () => { + const { container } = render( + <> + + + , + ) + + const ids = [...container.querySelectorAll('mask')].map((mask) => mask.id) + expect(ids).toHaveLength(2) + expect(new Set(ids).size).toBe(2) + }) + + it('passes the frame layout to raster downloads', () => { + const ref = createRef() + render() + + ref.current?.download({ format: 'png', size: 300 }) + expect(vi.mocked(downloadRaster)).toHaveBeenLastCalledWith( + expect.objectContaining({ + frameLayout: expect.objectContaining({ qrX: expect.any(Number) }), + }), + ) + }) + }) + describe('ref functionality', () => { it('provides access to SVG element', () => { const ref = createRef() diff --git a/packages/react-qr-code/src/react-qr-code.tsx b/packages/react-qr-code/src/react-qr-code.tsx index 751793e3..4b05192a 100644 --- a/packages/react-qr-code/src/react-qr-code.tsx +++ b/packages/react-qr-code/src/react-qr-code.tsx @@ -4,8 +4,10 @@ import { Background } from './components/background' import { DataModules } from './components/data-modules' import { FinderPatternsInner } from './components/finder-patterns-inner' import { FinderPatternsOuter } from './components/finder-patterns-outer' +import { FrameBack, FrameLabel } from './components/frame' import { Gradient } from './components/gradient' import { + DEFAULT_BGCOLOR, DEFAULT_FILENAME, DEFAULT_LEVEL, DEFAULT_MINVERSION, @@ -15,6 +17,7 @@ import { useIds } from './hooks/use-ids' import { useQRCode } from './hooks/use-qr-code' import type { DownloadOptions, ReactQRCodeProps, ReactQRCodeRef } from './types/lib' import { downloadRaster, downloadSVG } from './utils/download' +import { getFrameLayout } from './utils/frame' import { excavateModules } from './utils/qr-code' const ReactQRCode = forwardRef((props, ref) => { @@ -31,11 +34,12 @@ const ReactQRCode = forwardRef((props, ref) => finderPatternInnerSettings, dataModulesSettings, imageSettings, + frameSettings, svgProps, } = props const svgRef = useRef(null) - const { gradientId, bgGradientId } = useIds() + const { gradientId, bgGradientId, frameMaskId } = useIds() const { margin, cells, numCells, calculatedImageSettings } = useQRCode({ value, level, @@ -46,6 +50,12 @@ const ReactQRCode = forwardRef((props, ref) => size, }) + const frameLayout = frameSettings + ? getFrameLayout(frameSettings.style, numCells, margin, frameSettings.text !== '') + : null + const viewBoxWidth = frameLayout?.width ?? numCells + const viewBoxHeight = frameLayout?.height ?? numCells + useImperativeHandle(ref, () => ({ svg: svgRef.current, download: ({ @@ -68,6 +78,7 @@ const ReactQRCode = forwardRef((props, ref) => size, numCells, margin, + frameLayout, }) } }, @@ -102,16 +113,8 @@ const ReactQRCode = forwardRef((props, ref) => gradientId, } - return ( - + const qrCode = ( + <> ((props, ref) => {image} + + ) + + return ( + + {frameSettings && frameLayout ? ( + <> + + + {qrCode} + + + ) : ( + qrCode + )} ) }) diff --git a/packages/react-qr-code/src/types/lib.ts b/packages/react-qr-code/src/types/lib.ts index 4da18f01..4b1de0f8 100644 --- a/packages/react-qr-code/src/types/lib.ts +++ b/packages/react-qr-code/src/types/lib.ts @@ -115,6 +115,35 @@ export interface FinderPatternInnerSettings { style?: FinderPatternInnerStyle } +export type FrameStyle = 'banner-bottom' | 'banner-top' | 'ticket' | 'bubble' | 'border' + +export interface FrameSettings { + /** + * The frame style. + */ + style: FrameStyle + /** + * The label rendered in the frame. Long labels are condensed to fit the frame + * width. An empty string hides the label and the frame wraps the code evenly. + * @defaultValue 'Scan me!' + */ + text?: string + /** + * The frame color. + * @defaultValue #000000 + */ + color?: string + /** + * The label color. Defaults to white, except for the `border` style, which uses `color`. + */ + textColor?: string + /** + * The label font family. + * @defaultValue sans-serif + */ + fontFamily?: string +} + export type DownloadFileFormat = 'svg' | 'png' | 'jpeg' export interface DownloadOptions { name?: string @@ -193,6 +222,13 @@ export interface ReactQRCodeProps { * The settings for the embedded image. */ imageSettings?: ImageSettings + /** + * Draws a decorative frame with a call-to-action label around the QR code. + * `size` applies to the frame width; the height grows to fit the label. The + * code sits on a light panel (the `background` color, white by default) with + * a quiet zone of at least 2 modules so it stays scannable. + */ + frameSettings?: FrameSettings /** * Optional props to pass to the SVG element. */ diff --git a/packages/react-qr-code/src/types/utils.ts b/packages/react-qr-code/src/types/utils.ts index 3068d7e9..2491e424 100644 --- a/packages/react-qr-code/src/types/utils.ts +++ b/packages/react-qr-code/src/types/utils.ts @@ -1,5 +1,6 @@ import { type RefObject } from 'react' +import type { FrameLayout } from '../utils/frame' import type { CrossOrigin, DataModulesSettings, @@ -64,6 +65,7 @@ export interface DownloadRasterProps { size: number numCells: number margin: number + frameLayout: FrameLayout | null } export interface CalculatedImageSettings { diff --git a/packages/react-qr-code/src/utils/download.test.ts b/packages/react-qr-code/src/utils/download.test.ts new file mode 100644 index 00000000..c51c2f80 --- /dev/null +++ b/packages/react-qr-code/src/utils/download.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { DEFAULT_BGCOLOR } from '../constants' +import type { DownloadFileFormat } from '../types/lib' +import { downloadRaster, getFileHeight } from './download' + +const svgWithViewBox = (width: number, height: number) => + ({ viewBox: { baseVal: { width, height } } }) as unknown as SVGSVGElement + +describe('getFileHeight', () => { + it('keeps square codes square', () => { + expect(getFileHeight(svgWithViewBox(33, 33), 500)).toBe(500) + }) + + it('scales the height by the frame aspect ratio', () => { + expect(getFileHeight(svgWithViewBox(40, 50), 400)).toBe(500) + }) + + it('falls back to the file size without a viewBox', () => { + expect( + getFileHeight({ viewBox: { baseVal: null } } as unknown as SVGSVGElement, 300), + ).toBe(300) + }) +}) + +describe('downloadRaster', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + const run = (fileFormat: DownloadFileFormat) => { + const ctx = { fillStyle: '', fillRect: vi.fn(), drawImage: vi.fn() } + const canvas = document.createElement('canvas') + vi.spyOn(canvas, 'getContext').mockReturnValue( + ctx as unknown as CanvasRenderingContext2D, + ) + const createElement = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => + tag === 'canvas' ? canvas : createElement(tag), + ) + URL.createObjectURL = vi.fn(() => 'blob:qr') + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + svg.setAttribute('viewBox', '0 0 33 33') + downloadRaster({ + svgRef: { current: svg }, + fileSize: 300, + fileName: 'qr', + fileFormat, + imageSettings: undefined, + calculatedImageSettings: null, + size: 128, + numCells: 33, + margin: 4, + frameLayout: null, + }) + return ctx + } + + it('fills a white background for jpeg, which has no transparency', () => { + const ctx = run('jpeg') + + expect(ctx.fillStyle).toBe(DEFAULT_BGCOLOR) + expect(ctx.fillRect).toHaveBeenCalledWith(0, 0, 300, 300) + }) + + it('keeps png transparent', () => { + const ctx = run('png') + + expect(ctx.fillRect).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react-qr-code/src/utils/download.ts b/packages/react-qr-code/src/utils/download.ts index 228ecd79..36337f02 100644 --- a/packages/react-qr-code/src/utils/download.ts +++ b/packages/react-qr-code/src/utils/download.ts @@ -1,11 +1,18 @@ +import { DEFAULT_BGCOLOR } from '../constants' import type { DownloadRasterProps, DownloadSVGProps } from '../types/utils' +// Frames make the SVG non-square, so scale the height by the viewBox aspect ratio. +export const getFileHeight = (svg: SVGSVGElement, fileSize: number) => { + const { width, height } = svg.viewBox.baseVal ?? {} + return width && height ? Math.round((fileSize * height) / width) : fileSize +} + export const downloadSVG = ({ svgRef, fileSize, fileName }: DownloadSVGProps) => { if (!svgRef.current) return const clonedSvg = svgRef.current.cloneNode(true) as SVGSVGElement clonedSvg.setAttribute('width', fileSize.toString()) - clonedSvg.setAttribute('height', fileSize.toString()) + clonedSvg.setAttribute('height', getFileHeight(svgRef.current, fileSize).toString()) const serializer = new XMLSerializer() const svgBlob = new Blob([serializer.serializeToString(clonedSvg)], { @@ -31,6 +38,7 @@ export const downloadRaster = ({ size, numCells, margin, + frameLayout, }: DownloadRasterProps) => { if (!svgRef.current) return @@ -39,7 +47,14 @@ export const downloadRaster = ({ if (!ctx) return canvas.width = fileSize - canvas.height = fileSize + const fileHeight = getFileHeight(svgRef.current, fileSize) + canvas.height = fileHeight + + // JPEG has no alpha channel, so transparent areas would come out black. + if (fileFormat === 'jpeg') { + ctx.fillStyle = DEFAULT_BGCOLOR + ctx.fillRect(0, 0, fileSize, fileHeight) + } const svgData = new XMLSerializer().serializeToString(svgRef.current) const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }) @@ -50,7 +65,7 @@ export const downloadRaster = ({ qrImg.src = svgUrl qrImg.onload = () => { - ctx.drawImage(qrImg, 0, 0, fileSize, fileSize) + ctx.drawImage(qrImg, 0, 0, fileSize, fileHeight) URL.revokeObjectURL(svgUrl) if (imageSettings?.src && calculatedImageSettings) { @@ -59,16 +74,20 @@ export const downloadRaster = ({ logoImg.src = imageSettings.src logoImg.onload = () => { + const viewBoxWidth = frameLayout?.width ?? numCells + const offsetX = frameLayout?.qrX ?? 0 + const offsetY = frameLayout?.qrY ?? 0 const ratio = fileSize / size - const scale = numCells / fileSize + const scale = viewBoxWidth / fileSize + const qrSize = numCells / scale - const logoSize = imageSettings.width * ratio + const logoSize = imageSettings.width * ratio * (numCells / viewBoxWidth) const logoX = imageSettings.x - ? (calculatedImageSettings.x + margin) / scale - : (fileSize - logoSize) / 2 + ? (calculatedImageSettings.x + margin + offsetX) / scale + : offsetX / scale + (qrSize - logoSize) / 2 const logoY = imageSettings.y - ? (calculatedImageSettings.y + margin) / scale - : (fileSize - logoSize) / 2 + ? (calculatedImageSettings.y + margin + offsetY) / scale + : offsetY / scale + (qrSize - logoSize) / 2 ctx.drawImage(logoImg, logoX, logoY, logoSize, logoSize) const imageType = fileFormat === 'png' ? 'image/png' : 'image/jpeg' diff --git a/packages/react-qr-code/src/utils/frame.test.ts b/packages/react-qr-code/src/utils/frame.test.ts new file mode 100644 index 00000000..589ea4f4 --- /dev/null +++ b/packages/react-qr-code/src/utils/frame.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' + +import { FRAME_MIN_QUIET_ZONE } from '../constants' +import type { FrameStyle } from '../types/lib' +import { getFrameLayout, isFilledFrame } from './frame' + +const NUM_CELLS = 33 +const MARGIN = 4 +const styles: FrameStyle[] = ['banner-bottom', 'banner-top', 'ticket', 'bubble', 'border'] + +describe('getFrameLayout', () => { + it.each(styles)('fits the code and the label inside the %s frame', (style) => { + const layout = getFrameLayout(style, NUM_CELLS, MARGIN) + + expect(layout.width).toBeCloseTo(NUM_CELLS + layout.padding * 2) + expect(layout.qrX + NUM_CELLS).toBeLessThanOrEqual(layout.width) + expect(layout.qrY + NUM_CELLS).toBeLessThanOrEqual(layout.height) + expect(layout.labelY + layout.labelHeight / 2).toBeLessThanOrEqual( + layout.height + 1e-9, + ) + }) + + it('puts the label above the code for banner-top', () => { + const layout = getFrameLayout('banner-top', NUM_CELLS, MARGIN) + + expect(layout.labelY).toBeLessThan(layout.qrY) + }) + + it.each(['banner-bottom', 'ticket', 'bubble', 'border'] as FrameStyle[])( + 'puts the label below the code for %s', + (style) => { + const layout = getFrameLayout(style, NUM_CELLS, MARGIN) + + expect(layout.labelY - layout.labelHeight / 2).toBeGreaterThanOrEqual( + layout.qrY + NUM_CELLS, + ) + }, + ) + + it('places the ticket divider between the code and the label', () => { + const layout = getFrameLayout('ticket', NUM_CELLS, MARGIN) + + expect(layout.dividerY).toBeGreaterThan(layout.qrY + NUM_CELLS) + expect(layout.dividerY).toBeLessThan(layout.labelY) + }) + + it('places the code inside the panel', () => { + const layout = getFrameLayout('ticket', NUM_CELLS, MARGIN) + + expect(layout.panelSize).toBe(NUM_CELLS) + expect(layout.qrX).toBe(layout.panelX) + expect(layout.qrY).toBe(layout.panelY) + }) + + it.each([0, 1])('adds a quiet zone around codes with a margin of %s', (margin) => { + const layout = getFrameLayout('ticket', NUM_CELLS, margin) + const quietZone = FRAME_MIN_QUIET_ZONE - margin + + expect(layout.panelSize).toBe(NUM_CELLS + quietZone * 2) + expect(layout.qrX - layout.panelX).toBeCloseTo(quietZone) + expect(layout.qrY - layout.panelY).toBeCloseTo(quietZone) + }) + + it.each(styles)('collapses the %s frame without a label', (style) => { + const layout = getFrameLayout(style, NUM_CELLS, MARGIN, false) + + expect(layout.labelHeight).toBe(0) + expect(layout.height).toBeCloseTo(layout.width) + expect(layout.qrY).toBeCloseTo(layout.padding) + }) + + it('scales with the number of cells', () => { + const small = getFrameLayout('ticket', 21, MARGIN) + const large = getFrameLayout('ticket', 42, MARGIN) + + expect(large.width / small.width).toBeCloseTo(2) + expect(large.height / small.height).toBeCloseTo(2) + }) +}) + +describe('isFilledFrame', () => { + it.each([ + ['banner-bottom', true], + ['banner-top', true], + ['ticket', true], + ['bubble', false], + ['border', false], + ] as [FrameStyle, boolean][])('%s -> %s', (style, expected) => { + expect(isFilledFrame(style)).toBe(expected) + }) +}) diff --git a/packages/react-qr-code/src/utils/frame.ts b/packages/react-qr-code/src/utils/frame.ts new file mode 100644 index 00000000..be131fa1 --- /dev/null +++ b/packages/react-qr-code/src/utils/frame.ts @@ -0,0 +1,116 @@ +import { FRAME_MIN_QUIET_ZONE } from '../constants' +import type { FrameStyle } from '../types/lib' + +export interface FrameLayout { + width: number + height: number + // Position of the QR code, inside the panel. + qrX: number + qrY: number + // Light panel behind the QR code that guarantees a quiet zone against the frame. + panelX: number + panelY: number + panelSize: number + padding: number + radius: number + labelY: number + labelHeight: number + // Y position of the ticket divider. + dividerY: number + // Stroke width for outlined styles. + strokeWidth: number +} + +const FILLED_STYLES: FrameStyle[] = ['banner-bottom', 'banner-top', 'ticket'] + +export const isFilledFrame = (style: FrameStyle) => FILLED_STYLES.includes(style) + +const getPanelLayout = ( + style: FrameStyle, + panelSize: number, + hasLabel: boolean, +): Omit => { + const padding = panelSize * 0.07 + const radius = panelSize * 0.08 + const labelHeight = panelSize * 0.24 + const strokeWidth = padding * 0.45 + const width = panelSize + padding * 2 + + const base = { + width, + panelX: padding, + panelY: padding, + panelSize, + padding, + radius, + labelHeight, + dividerY: 0, + strokeWidth, + } + + // Without a label every style collapses to an even frame around the code. + if (!hasLabel) { + return { ...base, labelHeight: 0, height: width, labelY: width } + } + + switch (style) { + case 'banner-top': + return { + ...base, + panelY: labelHeight, + height: labelHeight + panelSize + padding, + labelY: labelHeight / 2, + } + case 'ticket': { + const dividerY = panelSize + padding * 1.6 + return { + ...base, + height: dividerY + labelHeight, + labelY: dividerY + labelHeight / 2, + dividerY, + } + } + case 'bubble': { + const gap = padding * 1.4 + const bubbleY = width + gap + return { + ...base, + height: bubbleY + labelHeight, + labelY: bubbleY + labelHeight / 2, + } + } + case 'border': + return { + ...base, + height: width + labelHeight, + labelY: width + labelHeight / 2, + } + case 'banner-bottom': + default: + return { + ...base, + height: panelSize + padding + labelHeight, + labelY: panelSize + padding + labelHeight / 2, + } + } +} + +/** + * Computes the frame geometry, in module units, around a QR code of `numCells` + * (margin included). + */ +export const getFrameLayout = ( + style: FrameStyle, + numCells: number, + margin: number, + hasLabel = true, +): FrameLayout => { + const layout = getPanelLayout( + style, + numCells + Math.max(0, FRAME_MIN_QUIET_ZONE - margin) * 2, + hasLabel, + ) + const quietZone = (layout.panelSize - numCells) / 2 + + return { ...layout, qrX: layout.panelX + quietZone, qrY: layout.panelY + quietZone } +}