-
+
+
+
)
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