@typecad/typecad-gitdiff
Version:
See differences between two KiCAD PCB files
631 lines (570 loc) • 24.2 kB
text/typescript
#!/usr/bin/env tsx
import sharp from 'sharp';
import * as fs from 'fs';
import * as path from 'path';
import { parse } from 'svg-parser';
import { PNG } from 'pngjs';
import open from 'open';
import tempDir from 'temp-dir'; // Import the temp-dir package
import { KiCAD, kicad_cli_path, kicad_path } from './kicad';
import { exec } from "node:child_process";
import yoctoSpinner from 'yocto-spinner';
// Function to parse SVG dimensions
function parseSvgDimensions(svgPath: string): { width: number; height: number } {
const svgContent = fs.readFileSync(svgPath, 'utf-8');
const parsedSvg = parse(svgContent);
const svgElement = parsedSvg.children[0] as any;
const svgAttributes = svgElement?.properties || {};
const widthAttr = svgAttributes.width;
const heightAttr = svgAttributes.height;
const viewBox = svgAttributes.viewBox;
let width = 0;
let height = 0;
if (typeof widthAttr === 'string') {
width = parseFloat(widthAttr.replace(/px$/, '')) || 0;
}
if (typeof heightAttr === 'string') {
height = parseFloat(heightAttr.replace(/px$/, '')) || 0;
}
if ((!width || !height) && typeof viewBox === 'string') {
const [_, __, vbWidth, vbHeight] = viewBox.split(/\s+/).map(Number);
width = width || vbWidth;
height = height || vbHeight;
}
if (!width || !height) {
console.warn(`No dimensions found in SVG. Using default size: 800x600.`);
width = 800;
height = 600;
}
return { width: Math.round(width), height: Math.round(height) };
}
async function createBinaryMaskFromSharp(imageBuffer: Buffer): Promise<Uint8Array> {
const { data, info } = await sharp(imageBuffer)
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true });
const { width, height } = info;
const mask = new Uint8Array(width * height);
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
mask[i / 4] = alpha > 0 ? 1 : 0;
}
return mask;
}
function compareBinaryMasks(
originalPng: PNG,
mask1: Uint8Array,
mask2: Uint8Array,
width: number,
height: number
): PNG {
const diff = new PNG({ width, height });
for (let y = 0; y < height; y++) {
const rowStart = y * width;
const rowEnd = rowStart + width;
// Check if the entire row is identical
let rowChanged = false;
for (let x = rowStart; x < rowEnd; x++) {
if (mask1[x] !== mask2[x]) {
rowChanged = true;
break;
}
}
if (!rowChanged) {
// Copy the entire row as semi-transparent
for (let x = 0; x < width; x++) {
const idx = (y * width + x) << 2;
diff.data[idx] = originalPng.data[idx];
diff.data[idx + 1] = originalPng.data[idx + 1];
diff.data[idx + 2] = originalPng.data[idx + 2];
diff.data[idx + 3] = 127; // Semi-transparent
}
} else {
// Compare pixel-by-pixel for this row
for (let x = 0; x < width; x++) {
const idx = (y * width + x) << 2;
const mask1Value = mask1[rowStart + x];
const mask2Value = mask2[rowStart + x];
if (mask1Value === 1 && mask2Value === 0) {
// Pixel was present in mask1 but not in mask2 (removed)
diff.data[idx] = 255; // Red
diff.data[idx + 1] = 0; // Green
diff.data[idx + 2] = 0; // Blue
diff.data[idx + 3] = 255; // Alpha
} else if (mask1Value === 0 && mask2Value === 1) {
// Pixel was present in mask2 but not in mask1 (added)
diff.data[idx] = 0; // Red
diff.data[idx + 1] = 255; // Green
diff.data[idx + 2] = 0; // Blue
diff.data[idx + 3] = 255; // Alpha
} else {
// No change, copy original image with semi-transparency
diff.data[idx] = originalPng.data[idx];
diff.data[idx + 1] = originalPng.data[idx + 1];
diff.data[idx + 2] = originalPng.data[idx + 2];
diff.data[idx + 3] = 127; // Semi-transparent
}
}
}
}
return diff;
}
function areMasksIdentical(mask1: Uint8Array, mask2: Uint8Array): boolean {
if (mask1.length !== mask2.length) return false;
for (let i = 0; i < mask1.length; i++) {
if (mask1[i] !== mask2[i]) return false;
}
return true;
}
async function convertSvgToPngBuffer(svgPath: string): Promise<Buffer> {
const { width, height } = parseSvgDimensions(svgPath);
// Define the target width (1000px)
const targetWidth = 1000;
// Calculate the scaling factor to maintain the aspect ratio
const scaleFactor = targetWidth / width;
const targetHeight = Math.round(height * scaleFactor);
// Resize the SVG to the target dimensions
const pngBuffer = await sharp(svgPath)
.resize(targetWidth, targetHeight) // Resize to 1000px wide while maintaining aspect ratio
.png()
.toBuffer();
return pngBuffer;
}
function compareColorImages(image1: PNG, image2: PNG): PNG {
const width = image1.width;
const height = image1.height;
// Ensure both images have the same dimensions
if (width !== image2.width || height !== image2.height) {
throw new Error("Images must have the same dimensions for comparison.");
}
const diff = new PNG({ width, height });
// Define tolerance thresholds for color and alpha
const colorThreshold = 30; // Allow small color differences (0-255 range)
const alphaThreshold = 30; // Allow small alpha differences (0-255 range)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) << 2;
const r1 = image1.data[idx];
const g1 = image1.data[idx + 1];
const b1 = image1.data[idx + 2];
const a1 = image1.data[idx + 3];
const r2 = image2.data[idx];
const g2 = image2.data[idx + 1];
const b2 = image2.data[idx + 2];
const a2 = image2.data[idx + 3];
// Calculate the absolute differences for each channel
const colorDiff = Math.abs(r1 - r2) + Math.abs(g1 - g2) + Math.abs(b1 - b2);
const alphaDiff = Math.abs(a1 - a2);
if (colorDiff <= colorThreshold && alphaDiff <= alphaThreshold) {
// No significant change, copy original pixel with semi-transparency
diff.data[idx] = r1;
diff.data[idx + 1] = g1;
diff.data[idx + 2] = b1;
diff.data[idx + 3] = 127; // Semi-transparent
} else {
// Significant change detected, highlight in red, green, or yellow
// Removed feature
diff.data[idx] = 255; // Red
diff.data[idx + 1] = 255; // Green
diff.data[idx + 2] = 0; // Blue
diff.data[idx + 3] = 255; // Alpha
}
}
}
return diff;
}
const layers = [
"F.Cu", "F.Adhesive", "F.Paste", "F.Silkscreen", "F.Mask", "F.Courtyard", "F.Fab",
"B.Cu", "B.Adhesive", "B.Paste", "B.Silkscreen", "B.Mask", "B.Courtyard", "B.Fab",
"User.1", "User.2", "User.3", "User.4", "User.5", "User.6", "User.7", "User.8", "User.9",
"Edge.Cuts", "User.Drawings", "User.Comments", "User.Eco1", "User.Eco2", "Margin"
];
(async () => {
const tempFiles: string[] = []; // Track all temporary files for cleanup
const htmlLayers: { name: string; originalImage: string | null, diffImage: string | null, modifiedImage: string | null }[] = [];
console.log("🔃 typeCAD gitdiff starting...");
if (process.argv.length < 3) {
console.error("Usage: typecad-gitdiff <original.kicad_pcb> <modified.kicad_pcb>");
console.log("typeCAD-gitdiff version 1.0.0");
process.exit(1);
}
const spinner = yoctoSpinner({text: 'processing files...', spinner: {frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], interval: 100}, color: 'green'}).start();
try {
new KiCAD();
// Export SVGs for both boards to temporary files in parallel
const exportPromises = layers.flatMap(layer => {
const tempSvgPath1 = path.join(tempDir, `1_${layer}.svg`);
const tempSvgPath2 = path.join(tempDir, `2_${layer}.svg`);
tempFiles.push(tempSvgPath1, tempSvgPath2);
return [
new Promise<void>((resolve, reject) => {
exec(`"${kicad_cli_path}" pcb export svg --exclude-drawing-sheet --page-size-mode 2 --layers "${layer}" --output "${tempSvgPath1}" ${process.argv[2]}`, (err) => {
if (err) reject(err);
else resolve();
});
}),
new Promise<void>((resolve, reject) => {
exec(`"${kicad_cli_path}" pcb export svg --exclude-drawing-sheet --page-size-mode 2 --layers "${layer}" --output "${tempSvgPath2}" ${process.argv[3]}`, (err) => {
if (err) reject(err);
else resolve();
});
})
];
});
await Promise.all(exportPromises);
// Generate paths for all SVGs
const svgPaths1 = layers.map(layer => path.join(tempDir, `1_${layer}.svg`));
const svgPaths2 = layers.map(layer => path.join(tempDir, `2_${layer}.svg`));
// Convert all SVGs to PNG buffers in parallel
const [pngBuffers1, pngBuffers2] = await Promise.all([
Promise.all(svgPaths1.map(svgPath => convertSvgToPngBuffer(svgPath))),
Promise.all(svgPaths2.map(svgPath => convertSvgToPngBuffer(svgPath)))
]);
// Render PNG files for "Render" layer
const renderPngPath1 = path.join(tempDir, '1_render.png');
const renderPngPath2 = path.join(tempDir, '2_render.png');
tempFiles.push(renderPngPath1, renderPngPath2);
// Execute rendering commands for "Render" layer
await new Promise<void>((resolve, reject) => {
exec(`"${kicad_cli_path}" pcb render --output "${renderPngPath1}" ${process.argv[2]}`, (err) => {
if (err) reject(err);
else resolve();
});
});
await new Promise<void>((resolve, reject) => {
exec(`"${kicad_cli_path}" pcb render --output "${renderPngPath2}" ${process.argv[3]}`, (err) => {
if (err) reject(err);
else resolve();
});
});
// Read the rendered PNG files into buffers
const renderPngBuffer1 = fs.readFileSync(renderPngPath1);
const renderPngBuffer2 = fs.readFileSync(renderPngPath2);
// Add "Render" layer to the layers array
layers.push("Render");
// Process layers in parallel
// Process layers in parallel
await Promise.all(layers.map(async (layer, index) => {
let originalPng1, pngBuffer1, pngBuffer2;
if (layer === "Render") {
// Use the rendered PNG buffers for the "Render" layer
originalPng1 = PNG.sync.read(renderPngBuffer1);
pngBuffer1 = renderPngBuffer1;
pngBuffer2 = renderPngBuffer2;
} else {
// Use the SVG-derived PNG buffers for other layers
originalPng1 = PNG.sync.read(pngBuffers1[index]);
pngBuffer1 = pngBuffers1[index];
pngBuffer2 = pngBuffers2[index];
}
// Convert original images to Base64
const originalImageBase64 = pngBuffer1.toString('base64');
const originalImageSrc = `data:image/png;base64,${originalImageBase64}`;
const modifiedImageBase64 = pngBuffer2.toString('base64');
const modifiedImageSrc = `data:image/png;base64,${modifiedImageBase64}`;
if (layer === "Render") {
// Compare the two PNGs directly for the "Render" layer
const diff = compareColorImages(originalPng1, PNG.sync.read(pngBuffer2));
const diffBuffer = PNG.sync.write(diff); // Get the buffer of the difference image
// Convert the buffer to a Base64-encoded string
const diffImageBase64 = diffBuffer.toString('base64');
const diffImageSrc = `data:image/png;base64,${diffImageBase64}`;
htmlLayers.push({
name: layer,
originalImage: originalImageSrc,
diffImage: diffImageSrc,
modifiedImage: modifiedImageSrc
});
} else {
// Create binary masks and compare them for other layers
const [mask1, mask2] = await Promise.all([
createBinaryMaskFromSharp(pngBuffer1),
createBinaryMaskFromSharp(pngBuffer2)
]);
if (areMasksIdentical(mask1, mask2)) {
htmlLayers.push({
name: layer,
originalImage: originalImageSrc,
diffImage: null,
modifiedImage: modifiedImageSrc
});
return;
}
const diff = compareBinaryMasks(originalPng1, mask1, mask2, originalPng1.width, originalPng1.height);
const diffBuffer = PNG.sync.write(diff); // Get the buffer of the difference image
// Convert the buffer to a Base64-encoded string
const diffImageBase64 = diffBuffer.toString('base64');
const diffImageSrc = `data:image/png;base64,${diffImageBase64}`;
htmlLayers.push({
name: layer,
originalImage: originalImageSrc,
diffImage: diffImageSrc,
modifiedImage: modifiedImageSrc
});
}
}));
// Generate HTML file with embedded images and enhanced styling
const htmlContent = `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Layer Comparison Results</title>
<style>
/* General Styling */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
background: linear-gradient(135deg, #f5f7fa, #c3cfe2);
color: #333;
line-height: 1.6;
}
h1 {
text-align: center;
font-size: 2.25em;
font-family: Inter VF, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"
font-weight: 800;
color: #111827;
margin-top: 20px;
margin-bottom: 20px;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1);
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.layer-container {
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
padding: 15px;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.layer-container:hover {
transform: translateY(-5px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
}
.layer-title {
font-size: 1.2em;
font-weight: bold;
color: #2980b9;
margin-bottom: 10px;
}
.image-grid {
display: flex;
gap: 15px;
justify-content: center;
align-items: center;
}
.image-wrapper {
position: relative;
display: inline-block;
background-color: #fff; /* White background for transparency */
border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.image-grid img {
max-width: 100%;
height: auto;
display: block;
cursor: pointer;
transition: transform 0.3s ease;
background: gray
}
.image-grid img:hover {
transform: scale(1.05);
}
.image-caption {
text-align: center;
font-size: 0.9em;
color: #555;
margin-top: 5px;
}
.no-changes {
color: #888;
font-style: italic;
text-align: center;
}
/* Lightbox Styling */
.lightbox {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.lightbox img {
max-width: 90%;
max-height: 90%;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
background: white;
}
.lightbox.active {
display: flex;
}
/* Legend Styling */
.legend {
margin-top: 40px;
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
font-size: 1em;
line-height: 1.5;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.legend h2 {
text-align: center;
font-size: 1.5em;
color: #2c3e50;
margin-bottom: 15px;
}
.legend div {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.legend span.color-box {
width: 20px;
height: 20px;
margin-right: 15px;
border-radius: 2px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.red { background-color: #ff4d4d; }
.green { background-color: #4caf50; }
.yellow { background-color:rgb(255, 255, 0); }
.legend span.description {
flex-grow: 1;
color: #333;
}
/* Footer */
footer {
text-align: center;
margin-top: 40px;
padding: 10px;
font-size: 0.9em;
color: #888;
}
footer a {
color: #2980b9;
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<h1>Layer Comparison Results</h1>
<div id="results">
${htmlLayers.map(layer => `
<div class="layer-container">
<div class="layer-title">${layer.name}</div>
${layer.diffImage
? `
<div class="image-grid">
<div class="image-wrapper">
<img src="${layer.originalImage}" alt="Original Image" class="clickable-image" data-src="${layer.originalImage}">
<div class="image-caption">Original</div>
</div>
<div class="image-wrapper">
<img src="${layer.diffImage}" alt="Difference Image" class="clickable-image" data-src="${layer.diffImage}">
<div class="image-caption">Difference</div>
</div>
<div class="image-wrapper">
<img src="${layer.modifiedImage}" alt="Modified Image" class="clickable-image" data-src="${layer.modifiedImage}">
<div class="image-caption">Modified</div>
</div>
</div>
`
: `<div class="no-changes">No changes detected.</div>`}
</div>
`).join('')}
</div>
<!-- Lightbox -->
<div class="lightbox" id="lightbox">
<img src="" alt="Expanded Image" id="lightbox-image">
</div>
<!-- Legend Section -->
<div class="legend">
<h2>Legend</h2>
<div>
<span class="color-box red"></span>
<span class="description">Features removed</span>
</div>
<div>
<span class="color-box green"></span>
<span class="description">Features added</span>
</div>
<div>
<span class="color-box yellow"></span>
<span class="description">Render features modified</span>
</div>
</div>
<!-- Footer -->
<footer>
Generated by <a href="https://typecad.net" target="_blank">typecad.net</a>
</footer>
</div>
<script>
// Lightbox functionality
document.addEventListener('DOMContentLoaded', () => {
const lightbox = document.getElementById('lightbox');
const lightboxImage = document.getElementById('lightbox-image');
// Open lightbox when an image is clicked
document.querySelectorAll('.clickable-image').forEach(img => {
img.addEventListener('click', () => {
lightboxImage.src = img.dataset.src;
lightbox.classList.add('active');
});
});
// Close lightbox when clicking outside the image or pressing Esc
lightbox.addEventListener('click', (e) => {
if (e.target === lightbox || e.target === lightboxImage) {
lightbox.classList.remove('active');
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
lightbox.classList.remove('active');
}
});
});
</script>
</body>
</html>
`;
const htmlFilePath = path.join(tempDir, 'results.html');
fs.writeFileSync(htmlFilePath, htmlContent);
await open(htmlFilePath); // Open the HTML file in the default browser
} catch (error) {
console.error('Error:', error);
} finally {
// Clean up all temporary files
spinner.success('Finished');
for (const filePath of tempFiles) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (cleanupError) {
console.error(`Failed to delete temporary file: ${filePath}`, cleanupError);
}
}
}
})();