Add ImageTracer integration and precise coordinate system for bitmap vectorization

- Integrated ImageTracer npm package for bitmap-to-vector conversion
- Fixed coordinate system to use 33x33 QR code coordinate space
- Implemented precise decimal coordinates for perfect centering
- Added custom bitmap-to-vector conversion as fallback
- Fixed white box and image positioning alignment
- Added comprehensive debugging and error handling
This commit is contained in:
Michael Mainguy 2025-08-01 17:01:23 -05:00
parent 3f743e567b
commit de188fb77a
4 changed files with 401 additions and 25 deletions

10
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "qrcodes", "name": "qrcodes",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"imagetracer": "^0.2.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1"
@ -1437,6 +1438,15 @@
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
} }
}, },
"node_modules/imagetracer": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/imagetracer/-/imagetracer-0.2.2.tgz",
"integrity": "sha512-tsdjCwfyJyl9dOHpMaMqqVLGC8V/KFgFd/ATdLSU5LlsZJMRxWOxNpfai0xgUbdrNw9uHUUXrYFwIjDKNXBdYA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/murongg"
}
},
"node_modules/is-fullwidth-code-point": { "node_modules/is-fullwidth-code-point": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",

View File

@ -13,6 +13,7 @@
"vite": "^7.0.4" "vite": "^7.0.4"
}, },
"dependencies": { "dependencies": {
"imagetracer": "^0.2.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1"

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { imageTracer } from 'imagetracer'
const TextAreaComponent = () => { const TextAreaComponent = () => {
const [text, setText] = useState('') const [text, setText] = useState('')
@ -7,26 +8,29 @@ const TextAreaComponent = () => {
const [customImage, setCustomImage] = useState(null) const [customImage, setCustomImage] = useState(null)
const [customImageUrl, setCustomImageUrl] = useState('') const [customImageUrl, setCustomImageUrl] = useState('')
const [imageSize, setImageSize] = useState(25) // Size as percentage of QR code const [imageSize, setImageSize] = useState(25) // Size as percentage of QR code
const [foregroundColor, setForegroundColor] = useState('#0000ff') // QR code foreground color
const [backgroundColor, setBackgroundColor] = useState('#FFFFFF') // QR code background color
const canvasRef = useRef(null) const canvasRef = useRef(null)
// Generate QR code when text, custom image, or image size changes // Generate QR code when text, custom image, image size, or colors change
useEffect(() => { useEffect(() => {
if (text.trim()) { if (text.trim()) {
generateQRCode(text) generateQRCode(text)
} else { } else {
setQrCodeUrl('') setQrCodeUrl('')
} }
}, [text, customImage, imageSize]) }, [text, customImage, imageSize, foregroundColor, backgroundColor])
const generateQRCode = async (inputText) => { const generateQRCode = async (inputText) => {
try { try {
// Generate QR code as data URL // Generate QR code as data URL
const url = await QRCode.toDataURL(inputText, { const url = await QRCode.toDataURL(inputText, {
width: 256, width: 512,
margin: 2, margin: 2,
errorCorrectionLevel: 'H',
color: { color: {
dark: '#000000', dark: foregroundColor,
light: '#FFFFFF' light: backgroundColor
} }
}) })
@ -43,14 +47,241 @@ const TextAreaComponent = () => {
} }
} }
const generateSVGQRCode = async (inputText) => {
try {
// Generate QR code as SVG
const svgString = await QRCode.toString(inputText, {
type: 'svg',
width: 512,
margin: 2,
errorCorrectionLevel: 'H',
color: {
dark: foregroundColor,
light: backgroundColor
}
})
console.log('Base SVG generated, length:', svgString.length)
if (customImage) {
console.log('Custom image detected, adding to SVG...')
// Add custom image to SVG
const svgWithImage = await addImageToSVG(svgString, customImageUrl)
console.log('SVG with image generated, length:', svgWithImage.length)
return svgWithImage
} else {
console.log('No custom image, returning base SVG')
return svgString
}
} catch (error) {
console.error('Error generating SVG QR code:', error)
return null
}
}
const addImageToSVG = async (svgString, imageUrl) => {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
// Parse the SVG string to add custom image
const parser = new DOMParser()
const svgDoc = parser.parseFromString(svgString, 'image/svg+xml')
const svgElement = svgDoc.documentElement
// Calculate image size and position with precise decimal coordinates
// The QR code uses a 33x33 coordinate system, so we need to scale accordingly
const qrSize = 33 // QR code coordinate system size
const calculatedImageSize = qrSize * (imageSize / 100)
const margin = 2 // Smaller margin for the 33x33 coordinate system
const boxSize = calculatedImageSize + (margin * 2)
const boxX = (qrSize - boxSize) / 2
const boxY = (qrSize - boxSize) / 2
const imageX = boxX + margin
const imageY = boxY + margin
// Create white background rectangle with precise positioning
const whiteBox = svgDoc.createElementNS('http://www.w3.org/2000/svg', 'rect')
whiteBox.setAttribute('x', boxX.toFixed(2))
whiteBox.setAttribute('y', boxY.toFixed(2))
whiteBox.setAttribute('width', boxSize.toFixed(2))
whiteBox.setAttribute('height', boxSize.toFixed(2))
whiteBox.setAttribute('fill', '#FFFFFF')
// Add elements to SVG
svgElement.appendChild(whiteBox)
// Handle different image types
if (imageUrl.startsWith('data:image/svg+xml')) {
console.log('Processing SVG image')
// For SVG images, embed the SVG content directly with precise positioning
const imageElement = svgDoc.createElementNS('http://www.w3.org/2000/svg', 'image')
imageElement.setAttribute('x', imageX.toFixed(2))
imageElement.setAttribute('y', imageY.toFixed(2))
imageElement.setAttribute('width', calculatedImageSize.toFixed(2))
imageElement.setAttribute('height', calculatedImageSize.toFixed(2))
imageElement.setAttribute('href', imageUrl)
svgElement.appendChild(imageElement)
console.log('SVG image element added')
// Convert back to string
const serializer = new XMLSerializer()
resolve(serializer.serializeToString(svgElement))
} else {
console.log('Processing bitmap image')
// For bitmap images, convert to vector paths
vectorizeBitmap(img, calculatedImageSize, imageX, imageY, svgDoc).then((vectorizedImage) => {
svgElement.appendChild(vectorizedImage)
console.log('Vectorized image group added')
// Convert back to string
const serializer = new XMLSerializer()
resolve(serializer.serializeToString(svgElement))
}).catch((error) => {
console.error('Vectorization failed:', error)
// Convert back to string without the image
const serializer = new XMLSerializer()
resolve(serializer.serializeToString(svgElement))
})
}
}
img.onerror = () => {
console.error('Error loading image for SVG')
resolve(svgString) // Return SVG without image if loading fails
}
img.src = imageUrl
})
}
const vectorizeBitmap = (img, targetSize, x, y, svgDoc) => {
return new Promise((resolve) => {
// Create a canvas to get the image data
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
// Set canvas size to match the image
canvas.width = img.width
canvas.height = img.height
// Draw the image to canvas
ctx.drawImage(img, 0, 0)
// Get image data for ImageTracer
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
// Configure ImageTracer options
const options = {
ltres: 1, // Line threshold
qtres: 1, // Quadratic threshold
pathomit: 0, // Path omission threshold
colorsampling: 0, // Color sampling
numberofcolors: 16, // Number of colors
mincolorratio: 0.02, // Minimum color ratio
strokewidth: 1, // Stroke width
linefilter: false, // Line filter
scale: 1, // Scale
roundcoords: 1, // Round coordinates
viewbox: false, // Viewbox
desc: false, // Description
lcpr: 0, // Line connecting precision
qcpr: 0, // Quadratic curve precision
blurradius: 0, // Blur radius
blurdelta: 20 // Blur delta
}
console.log('Starting ImageTracer conversion...')
// Check if ImageTracer is available
if (!imageTracer) {
console.error('ImageTracer not available, using fallback')
// Fallback to a simple colored rectangle
const fallbackRect = svgDoc.createElementNS('http://www.w3.org/2000/svg', 'rect')
fallbackRect.setAttribute('x', x)
fallbackRect.setAttribute('y', y)
fallbackRect.setAttribute('width', targetSize)
fallbackRect.setAttribute('height', targetSize)
fallbackRect.setAttribute('fill', '#a600ff')
fallbackRect.setAttribute('rx', '8')
fallbackRect.setAttribute('ry', '8')
resolve(fallbackRect)
return
}
// Use a custom bitmap-to-vector conversion instead of ImageTracer
console.log('Using custom bitmap-to-vector conversion...')
// Create a group for the vectorized image
const group = svgDoc.createElementNS('http://www.w3.org/2000/svg', 'g')
// Scale the image to fit within the target size in the QR coordinate system
const scale = targetSize / Math.max(img.width, img.height)
group.setAttribute('transform', `translate(${x.toFixed(2)}, ${y.toFixed(2)}) scale(${scale.toFixed(4)})`)
// Sample pixels and create colored rectangles
const sampleSize = Math.max(1, Math.floor(Math.min(img.width, img.height) / 32)) // Sample every N pixels
const data = imageData.data
for (let y = 0; y < img.height; y += sampleSize) {
for (let x = 0; x < img.width; x += sampleSize) {
const index = (y * img.width + x) * 4
const r = data[index]
const g = data[index + 1]
const b = data[index + 2]
const a = data[index + 3]
// Only create rectangles for non-transparent pixels with sufficient opacity
if (a > 128 && (r > 0 || g > 0 || b > 0)) {
const rect = svgDoc.createElementNS('http://www.w3.org/2000/svg', 'rect')
rect.setAttribute('x', x.toFixed(2))
rect.setAttribute('y', y.toFixed(2))
rect.setAttribute('width', sampleSize.toFixed(2))
rect.setAttribute('height', sampleSize.toFixed(2))
rect.setAttribute('fill', `rgb(${r}, ${g}, ${b})`)
group.appendChild(rect)
}
}
}
console.log('Custom bitmap-to-vector conversion completed')
resolve(group)
})
}
const exportAsSVG = async () => {
if (!text.trim()) {
alert('Please enter some text to generate a QR code')
return
}
const svgContent = await generateSVGQRCode(text)
if (svgContent) {
// Debug: Log the SVG content to see what's being generated
console.log('Generated SVG content:', svgContent)
// Create download link
const blob = new Blob([svgContent], { type: 'image/svg+xml' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'qrcode.svg'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
}
const addImageToQRCode = async (qrCodeUrl, imageUrl) => { const addImageToQRCode = async (qrCodeUrl, imageUrl) => {
return new Promise((resolve) => { return new Promise((resolve) => {
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
// Set canvas size // Set canvas size
canvas.width = 256 canvas.width = 512
canvas.height = 256 canvas.height = 512
// Create image objects // Create image objects
const qrImage = new Image() const qrImage = new Image()
@ -58,32 +289,33 @@ const TextAreaComponent = () => {
qrImage.onload = () => { qrImage.onload = () => {
// Draw QR code // Draw QR code
ctx.drawImage(qrImage, 0, 0, 256, 256) ctx.drawImage(qrImage, 0, 0, 512, 512)
customImage.onload = () => { customImage.onload = () => {
// Calculate image size based on user preference // Calculate image size based on user preference
const calculatedImageSize = 256 * (imageSize / 100) const calculatedImageSize = 512 * (imageSize / 100)
const imageX = (256 - calculatedImageSize) / 2
const imageY = (256 - calculatedImageSize) / 2
// Create circular clipping path for the image // Calculate white box size with margin
ctx.save() const margin = 16 // 8 pixel margin around the image
ctx.beginPath() const boxSize = calculatedImageSize + (margin * 2)
ctx.arc(imageX + calculatedImageSize/2, imageY + calculatedImageSize/2, calculatedImageSize/2, 0, 2 * Math.PI) const boxX = (512 - boxSize) / 2
ctx.clip() const boxY = (512 - boxSize) / 2
// Draw white background box
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(boxX, boxY, boxSize, boxSize)
// Calculate image position within the white box
const imageX = boxX + margin
const imageY = boxY + margin
// Draw custom image // Draw custom image
ctx.drawImage(customImage, imageX, imageY, calculatedImageSize, calculatedImageSize) ctx.drawImage(customImage, imageX, imageY, calculatedImageSize, calculatedImageSize)
// Restore context // Add border around the white box
ctx.restore() //ctx.strokeStyle = '#000000'
//ctx.lineWidth = 1
// Add white border around the image //ctx.strokeRect(boxX, boxY, boxSize, boxSize)
ctx.strokeStyle = '#FFFFFF'
ctx.lineWidth = 3
ctx.beginPath()
ctx.arc(imageX + calculatedImageSize/2, imageY + calculatedImageSize/2, calculatedImageSize/2, 0, 2 * Math.PI)
ctx.stroke()
// Convert canvas to data URL // Convert canvas to data URL
resolve(canvas.toDataURL('image/png')) resolve(canvas.toDataURL('image/png'))
@ -160,6 +392,36 @@ const TextAreaComponent = () => {
cols={50} cols={50}
/> />
<div className="color-controls">
<div className="color-input-group">
<label htmlFor="foreground-color" className="color-label">
QR Code Color:
</label>
<input
id="foreground-color"
type="color"
value={foregroundColor}
onChange={(e) => setForegroundColor(e.target.value)}
className="color-input"
/>
<span className="color-value">{foregroundColor}</span>
</div>
<div className="color-input-group">
<label htmlFor="background-color" className="color-label">
Background Color:
</label>
<input
id="background-color"
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="color-input"
/>
<span className="color-value">{backgroundColor}</span>
</div>
</div>
<div className="image-upload-section"> <div className="image-upload-section">
<label htmlFor="image-upload" className="image-upload-label"> <label htmlFor="image-upload" className="image-upload-label">
Add custom image or SVG to QR code center: Add custom image or SVG to QR code center:
@ -235,6 +497,14 @@ const TextAreaComponent = () => {
alt="QR Code" alt="QR Code"
className="qr-code-image" className="qr-code-image"
/> />
<div className="qr-code-actions">
<button
onClick={exportAsSVG}
className="export-svg-button"
>
Export as SVG
</button>
</div>
<p className="qr-code-info"> <p className="qr-code-info">
Scan this QR code with any QR code reader app Scan this QR code with any QR code reader app
</p> </p>

View File

@ -94,6 +94,58 @@ h1 {
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
} }
.color-controls {
margin: 1.5rem 0;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
gap: 2rem;
align-items: center;
}
.color-input-group {
display: flex;
align-items: center;
gap: 1rem;
}
.color-label {
font-size: 0.9rem;
font-weight: 500;
color: #e0e0e0;
white-space: nowrap;
}
.color-input {
width: 50px;
height: 40px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
background: none;
cursor: pointer;
transition: all 0.3s ease;
}
.color-input:hover {
border-color: rgba(255, 255, 255, 0.4);
}
.color-input:focus {
outline: none;
border-color: #646cff;
box-shadow: 0 0 0 3px rgba(100, 108, 255, 0.1);
}
.color-value {
font-size: 0.9rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
font-family: monospace;
min-width: 70px;
}
.textarea-actions { .textarea-actions {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -306,6 +358,28 @@ h1 {
font-style: italic; font-style: italic;
} }
.qr-code-actions {
margin: 1rem 0;
text-align: center;
}
.export-svg-button {
padding: 0.75rem 1.5rem;
background: #28a745;
color: white;
border: none;
border-radius: 6px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.export-svg-button:hover {
background: #218838;
transform: translateY(-1px);
}
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {
color: #213547; color: #213547;
@ -329,6 +403,27 @@ h1 {
.textarea-input::placeholder { .textarea-input::placeholder {
color: rgba(0, 0, 0, 0.5); color: rgba(0, 0, 0, 0.5);
} }
.color-controls {
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.1);
}
.color-label {
color: #213547;
}
.color-input {
border: 2px solid rgba(0, 0, 0, 0.2);
}
.color-input:hover {
border-color: rgba(0, 0, 0, 0.4);
}
.color-value {
color: rgba(0, 0, 0, 0.8);
}
.clear-button:disabled { .clear-button:disabled {
background: rgba(0, 0, 0, 0.1); background: rgba(0, 0, 0, 0.1);
color: rgba(0, 0, 0, 0.3); color: rgba(0, 0, 0, 0.3);