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:
parent
3f743e567b
commit
de188fb77a
10
package-lock.json
generated
10
package-lock.json
generated
@ -8,6 +8,7 @@
|
||||
"name": "qrcodes",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"imagetracer": "^0.2.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
@ -1437,6 +1438,15 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
|
@ -13,6 +13,7 @@
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"imagetracer": "^0.2.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
import { imageTracer } from 'imagetracer'
|
||||
|
||||
const TextAreaComponent = () => {
|
||||
const [text, setText] = useState('')
|
||||
@ -7,26 +8,29 @@ const TextAreaComponent = () => {
|
||||
const [customImage, setCustomImage] = useState(null)
|
||||
const [customImageUrl, setCustomImageUrl] = useState('')
|
||||
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)
|
||||
|
||||
// Generate QR code when text, custom image, or image size changes
|
||||
// Generate QR code when text, custom image, image size, or colors change
|
||||
useEffect(() => {
|
||||
if (text.trim()) {
|
||||
generateQRCode(text)
|
||||
} else {
|
||||
setQrCodeUrl('')
|
||||
}
|
||||
}, [text, customImage, imageSize])
|
||||
}, [text, customImage, imageSize, foregroundColor, backgroundColor])
|
||||
|
||||
const generateQRCode = async (inputText) => {
|
||||
try {
|
||||
// Generate QR code as data URL
|
||||
const url = await QRCode.toDataURL(inputText, {
|
||||
width: 256,
|
||||
width: 512,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'H',
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
dark: foregroundColor,
|
||||
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) => {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// Set canvas size
|
||||
canvas.width = 256
|
||||
canvas.height = 256
|
||||
canvas.width = 512
|
||||
canvas.height = 512
|
||||
|
||||
// Create image objects
|
||||
const qrImage = new Image()
|
||||
@ -58,32 +289,33 @@ const TextAreaComponent = () => {
|
||||
|
||||
qrImage.onload = () => {
|
||||
// Draw QR code
|
||||
ctx.drawImage(qrImage, 0, 0, 256, 256)
|
||||
ctx.drawImage(qrImage, 0, 0, 512, 512)
|
||||
|
||||
customImage.onload = () => {
|
||||
// Calculate image size based on user preference
|
||||
const calculatedImageSize = 256 * (imageSize / 100)
|
||||
const imageX = (256 - calculatedImageSize) / 2
|
||||
const imageY = (256 - calculatedImageSize) / 2
|
||||
const calculatedImageSize = 512 * (imageSize / 100)
|
||||
|
||||
// Create circular clipping path for the image
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.arc(imageX + calculatedImageSize/2, imageY + calculatedImageSize/2, calculatedImageSize/2, 0, 2 * Math.PI)
|
||||
ctx.clip()
|
||||
// Calculate white box size with margin
|
||||
const margin = 16 // 8 pixel margin around the image
|
||||
const boxSize = calculatedImageSize + (margin * 2)
|
||||
const boxX = (512 - boxSize) / 2
|
||||
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
|
||||
ctx.drawImage(customImage, imageX, imageY, calculatedImageSize, calculatedImageSize)
|
||||
|
||||
// Restore context
|
||||
ctx.restore()
|
||||
|
||||
// Add white border around the image
|
||||
ctx.strokeStyle = '#FFFFFF'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
ctx.arc(imageX + calculatedImageSize/2, imageY + calculatedImageSize/2, calculatedImageSize/2, 0, 2 * Math.PI)
|
||||
ctx.stroke()
|
||||
// Add border around the white box
|
||||
//ctx.strokeStyle = '#000000'
|
||||
//ctx.lineWidth = 1
|
||||
//ctx.strokeRect(boxX, boxY, boxSize, boxSize)
|
||||
|
||||
// Convert canvas to data URL
|
||||
resolve(canvas.toDataURL('image/png'))
|
||||
@ -160,6 +392,36 @@ const TextAreaComponent = () => {
|
||||
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">
|
||||
<label htmlFor="image-upload" className="image-upload-label">
|
||||
Add custom image or SVG to QR code center:
|
||||
@ -235,6 +497,14 @@ const TextAreaComponent = () => {
|
||||
alt="QR Code"
|
||||
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">
|
||||
Scan this QR code with any QR code reader app
|
||||
</p>
|
||||
|
@ -94,6 +94,58 @@ h1 {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -306,6 +358,28 @@ h1 {
|
||||
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) {
|
||||
:root {
|
||||
color: #213547;
|
||||
@ -329,6 +403,27 @@ h1 {
|
||||
.textarea-input::placeholder {
|
||||
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 {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
|
Loading…
Reference in New Issue
Block a user