diff --git a/.changeset/feat-data-modules-line-width.md b/.changeset/feat-data-modules-line-width.md new file mode 100644 index 00000000..1c114a9e --- /dev/null +++ b/.changeset/feat-data-modules-line-width.md @@ -0,0 +1,5 @@ +--- +'@lglab/react-qr-code': minor +--- + +Add `lineWidth` option to `dataModulesSettings`. Controls the stroke width (in module units) for connected-shape styles: `vertical-line`, `horizontal-line`, `rounded`, and `circuit-board`. Defaults preserve existing rendering (`1` for line/rounded, `0.5` for `circuit-board`). The `rounded` style fillets exposed outer hub corners so the rounded character is preserved at any `lineWidth`. diff --git a/.gitignore b/.gitignore index 13e60b59..1f79a6b7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ dist-ssr **/.next/ **/out/ +# TypeScript incremental build cache +*.tsbuildinfo + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/apps/docs/src/app/data-modules-settings/page.tsx b/apps/docs/src/app/data-modules-settings/page.tsx index cee198e3..5e0142c0 100644 --- a/apps/docs/src/app/data-modules-settings/page.tsx +++ b/apps/docs/src/app/data-modules-settings/page.tsx @@ -68,6 +68,19 @@ const props: Prop[] = [ ), defaultValue: '1', }, + { + name: 'lineWidth', + type: 'number', + description: ( + <> + Width of the stroke for connected-shape styles, in module units. Only applies to{' '} + vertical-line, horizontal-line, rounded, + and circuit-board. Keep between 0.25 and 1; + values outside this range may degrade scannability or overflow neighbouring cells. + + ), + defaultValue: '1 (0.5 for circuit-board)', + }, ] export default function Page() { diff --git a/apps/docs/src/components/demo/data-modules.tsx b/apps/docs/src/components/demo/data-modules.tsx index 5281879d..5836df7c 100644 --- a/apps/docs/src/components/demo/data-modules.tsx +++ b/apps/docs/src/components/demo/data-modules.tsx @@ -61,6 +61,16 @@ export const DataModules = ({ qrProps, setQrProps }: DataModulesProps) => { const size = qrProps.dataModulesSettings?.size ?? 1 const randomSize = qrProps.dataModulesSettings?.randomSize ?? false + const currentStyle = qrProps.dataModulesSettings?.style + const isCircuitBoard = currentStyle === 'circuit-board' + const canSetLineWidth = + currentStyle === 'vertical-line' || + currentStyle === 'horizontal-line' || + currentStyle === 'rounded' || + isCircuitBoard + const defaultLineWidth = isCircuitBoard ? 0.5 : 1 + const lineWidth = qrProps.dataModulesSettings?.lineWidth ?? defaultLineWidth + return ( <> @@ -79,6 +89,25 @@ export const DataModules = ({ qrProps, setQrProps }: DataModulesProps) => { ))} + {canSetLineWidth && ( + + + setQrProps((prevProps) => ({ + ...prevProps, + dataModulesSettings: { + ...prevProps.dataModulesSettings, + lineWidth: value, + }, + })) + } + min={0.25} + max={1} + step={0.01} + /> + + )} {canBeRandomSize && ( <> ( ) export const Demo = () => { + const qrRef = useRef(null) const [qrProps, setQrProps] = useState({ value: 'https://reactqrcode.com', size: 400, @@ -116,8 +122,15 @@ export const Demo = () => { -
- +
+ +
) diff --git a/packages/react-qr-code/src/components/data-modules.tsx b/packages/react-qr-code/src/components/data-modules.tsx index 56d9c23b..cbddb0c5 100644 --- a/packages/react-qr-code/src/components/data-modules.tsx +++ b/packages/react-qr-code/src/components/data-modules.tsx @@ -1,10 +1,6 @@ import { type ReactNode, useCallback, useMemo } from 'react' -import { - CIRCUIT_BOARD_LINE_WIDTH, - CIRCUIT_BOARD_PAD_RADIUS, - DEFAULT_NUM_STAR_POINTS, -} from '../constants' +import { CIRCUIT_BOARD_PAD_RADIUS, DEFAULT_NUM_STAR_POINTS } from '../constants' import type { DataModulesProps } from '../types/utils' import { bottomRounded, @@ -19,6 +15,7 @@ import { leftRounded, rect, rightRounded, + roundedDataModule, square, topRounded, } from '../utils/data-modules' @@ -41,7 +38,7 @@ export const DataModules = ({ gradient, gradientId, }: DataModulesProps): ReactNode => { - const { color, style, randomSize, size } = useMemo( + const { color, style, randomSize, size, lineWidth } = useMemo( () => sanitizeDataModulesSettings(settings), [settings], ) @@ -68,41 +65,34 @@ export const DataModules = ({ const scale = scaleFactor() const size = 1 * scale const posOffset = (1 - 1 * scale) / 2 - const xPos = x + margin + posOffset - const yPos = y + margin + posOffset + const baseX = x + margin + const baseY = y + margin + const xPos = baseX + posOffset + const yPos = baseY + posOffset + const lwOffset = (1 - lineWidth) / 2 if (cell) { if (style === 'circuit-board') { - const cx = x + margin + 0.5 - const cy = y + margin + 0.5 - const traceHalf = CIRCUIT_BOARD_LINE_WIDTH / 2 + const cx = baseX + 0.5 + const cy = baseY + 0.5 + const traceHalf = lineWidth / 2 // Traces extend traceHalf past both endpoints so that adjacent // traces fully cover the cell-center square at every junction // (preventing white notches at L/T/+ bends under nonzero fill). - const traceLength = 1 + CIRCUIT_BOARD_LINE_WIDTH + const traceLength = 1 + lineWidth const neighbours = getRenderableDataModuleNeighbours(x, y, modules, numCells) const { right, bottom, count } = neighbours if (right) { - ops.push( - rect(cx - traceHalf, cy - traceHalf, traceLength, CIRCUIT_BOARD_LINE_WIDTH), - ) + ops.push(rect(cx - traceHalf, cy - traceHalf, traceLength, lineWidth)) } if (bottom) { - ops.push( - rect(cx - traceHalf, cy - traceHalf, CIRCUIT_BOARD_LINE_WIDTH, traceLength), - ) + ops.push(rect(cx - traceHalf, cy - traceHalf, lineWidth, traceLength)) } if (count === 0) { const isolatedSize = 0.75 const isolatedOffset = (1 - isolatedSize) / 2 - ops.push( - square( - x + margin + isolatedOffset, - y + margin + isolatedOffset, - isolatedSize, - ), - ) + ops.push(square(baseX + isolatedOffset, baseY + isolatedOffset, isolatedSize)) } else if (circuitBoardShouldDrawPad({ ...neighbours, count })) { ops.push(circuitBoardPad(cx, cy, CIRCUIT_BOARD_PAD_RADIUS)) } @@ -123,32 +113,37 @@ export const DataModules = ({ } else if (style === 'hashtag') { ops.push(hashtag(xPos, yPos, size)) } else if (style === 'rounded') { - const { left, right, top, bottom, count } = getModuleNeighbours(x, y, modules) + const neighbours = getModuleNeighbours(x, y, modules) + const { left, right, top, bottom, count } = neighbours - if (count === 0) { - ops.push(circle(xPos, yPos, 1)) - } else if (count > 2 || (left && right) || (top && bottom)) { - ops.push(square(xPos, yPos, 1)) - } else if (count === 2) { - if (left && top) { - ops.push(bottomRightRounded(xPos, yPos)) - } else if (top && right) { - ops.push(bottomLeftRounded(xPos, yPos)) - } else if (right && bottom) { - ops.push(topLeftRounded(xPos, yPos)) + if (lineWidth === 1) { + if (count === 0) { + ops.push(circle(xPos, yPos, 1)) + } else if (count > 2 || (left && right) || (top && bottom)) { + ops.push(square(xPos, yPos, 1)) + } else if (count === 2) { + if (left && top) { + ops.push(bottomRightRounded(xPos, yPos)) + } else if (top && right) { + ops.push(bottomLeftRounded(xPos, yPos)) + } else if (right && bottom) { + ops.push(topLeftRounded(xPos, yPos)) + } else { + ops.push(topRightRounded(xPos, yPos)) + } } else { - ops.push(topRightRounded(xPos, yPos)) + if (top) { + ops.push(bottomRounded(xPos, yPos)) + } else if (right) { + ops.push(leftRounded(xPos, yPos)) + } else if (bottom) { + ops.push(topRounded(xPos, yPos)) + } else { + ops.push(rightRounded(xPos, yPos)) + } } } else { - if (top) { - ops.push(bottomRounded(xPos, yPos)) - } else if (right) { - ops.push(leftRounded(xPos, yPos)) - } else if (bottom) { - ops.push(topRounded(xPos, yPos)) - } else { - ops.push(rightRounded(xPos, yPos)) - } + ops.push(roundedDataModule(baseX, baseY, lineWidth, neighbours)) } } else if (style === 'leaf') { const { left, right, top, bottom, count } = getModuleNeighbours(x, y, modules) @@ -167,25 +162,25 @@ export const DataModules = ({ const { left, right, top, bottom, count } = getModuleNeighbours(x, y, modules) if (count === 0 || (left && !(top || bottom)) || (right && !(top || bottom))) { - ops.push(circle(xPos, yPos, 1)) + ops.push(circle(baseX + lwOffset, baseY + lwOffset, lineWidth)) } else if (top && bottom) { - ops.push(square(xPos, yPos, 1)) + ops.push(rect(baseX + lwOffset, baseY, lineWidth, 1)) } else if (top && !bottom) { - ops.push(bottomRounded(xPos, yPos)) + ops.push(bottomRounded(baseX, baseY, lineWidth)) } else if (bottom && !top) { - ops.push(topRounded(xPos, yPos)) + ops.push(topRounded(baseX, baseY, lineWidth)) } } else if (style === 'horizontal-line') { const { left, right, top, bottom, count } = getModuleNeighbours(x, y, modules) if (count === 0 || (top && !(left || right)) || (bottom && !(left || right))) { - ops.push(circle(xPos, yPos, 1)) + ops.push(circle(baseX + lwOffset, baseY + lwOffset, lineWidth)) } else if (left && right) { - ops.push(square(xPos, yPos, 1)) + ops.push(rect(baseX, baseY + lwOffset, 1, lineWidth)) } else if (left && !right) { - ops.push(rightRounded(xPos, yPos)) + ops.push(rightRounded(baseX, baseY, lineWidth)) } else if (right && !left) { - ops.push(leftRounded(xPos, yPos)) + ops.push(leftRounded(baseX, baseY, lineWidth)) } } } diff --git a/packages/react-qr-code/src/test/data-modules-neightbours.ts b/packages/react-qr-code/src/test/data-modules-neightbours.ts index c240446a..7f064d9f 100644 --- a/packages/react-qr-code/src/test/data-modules-neightbours.ts +++ b/packages/react-qr-code/src/test/data-modules-neightbours.ts @@ -243,7 +243,7 @@ export const dataModulesVerticalLineNeighbours = [ bottom: true, count: 1, }, - 'square', + 'rect', ], [ { @@ -253,7 +253,7 @@ export const dataModulesVerticalLineNeighbours = [ bottom: true, count: 2, }, - 'square', + 'rect', ], [ { @@ -263,7 +263,7 @@ export const dataModulesVerticalLineNeighbours = [ bottom: true, count: 3, }, - 'square', + 'rect', ], [ { @@ -273,7 +273,7 @@ export const dataModulesVerticalLineNeighbours = [ bottom: true, count: 4, }, - 'square', + 'rect', ], [ { @@ -396,7 +396,7 @@ export const dataModulesHorizontalLineNeighbours = [ bottom: false, count: 1, }, - 'square', + 'rect', ], [ { diff --git a/packages/react-qr-code/src/types/lib.ts b/packages/react-qr-code/src/types/lib.ts index d2291290..af70a56a 100644 --- a/packages/react-qr-code/src/types/lib.ts +++ b/packages/react-qr-code/src/types/lib.ts @@ -55,6 +55,15 @@ export interface DataModulesSettings { * @defaultValue 1 */ size?: number + /** + * Width of the stroke for connected-shape styles, in module units. Only + * applies to `vertical-line`, `horizontal-line`, `rounded`, and + * `circuit-board`. Keep between 0.25 and 1 — lower values may degrade + * scannability, and values above 1 cause end caps to overflow neighbouring + * cells. Not clamped. + * @defaultValue 1 for `vertical-line` / `horizontal-line` / `rounded`, 0.5 for `circuit-board` + */ + lineWidth?: number } export type FinderPatternOuterStyle = diff --git a/packages/react-qr-code/src/utils/data-modules.test.ts b/packages/react-qr-code/src/utils/data-modules.test.ts index 72d9cef5..bd4d637c 100644 --- a/packages/react-qr-code/src/utils/data-modules.test.ts +++ b/packages/react-qr-code/src/utils/data-modules.test.ts @@ -2,12 +2,17 @@ import { describe, expect, it, vi } from 'vitest' import type { Modules } from '../types/lib' import { + bottomRounded, circuitBoardShouldDrawPad, dataModuleCanBeRandomSize, getModuleNeighbours, getRenderableDataModuleNeighbours, getScaleFactor, isRenderableDataModule, + leftRounded, + rightRounded, + roundedDataModule, + topRounded, } from './data-modules' describe('getScaleFactor', () => { @@ -256,6 +261,67 @@ describe('isRenderableDataModule', () => { }) }) +describe('rounded line-cap helpers', () => { + const normalize = (s: string) => s.replace(/\s+/g, ' ').trim() + + it('emit the legacy full-width path when width defaults to 1', () => { + expect(normalize(topRounded(0, 0))).toBe( + normalize(`M 0 1 h 1 v -0.5 a 0.5 0.5, 0, 0, 0, -1 0`), + ) + expect(normalize(bottomRounded(0, 0))).toBe( + normalize(`M 0 0 h 1 v 0.5 a 0.5 0.5, 0, 0, 1, -1 0`), + ) + expect(normalize(leftRounded(0, 0))).toBe( + normalize(`M 1 0 v 1 h -0.5 a 0.5 0.5, 0, 0, 1, 0 -1`), + ) + expect(normalize(rightRounded(0, 0))).toBe( + normalize(`M 0 0 v 1 h 0.5 a 0.5 0.5, 0, 0, 0, 0 -1`), + ) + }) + + it('centers the cap on the cell axis when width < 1', () => { + // width = 0.5, vertical-line top cap: cap is 0.5 wide, centered at x+0.5, + // straight body 0.75 tall, semicircle radius 0.25 on top. + expect(normalize(topRounded(0, 0, 0.5))).toBe( + normalize(`M 0.25 1 h 0.5 v -0.75 a 0.25 0.25, 0, 0, 0, -0.5 0`), + ) + expect(normalize(leftRounded(0, 0, 0.5))).toBe( + normalize(`M 1 0.25 v 0.5 h -0.75 a 0.25 0.25, 0, 0, 1, 0 -0.5`), + ) + }) +}) + +describe('roundedDataModule', () => { + const n = { left: false, right: false, top: false, bottom: false } + + it('fillets every hub corner when isolated (count=0)', () => { + const d = roundedDataModule(0, 0, 0.75, n) + // Four quarter-circle arcs of radius 0.375 → full circle + const arcs = d.match(/A 0.375 0.375 0 0 1/g) ?? [] + expect(arcs.length).toBe(4) + }) + + it('fillets only the outer (bottom-right) corner for a left+top L-bend', () => { + const d = roundedDataModule(0, 0, 0.5, { ...n, left: true, top: true }) + const arcs = d.match(/A 0.25 0.25 0 0 1/g) ?? [] + expect(arcs.length).toBe(1) + // Outer fillet sits at the hub's BR corner (cx+r, cy+r) = (0.75, 0.75) + expect(d).toContain('A 0.25 0.25 0 0 1 0.5 0.75') + }) + + it('emits no fillets for a straight-through (left+right) cell', () => { + const d = roundedDataModule(0, 0, 0.5, { ...n, left: true, right: true }) + expect(d.includes('A ')).toBe(false) + }) + + it('fillets the two corners on the open side for a 1-neighbour cell', () => { + // Only top neighbour → BR and BL exposed + const d = roundedDataModule(0, 0, 0.5, { ...n, top: true }) + const arcs = d.match(/A 0.25 0.25 0 0 1/g) ?? [] + expect(arcs.length).toBe(2) + }) +}) + describe('getRenderableDataModuleNeighbours', () => { it('ignores neighbouring finder pattern modules', () => { const modules: Modules = Array.from({ length: 21 }, () => Array(21).fill(false)) diff --git a/packages/react-qr-code/src/utils/data-modules.ts b/packages/react-qr-code/src/utils/data-modules.ts index c3fb1877..45024afa 100644 --- a/packages/react-qr-code/src/utils/data-modules.ts +++ b/packages/react-qr-code/src/utils/data-modules.ts @@ -132,29 +132,123 @@ export const bottomLeftRounded = (x: number, y: number) => v -0.5 h 1` -export const rightRounded = (x: number, y: number) => - `M ${x} ${y} - v 1 - h 0.5 - a 0.5 0.5, 0, 0, 0, 0 -1` +export const rightRounded = (x: number, y: number, w = 1) => { + const cy = y + 0.5 + const r = w / 2 + const straight = 1 - r + return `M ${x} ${cy - r} + v ${w} + h ${straight} + a ${r} ${r}, 0, 0, 0, 0 -${w}` +} -export const leftRounded = (x: number, y: number) => - `M ${x + 1} ${y} - v 1 - h -0.5 - a 0.5 0.5, 0, 0, 1, 0 -1` +export const leftRounded = (x: number, y: number, w = 1) => { + const cy = y + 0.5 + const r = w / 2 + const straight = 1 - r + return `M ${x + 1} ${cy - r} + v ${w} + h -${straight} + a ${r} ${r}, 0, 0, 1, 0 -${w}` +} -export const topRounded = (x: number, y: number) => - `M ${x} ${y + 1} - h 1 - v -0.5 - a 0.5 0.5, 0, 0, 0, -1 0` +export const topRounded = (x: number, y: number, w = 1) => { + const cx = x + 0.5 + const r = w / 2 + const straight = 1 - r + return `M ${cx - r} ${y + 1} + h ${w} + v -${straight} + a ${r} ${r}, 0, 0, 0, -${w} 0` +} -export const bottomRounded = (x: number, y: number) => - `M ${x} ${y} - h 1 - v 0.5 - a 0.5 0.5, 0, 0, 1, -1 0` +export const bottomRounded = (x: number, y: number, w = 1) => { + const cx = x + 0.5 + const r = w / 2 + const straight = 1 - r + return `M ${cx - r} ${y} + h ${w} + v ${straight} + a ${r} ${r}, 0, 0, 1, -${w} 0` +} + +// Renders a `rounded`-style cell as the union of a central hub plus arms +// reaching toward each present neighbour. Hub corners whose two adjacent +// sides are both empty are filleted with a quarter-circle of radius lw/2, +// preserving the rounded aesthetic at any lineWidth. +export const roundedDataModule = ( + x: number, + y: number, + lw: number, + neighbours: Omit, +) => { + const { left, right, top, bottom } = neighbours + const cx = x + 0.5 + const cy = y + 0.5 + const r = lw / 2 + + const TLexp = !top && !left + const TRexp = !top && !right + const BRexp = !bottom && !right + const BLexp = !bottom && !left + + const topY = top ? y : cy - r + const rightX = right ? x + 1 : cx + r + const bottomY = bottom ? y + 1 : cy + r + const leftX = left ? x : cx - r + + const segments: string[] = [`M ${TLexp ? cx : cx - r} ${topY}`] + // Top edge + segments.push(`L ${TRexp ? cx : cx + r} ${topY}`) + // TR transition + if (TRexp) { + segments.push(`A ${r} ${r} 0 0 1 ${cx + r} ${cy}`) + } else if (top && right) { + segments.push(`L ${cx + r} ${cy - r} L ${x + 1} ${cy - r}`) + } else if (top) { + segments.push(`L ${cx + r} ${cy - r}`) + } else if (right) { + segments.push(`L ${x + 1} ${cy - r}`) + } + // Right edge + segments.push(`L ${rightX} ${BRexp ? cy : cy + r}`) + // BR transition + if (BRexp) { + segments.push(`A ${r} ${r} 0 0 1 ${cx} ${cy + r}`) + } else if (right && bottom) { + segments.push(`L ${cx + r} ${cy + r} L ${cx + r} ${y + 1}`) + } else if (right) { + segments.push(`L ${cx + r} ${cy + r}`) + } else if (bottom) { + segments.push(`L ${cx + r} ${y + 1}`) + } + // Bottom edge + segments.push(`L ${BLexp ? cx : cx - r} ${bottomY}`) + // BL transition + if (BLexp) { + segments.push(`A ${r} ${r} 0 0 1 ${cx - r} ${cy}`) + } else if (bottom && left) { + segments.push(`L ${cx - r} ${cy + r} L ${x} ${cy + r}`) + } else if (bottom) { + segments.push(`L ${cx - r} ${cy + r}`) + } else if (left) { + segments.push(`L ${x} ${cy + r}`) + } + // Left edge + segments.push(`L ${leftX} ${TLexp ? cy : cy - r}`) + // TL transition + if (TLexp) { + segments.push(`A ${r} ${r} 0 0 1 ${cx} ${cy - r}`) + } else if (left && top) { + segments.push(`L ${cx - r} ${cy - r} L ${cx - r} ${y}`) + } else if (left) { + segments.push(`L ${cx - r} ${cy - r}`) + } else if (top) { + segments.push(`L ${cx - r} ${y}`) + } + segments.push('Z') + return segments.join(' ') +} export const leaf = (x: number, y: number, size: number) => { return ( diff --git a/packages/react-qr-code/src/utils/settings.test.ts b/packages/react-qr-code/src/utils/settings.test.ts new file mode 100644 index 00000000..3f71a0a0 --- /dev/null +++ b/packages/react-qr-code/src/utils/settings.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' + +import { sanitizeDataModulesSettings } from './settings' + +describe('sanitizeDataModulesSettings lineWidth', () => { + it('defaults to 1 for line and rounded styles', () => { + expect(sanitizeDataModulesSettings({ style: 'vertical-line' }).lineWidth).toBe(1) + expect(sanitizeDataModulesSettings({ style: 'horizontal-line' }).lineWidth).toBe(1) + expect(sanitizeDataModulesSettings({ style: 'rounded' }).lineWidth).toBe(1) + }) + + it('defaults to 0.5 for circuit-board', () => { + expect(sanitizeDataModulesSettings({ style: 'circuit-board' }).lineWidth).toBe(0.5) + }) + + it('passes through an explicitly provided lineWidth', () => { + expect( + sanitizeDataModulesSettings({ style: 'vertical-line', lineWidth: 0.8 }).lineWidth, + ).toBe(0.8) + expect( + sanitizeDataModulesSettings({ style: 'circuit-board', lineWidth: 0.3 }).lineWidth, + ).toBe(0.3) + }) +}) diff --git a/packages/react-qr-code/src/utils/settings.ts b/packages/react-qr-code/src/utils/settings.ts index 84753a03..24623c15 100644 --- a/packages/react-qr-code/src/utils/settings.ts +++ b/packages/react-qr-code/src/utils/settings.ts @@ -1,4 +1,5 @@ import { + CIRCUIT_BOARD_LINE_WIDTH, DEFAULT_DATA_MODULES_COLOR, DEFAULT_DATA_MODULES_STYLE, DEFAULT_FINDER_PATTERN_INNER_STYLE, @@ -11,11 +12,14 @@ import type { } from '../types/lib' export const sanitizeDataModulesSettings = (settings?: DataModulesSettings) => { + const style = settings?.style || DEFAULT_DATA_MODULES_STYLE + const defaultLineWidth = style === 'circuit-board' ? CIRCUIT_BOARD_LINE_WIDTH : 1 return { color: settings?.color || DEFAULT_DATA_MODULES_COLOR, - style: settings?.style || DEFAULT_DATA_MODULES_STYLE, + style, randomSize: settings?.randomSize || false, size: settings?.size ?? 1, + lineWidth: settings?.lineWidth ?? defaultLineWidth, } }