- Feat: Add Gotenberg service to docker-compose for headless PDF rendering - Feat: Implement /generate-pdf endpoint in report-generator-service - Feat: Add PDF generation proxy route in api-gateway - Refactor(frontend): Rewrite PDFExportButton to generate HTML with embedded styles and images - Feat(frontend): Auto-crop React Flow screenshots to remove whitespace - Style: Optimize report print layout with CSS (margins, image sizing) - Chore: Remove legacy react-pdf code and font files
165 lines
5.4 KiB
TypeScript
165 lines
5.4 KiB
TypeScript
|
|
/**
|
|
* Process image data to crop whitespace
|
|
*/
|
|
export async function cropImage(
|
|
imageSrc: string,
|
|
options: {
|
|
threshold?: number; // 0-255, higher means more colors are considered "white"
|
|
padding?: number; // padding in pixels to keep around content
|
|
} = {}
|
|
): Promise<string> {
|
|
const { threshold = 252, padding = 20 } = options; // 252 covers pure white and very light compression artifacts
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
// Enable CORS if needed, though usually data URLs don't need it
|
|
img.crossOrigin = "anonymous";
|
|
|
|
img.onload = () => {
|
|
try {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = img.width;
|
|
canvas.height = img.height;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
if (!ctx) {
|
|
resolve(imageSrc);
|
|
return;
|
|
}
|
|
|
|
ctx.drawImage(img, 0, 0);
|
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
const data = imageData.data;
|
|
const { width, height } = canvas;
|
|
|
|
// Check if pixel is "content" (darker than threshold)
|
|
// Returns true if it is CONTENT
|
|
const isContent = (r: number, g: number, b: number) => {
|
|
return r < threshold || g < threshold || b < threshold;
|
|
};
|
|
|
|
let top = -1, bottom = -1, left = -1, right = -1;
|
|
|
|
// Scan Top-down for top boundary
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const idx = (y * width + x) * 4;
|
|
if (isContent(data[idx], data[idx+1], data[idx+2])) {
|
|
top = y;
|
|
break;
|
|
}
|
|
}
|
|
if (top !== -1) break;
|
|
}
|
|
|
|
// If top is still -1, the image is empty (all white)
|
|
if (top === -1) {
|
|
resolve(imageSrc);
|
|
return;
|
|
}
|
|
|
|
// Scan Bottom-up for bottom boundary
|
|
for (let y = height - 1; y >= top; y--) {
|
|
for (let x = 0; x < width; x++) {
|
|
const idx = (y * width + x) * 4;
|
|
if (isContent(data[idx], data[idx+1], data[idx+2])) {
|
|
bottom = y + 1;
|
|
break;
|
|
}
|
|
}
|
|
if (bottom !== -1) break;
|
|
}
|
|
|
|
// Scan Left-right for left boundary
|
|
// We only need to scan within the vertical bounds we found
|
|
for (let x = 0; x < width; x++) {
|
|
for (let y = top; y < bottom; y++) {
|
|
const idx = (y * width + x) * 4;
|
|
if (isContent(data[idx], data[idx+1], data[idx+2])) {
|
|
left = x;
|
|
break;
|
|
}
|
|
}
|
|
if (left !== -1) break;
|
|
}
|
|
|
|
// Scan Right-left for right boundary
|
|
for (let x = width - 1; x >= left; x--) {
|
|
for (let y = top; y < bottom; y++) {
|
|
const idx = (y * width + x) * 4;
|
|
if (isContent(data[idx], data[idx+1], data[idx+2])) {
|
|
right = x + 1;
|
|
break;
|
|
}
|
|
}
|
|
if (right !== -1) break;
|
|
}
|
|
|
|
// Calculate crop coordinates with padding
|
|
const cropX = Math.max(0, left - padding);
|
|
const cropY = Math.max(0, top - padding);
|
|
|
|
// Calculate width/height ensuring we don't go out of bounds
|
|
// right is exclusive index + 1, so width is right - left.
|
|
// content width = right - left
|
|
// target width = content width + 2 * padding
|
|
// but limited by image bounds
|
|
const contentWidth = right - left;
|
|
const contentHeight = bottom - top;
|
|
|
|
// Check if we found valid bounds
|
|
if (contentWidth <= 0 || contentHeight <= 0) {
|
|
resolve(imageSrc);
|
|
return;
|
|
}
|
|
|
|
// Adjust padding to not exceed original image
|
|
// Actually, we want the crop rect to include padding if it exists in source,
|
|
// or maybe just white pad if it doesn't?
|
|
// The current logic `Math.max(0, left - padding)` keeps padding INSIDE the original image.
|
|
// If the content is at the edge, we might want to ADD whitespace?
|
|
// Usually cropping means reducing size. Let's stick to keeping what's in the image.
|
|
|
|
const rectRight = Math.min(width, right + padding);
|
|
const rectBottom = Math.min(height, bottom + padding);
|
|
|
|
const finalWidth = rectRight - cropX;
|
|
const finalHeight = rectBottom - cropY;
|
|
|
|
const finalCanvas = document.createElement('canvas');
|
|
finalCanvas.width = finalWidth;
|
|
finalCanvas.height = finalHeight;
|
|
const finalCtx = finalCanvas.getContext('2d');
|
|
|
|
if (!finalCtx) {
|
|
resolve(imageSrc);
|
|
return;
|
|
}
|
|
|
|
// Fill with white in case of transparency (though we are copying from opaque usually)
|
|
finalCtx.fillStyle = '#ffffff';
|
|
finalCtx.fillRect(0, 0, finalWidth, finalHeight);
|
|
|
|
finalCtx.drawImage(
|
|
canvas,
|
|
cropX, cropY, finalWidth, finalHeight, // Source rect
|
|
0, 0, finalWidth, finalHeight // Dest rect
|
|
);
|
|
|
|
resolve(finalCanvas.toDataURL('image/png'));
|
|
|
|
} catch (e) {
|
|
console.error("Image processing failed", e);
|
|
resolve(imageSrc);
|
|
}
|
|
};
|
|
img.onerror = (e) => {
|
|
console.error("Image loading failed", e);
|
|
resolve(imageSrc);
|
|
};
|
|
img.src = imageSrc;
|
|
});
|
|
}
|
|
|