Fundamental_Analysis/frontend/src/lib/image-processing.ts
Lv, Qi abe47c4bc8 refactor(report): switch to HTML+Gotenberg for high-quality PDF export
- 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
2025-11-30 22:43:22 +08:00

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;
});
}