diff --git a/docs/api/README.md b/docs/api/README.md
index c243862..84e2cb0 100644
--- a/docs/api/README.md
+++ b/docs/api/README.md
@@ -132,6 +132,7 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/quote/delete.md`](routes/quote/delete.md) | `DELETE /quote/{id}` |
| [`routes/quote/get.md`](routes/quote/get.md) | `GET /quote/{id}` |
| [`routes/quote/update.md`](routes/quote/update.md) | `PATCH /quote/{id}` |
+| [`routes/serper/image.md`](routes/serper/image.md) | `POST /serper/image` |
| [`routes/serper/images.md`](routes/serper/images.md) | `POST /serper/images` |
| [`routes/stock/adjust.md`](routes/stock/adjust.md) | `POST /stock/adjust` |
| [`routes/supplier/list.md`](routes/supplier/list.md) | `GET /supplier` |
diff --git a/docs/api/openapi.json b/docs/api/openapi.json
index 8943643..9d96d10 100644
--- a/docs/api/openapi.json
+++ b/docs/api/openapi.json
@@ -1126,6 +1126,114 @@
}
}
},
+ "/serper/image": {
+ "post": {
+ "tags": [
+ "Serper"
+ ],
+ "summary": "Crawl image from URL, crop white/transparency borders, and return AVIF",
+ "description": "Fetches the image at the provided `url`, crops surrounding white and\ntransparent border margins, resizes to max 1000px, and returns the AVIF image.\n",
+ "security": [
+ {
+ "cookieAuth": []
+ }
+ ],
+ "x-markdown": "serper/image.md",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "url"
+ ],
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "Absolute HTTP/HTTPS URL of the image to crawl",
+ "example": "https://example.com/logo.png"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "AVIF image bytes",
+ "headers": {
+ "Content-Type": {
+ "schema": {
+ "type": "string",
+ "example": "image/avif"
+ }
+ },
+ "X-Image-Width": {
+ "schema": {
+ "type": "integer",
+ "example": 540
+ }
+ },
+ "X-Image-Height": {
+ "schema": {
+ "type": "integer",
+ "example": 179
+ }
+ }
+ },
+ "content": {
+ "image/avif": {
+ "schema": {
+ "type": "string",
+ "format": "binary"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "URL missing, invalid input, or image processing error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not authorized",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "502": {
+ "description": "Bad Gateway (crawling the upstream image URL failed)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/quote": {
"get": {
"tags": [
diff --git a/docs/api/routes/serper/image.md b/docs/api/routes/serper/image.md
new file mode 100644
index 0000000..edaa703
--- /dev/null
+++ b/docs/api/routes/serper/image.md
@@ -0,0 +1,34 @@
+# `POST /serper/image`
+
+Protected. Requires a valid session cookie (`credentials: 'include'`).
+
+Crawl image from URL, crop white/transparency borders, and return AVIF
+
+Fetches the image at the provided `url`, crops surrounding white and
+transparent border margins, resizes to max 1000px, and returns the AVIF image.
+
+
+## Body (JSON)
+
+```json
+{
+ "url": "https://example.com/logo.png"
+}
+```
+
+## Response `200`
+
+AVIF image bytes
+
+## Errors
+
+| Status | Description |
+|--------|-------------|
+| 400 | URL missing, invalid input, or image processing error — `{"error":"…"}` |
+| 401 | Unauthorized — `{"error":"…"}` |
+| 403 | Not authorized — `{"error":"…"}` |
+| 502 | Bad Gateway (crawling the upstream image URL failed) — `{"error":"…"}` |
+
+---
+
+_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._
diff --git a/scripts/testImageCrop.js b/scripts/testImageCrop.js
new file mode 100644
index 0000000..d697200
--- /dev/null
+++ b/scripts/testImageCrop.js
@@ -0,0 +1,89 @@
+#!/usr/bin/env node
+import sharp from 'sharp';
+import { cropAndConvertToAvif, crawlAndProcessImage } from '../src/services/imageCrop.js';
+
+async function run() {
+ console.log('--- 1. Testing synthetic images ---');
+
+ // Synthetic test 1: White border
+ const whiteImg = await sharp({
+ create: { width: 300, height: 300, channels: 4, background: { r: 255, g: 255, b: 255, alpha: 1 } },
+ })
+ .composite([
+ {
+ input: Buffer.from(''),
+ top: 100,
+ left: 100,
+ },
+ ])
+ .png()
+ .toBuffer();
+
+ const res1 = await cropAndConvertToAvif(whiteImg);
+ console.log(`White border: 300x300 -> ${res1.width}x${res1.height}, ${res1.size} bytes`);
+ if (res1.width !== 100 || res1.height !== 100) {
+ throw new Error(`Expected 100x100, got ${res1.width}x${res1.height}`);
+ }
+
+ // Synthetic test 2: Transparent border
+ const transImg = await sharp({
+ create: { width: 300, height: 300, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
+ })
+ .composite([
+ {
+ input: Buffer.from(''),
+ top: 110,
+ left: 90,
+ },
+ ])
+ .png()
+ .toBuffer();
+
+ const res2 = await cropAndConvertToAvif(transImg);
+ console.log(`Transparent border: 300x300 -> ${res2.width}x${res2.height}, ${res2.size} bytes`);
+ if (res2.width !== 120 || res2.height !== 80) {
+ throw new Error(`Expected 120x80, got ${res2.width}x${res2.height}`);
+ }
+
+ // Synthetic test 3: Outer transparent, inner white border
+ const mixedImg = await sharp({
+ create: { width: 400, height: 400, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
+ })
+ .composite([
+ {
+ input: Buffer.from(''),
+ top: 100,
+ left: 100,
+ },
+ {
+ input: Buffer.from(''),
+ top: 170,
+ left: 150,
+ },
+ ])
+ .png()
+ .toBuffer();
+
+ const res3 = await cropAndConvertToAvif(mixedImg);
+ console.log(`Mixed border: 400x400 -> ${res3.width}x${res3.height}, ${res3.size} bytes`);
+ if (res3.width !== 100 || res3.height !== 60) {
+ throw new Error(`Expected 100x60, got ${res3.width}x${res3.height}`);
+ }
+
+ console.log('\nAll synthetic tests PASSED!');
+
+ // CLI URL test if provided as argument
+ const testUrl = process.argv[2];
+ if (testUrl) {
+ console.log(`\n--- 2. Crawling URL: ${testUrl} ---`);
+ const urlRes = await crawlAndProcessImage(testUrl);
+ console.log(`Crawled & processed: ${urlRes.width}x${urlRes.height}, ${urlRes.size} bytes`);
+ }
+
+ process.exit(0);
+}
+
+run().catch((err) => {
+ console.error('Test failed:', err);
+ process.exit(1);
+});
diff --git a/src/api/routes/serper.js b/src/api/routes/serper.js
index 436010c..e5d2f36 100644
--- a/src/api/routes/serper.js
+++ b/src/api/routes/serper.js
@@ -1,6 +1,7 @@
import sharp from 'sharp';
import * as dbCompanySetting from '../../db/companysetting.js';
import env from '../../config/env.js';
+import { crawlAndProcessImage } from '../../services/imageCrop.js';
const CONCURRENCY = 6;
const FETCH_TIMEOUT_MS = 6000;
@@ -342,6 +343,113 @@ export function register(app, basePath = '') {
}
};
+ /**
+ * @openapi
+ * /serper/image:
+ * post:
+ * tags: [Serper]
+ * summary: Crawl image from URL, crop white/transparency borders, and return AVIF
+ * description: |
+ * Fetches the image at the provided `url`, crops surrounding white and
+ * transparent border margins, resizes to max 1000px, and returns the AVIF image.
+ * security:
+ * - cookieAuth: []
+ * x-markdown: serper/image.md
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [url]
+ * properties:
+ * url:
+ * type: string
+ * description: Absolute HTTP/HTTPS URL of the image to crawl
+ * example: "https://example.com/logo.png"
+ * responses:
+ * 200:
+ * description: AVIF image bytes
+ * headers:
+ * Content-Type:
+ * schema:
+ * type: string
+ * example: image/avif
+ * X-Image-Width:
+ * schema:
+ * type: integer
+ * example: 540
+ * X-Image-Height:
+ * schema:
+ * type: integer
+ * example: 179
+ * content:
+ * image/avif:
+ * schema:
+ * type: string
+ * format: binary
+ * 400:
+ * description: URL missing, invalid input, or image processing error
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 401:
+ * description: Unauthorized
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 403:
+ * description: Not authorized
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ * 502:
+ * description: Bad Gateway (crawling the upstream image URL failed)
+ * content:
+ * application/json:
+ * schema:
+ * $ref: '#/components/schemas/Error'
+ */
+ const handleImageCrop = async (req, res) => {
+ const selected = requireSelectedCompany(req);
+ if (selected.error) {
+ return res.status(400).json({ error: selected.error });
+ }
+
+ const rawUrl = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
+ if (!rawUrl) {
+ return res.status(400).json({ error: 'url is required' });
+ }
+
+ const abortController = new AbortController();
+ const onClose = () => abortController.abort();
+ req.on('close', onClose);
+ res.on('close', onClose);
+
+ try {
+ const result = await crawlAndProcessImage(rawUrl, {
+ signal: abortController.signal,
+ });
+
+ if (res.writableEnded || res.destroyed) return;
+
+ res.setHeader('Content-Type', 'image/avif');
+ res.setHeader('Content-Length', String(result.buffer.length));
+ res.setHeader('X-Image-Width', String(result.width));
+ res.setHeader('X-Image-Height', String(result.height));
+ return res.status(200).send(result.buffer);
+ } catch (err) {
+ if (abortController.signal.aborted || res.writableEnded || res.destroyed) return;
+ const message = err?.message || 'Failed to process image';
+ const isUpstream = message.startsWith('Failed to crawl image:');
+ const status = isUpstream ? 502 : 400;
+ return res.status(status).json({ error: message });
+ }
+ };
+
app.post(`${basePath}/serper/images`, handleImagesSearch);
- app.post(`${basePath}/search/images`, handleImagesSearch);
+ app.post(`${basePath}/serper/image`, handleImageCrop);
}
diff --git a/src/services/imageCrop.js b/src/services/imageCrop.js
new file mode 100644
index 0000000..392268c
--- /dev/null
+++ b/src/services/imageCrop.js
@@ -0,0 +1,175 @@
+import sharp from 'sharp';
+
+const FETCH_TIMEOUT_MS = 15000;
+const MAX_BYTES = 50 * 1024 * 1024; // 50MB
+const MAX_EDGE = 1000;
+const TRIM_THRESHOLD = 10;
+const AVIF_QUALITY = 80;
+const AVIF_EFFORT = 4;
+
+/**
+ * Validates and normalizes an image URL.
+ * @param {string} url
+ * @returns {URL}
+ */
+export function validateImageUrl(url) {
+ if (typeof url !== 'string' || !url.trim()) {
+ throw new Error('url is required');
+ }
+ let parsed;
+ try {
+ parsed = new URL(url.trim());
+ } catch {
+ throw new Error('Invalid URL');
+ }
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ throw new Error(`Unsupported URL protocol: ${parsed.protocol}. Only http: and https: are allowed.`);
+ }
+ return parsed;
+}
+
+/**
+ * Crawls/downloads an image from a URL.
+ *
+ * @param {string} url
+ * @param {object} [options]
+ * @param {AbortSignal} [options.signal]
+ * @returns {Promise}
+ */
+export async function crawlImage(url, { signal } = {}) {
+ const parsedUrl = validateImageUrl(url);
+
+ const fetchSignal = signal
+ ? AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)])
+ : AbortSignal.timeout(FETCH_TIMEOUT_MS);
+
+ const response = await fetch(parsedUrl.href, {
+ headers: {
+ 'User-Agent':
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+ 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.9,de;q=0.8',
+ 'Referer': parsedUrl.origin + '/',
+ },
+ signal: fetchSignal,
+ redirect: 'follow',
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to crawl image: HTTP ${response.status} ${response.statusText}`);
+ }
+
+ const contentLength = Number(response.headers.get('content-length'));
+ if (Number.isFinite(contentLength) && contentLength > MAX_BYTES) {
+ throw new Error(`Image size exceeds limit of ${MAX_BYTES} bytes`);
+ }
+
+ const arrayBuffer = await response.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ if (buffer.length === 0) {
+ throw new Error('Crawled image is empty (0 bytes)');
+ }
+
+ if (buffer.length > MAX_BYTES) {
+ throw new Error(`Image size (${buffer.length} bytes) exceeds limit of ${MAX_BYTES} bytes`);
+ }
+
+ return buffer;
+}
+
+/**
+ * Trims transparent and white borders from an image buffer, resizes to max 1000px, and converts to AVIF.
+ *
+ * @param {Buffer} inputBuffer - Raw image buffer
+ * @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
+ */
+export async function cropAndConvertToAvif(inputBuffer) {
+ // 1. Initial metadata and orientation correction
+ const baseSharp = sharp(inputBuffer, { failOn: 'none' }).rotate();
+ const initialMeta = await baseSharp.metadata();
+
+ // Use PNG intermediate for trimming passes to preserve alpha
+ let currentBuffer = await baseSharp.png().toBuffer();
+ let currentMeta = initialMeta;
+
+ // 2. Step A: Trim transparent borders if alpha is present
+ if (currentMeta.hasAlpha) {
+ try {
+ const trimmed = await sharp(currentBuffer)
+ .trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: TRIM_THRESHOLD })
+ .png()
+ .toBuffer({ resolveWithObject: true });
+ if (trimmed.info.width > 0 && trimmed.info.height > 0) {
+ currentBuffer = trimmed.data;
+ currentMeta = trimmed.info;
+ }
+ } catch {
+ // Keep currentBuffer
+ }
+ }
+
+ // 3. Step B: Trim white borders
+ try {
+ const trimmed = await sharp(currentBuffer)
+ .trim({ background: '#ffffff', threshold: TRIM_THRESHOLD })
+ .png()
+ .toBuffer({ resolveWithObject: true });
+ if (trimmed.info.width > 0 && trimmed.info.height > 0) {
+ currentBuffer = trimmed.data;
+ currentMeta = trimmed.info;
+ }
+ } catch {
+ // Keep currentBuffer
+ }
+
+ // 4. Step C: Trim transparent borders again (handles white border containing transparent padding)
+ if (currentMeta.hasAlpha) {
+ try {
+ const trimmed = await sharp(currentBuffer)
+ .trim({ background: { r: 0, g: 0, b: 0, alpha: 0 }, threshold: TRIM_THRESHOLD })
+ .png()
+ .toBuffer({ resolveWithObject: true });
+ if (trimmed.info.width > 0 && trimmed.info.height > 0) {
+ currentBuffer = trimmed.data;
+ currentMeta = trimmed.info;
+ }
+ } catch {
+ // Keep currentBuffer
+ }
+ }
+
+ // 5. Resize to max 1000px without enlargement
+ let finalPipeline = sharp(currentBuffer);
+ if (currentMeta.width > MAX_EDGE || currentMeta.height > MAX_EDGE) {
+ finalPipeline = finalPipeline.resize(MAX_EDGE, MAX_EDGE, {
+ fit: 'inside',
+ withoutEnlargement: true,
+ });
+ }
+
+ // 6. Convert to AVIF
+ const avifResult = await finalPipeline
+ .avif({ quality: AVIF_QUALITY, effort: AVIF_EFFORT })
+ .toBuffer({ resolveWithObject: true });
+
+ return {
+ buffer: avifResult.data,
+ width: avifResult.info.width,
+ height: avifResult.info.height,
+ size: avifResult.info.size,
+ };
+}
+
+/**
+ * Crawls a URL, crops white & transparency borders, resizes to max 1000px, and returns AVIF.
+ *
+ * @param {string} url - Image URL to crawl
+ * @param {object} [options]
+ * @param {AbortSignal} [options.signal] - Abort signal
+ * @returns {Promise<{ buffer: Buffer, width: number, height: number, size: number }>}
+ */
+export async function crawlAndProcessImage(url, { signal } = {}) {
+ const rawBuffer = await crawlImage(url, { signal });
+ return cropAndConvertToAvif(rawBuffer);
+}