From 464021318a03fc501e38501634bb89aea09c6f4c Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 24 Oct 2026 05:13:13 +0200 Subject: [PATCH] u --- docs/api/routes-all.md | 4 +- docs/api/routes/pos/cimage.md | 3 +- docs/api/routes/pos/pimage.md | 1 + scratch/curl-pimage.jpg | Bin 0 -> 4247 bytes src/v1/routes/pimage.js | 97 ++++++++++++++++++++++++++-------- 5 files changed, 80 insertions(+), 25 deletions(-) create mode 100644 scratch/curl-pimage.jpg diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 28a39a2..cc44c99 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -925,7 +925,7 @@ _none_ - Params: `query: lastChangedCategory?: str, limit?: int` - Returns: `200 {}[]` #### POS category image delivery — `GET /v1/cimage` -- Params: `query: path: str, w?: int, h?: int, format?: str` +- Params: `query: path: str, size?: int, w?: int, h?: int, format?: str` - Returns: `200 jpeg` #### POS discovery and pairing — `GET /v1/client` - Params: `query: authCode: str, name?: str` @@ -946,7 +946,7 @@ _none_ - Params: `query: mandantId?: str, body: { orders?: {}[] }` - Returns: `200 { status: str, externalOrderId: str, message: str }[]` #### POS product image delivery — `GET /v1/pimage` -- Params: `query: path: str, w?: int, h?: int, format?: str` +- Params: `query: path: str, size?: int, w?: int, h?: int, format?: str` - Returns: `200 jpeg` #### JTL-POS product catalog synchronization — `GET /v1/product` - Params: `query: lastChangedProduct?: str, limit?: int` diff --git a/docs/api/routes/pos/cimage.md b/docs/api/routes/pos/cimage.md index c946fdf..e0de29a 100644 --- a/docs/api/routes/pos/cimage.md +++ b/docs/api/routes/pos/cimage.md @@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). POS category image delivery -Returns category image by hash (`path` query param) scaled to preview size with 3:4 portrait ratio. +Returns category image by hash (`path` query param) scaled to preview size (padded to 1:1 with adaptive aspect-ratio borders: thinner borders for vertical images, wider borders for horizontal images). ## Query parameters @@ -12,6 +12,7 @@ Returns category image by hash (`path` query param) scaled to preview size with | Param | Required | Type | Description | |-------|----------|------|-------------| | `path` | yes | string | SHA-256 hash of the category image | +| `size` | no | integer | Max dimension (width & height) | | `w` | no | integer | Max width | | `h` | no | integer | Max height | | `format` | no | string | Optional target format (defaults to jpeg/png or client Accept preference) | diff --git a/docs/api/routes/pos/pimage.md b/docs/api/routes/pos/pimage.md index 0d754b7..39cd230 100644 --- a/docs/api/routes/pos/pimage.md +++ b/docs/api/routes/pos/pimage.md @@ -12,6 +12,7 @@ Returns product image by hash (`path` query param) scaled to preview size. | Param | Required | Type | Description | |-------|----------|------|-------------| | `path` | yes | string | SHA-256 hash of the product image | +| `size` | no | integer | Max dimension (width & height) | | `w` | no | integer | Max width | | `h` | no | integer | Max height | | `format` | no | string | Optional target format (defaults to jpeg/png or client Accept preference) | diff --git a/scratch/curl-pimage.jpg b/scratch/curl-pimage.jpg new file mode 100644 index 0000000000000000000000000000000000000000..170872660b60c0d02ed9e0d43625bef099508f00 GIT binary patch literal 4247 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 0; const hasW = req.query.w !== undefined && Number.isFinite(Number(req.query.w)) && Number(req.query.w) > 0; const hasH = req.query.h !== undefined && Number.isFinite(Number(req.query.h)) && Number(req.query.h) > 0; @@ -193,20 +208,26 @@ async function servePosImage(req, res, { isCimage = false } = {}) { if (isCimage) { if (hasW && !hasH) { targetW = Math.min(Math.round(Number(req.query.w)), 2000); - targetH = Math.min(Math.round((targetW * 4) / 3), 2000); + targetH = targetW; } else if (hasH && !hasW) { targetH = Math.min(Math.round(Number(req.query.h)), 2000); - targetW = Math.min(Math.round((targetH * 3) / 4), 2000); + targetW = targetH; } else if (hasW && hasH) { targetW = Math.min(Math.round(Number(req.query.w)), 2000); targetH = Math.min(Math.round(Number(req.query.h)), 2000); + } else if (hasSize) { + const s = Math.min(Math.round(Number(req.query.size)), 2000); + targetW = s; + targetH = s; } else { targetW = CIMAGE_DEFAULT_W; targetH = CIMAGE_DEFAULT_H; } } else { - const widthParam = hasW ? Number(req.query.w) : PIMAGE_DEFAULT_W; - const heightParam = hasH ? Number(req.query.h) : PIMAGE_DEFAULT_H; + const defaultW = hasSize ? Number(req.query.size) : PIMAGE_DEFAULT_W; + const defaultH = hasSize ? Number(req.query.size) : PIMAGE_DEFAULT_H; + const widthParam = hasW ? Number(req.query.w) : defaultW; + const heightParam = hasH ? Number(req.query.h) : defaultH; targetW = Math.min(Math.round(widthParam), 2000); targetH = Math.min(Math.round(heightParam), 2000); } @@ -217,7 +238,7 @@ async function servePosImage(req, res, { isCimage = false } = {}) { return res.status(404).json({ Message: `No image was found for path '${rawPath}'.` }); } - const etag = `"${image.fullHash}-${targetW}x${targetH}${isCimage ? '-c' : ''}"`; + const etag = `"${image.fullHash}-${targetW}x${targetH}${isCimage ? '-c-v5' : ''}"`; const ifNoneMatch = req.headers['if-none-match']; if (ifNoneMatch === etag || ifNoneMatch === image.fullHash || ifNoneMatch === `"${image.fullHash}"`) { res.setHeader('ETag', etag); @@ -226,22 +247,54 @@ async function servePosImage(req, res, { isCimage = false } = {}) { } const imageSharp = sharp(image.buffer); - const meta = await imageSharp.metadata(); - const resizeOptions = isCimage - ? { - fit: 'contain', - background: '#ffffff', - withoutEnlargement: true, + let pipeline; + if (isCimage) { + const meta = await imageSharp.metadata(); + const origW = meta.width || targetW; + const origH = meta.height || targetH; + const r = origW / origH; + + // Adaptive fill ratio: + // Vertical images (r < 1) get much less border (height fills up to ~94% of tile). + // Horizontal images (r > 1) get a bit more border (width fills down to ~65% of tile). + // Square images (r = 1) get ~75% fill (~12.5% border on all sides). + let fillRatio; + if (r < 1.0) { + const t = Math.max(0, Math.min(1, (1.0 - r) / 0.5)); + fillRatio = 0.75 + t * (0.94 - 0.75); + } else { + const t = Math.max(0, Math.min(1, (r - 1.0) / 0.8)); + fillRatio = 0.75 - t * (0.75 - 0.65); } - : { - fit: 'inside', - withoutEnlargement: true, - }; - let pipeline = imageSharp - .resize(targetW, targetH, resizeOptions) - .flatten({ background: '#ffffff' }); + const innerW = Math.max(1, Math.round(targetW * fillRatio)); + const innerH = Math.max(1, Math.round(targetH * fillRatio)); + + const intermediateBuffer = await imageSharp + .resize(innerW, innerH, { + fit: 'contain', + background: '#ffffff', + withoutEnlargement: true, + }) + .flatten({ background: '#ffffff' }) + .toBuffer(); + + pipeline = sharp(intermediateBuffer) + .resize(targetW, targetH, { + fit: 'contain', + background: '#ffffff', + withoutEnlargement: true, + }) + .flatten({ background: '#ffffff' }); + } else { + pipeline = imageSharp + .resize(targetW, targetH, { + fit: 'inside', + withoutEnlargement: true, + }) + .flatten({ background: '#ffffff' }); + } const accept = String(req.headers.accept || ''); const formatParam = typeof req.query.format === 'string' ? req.query.format.toLowerCase() : ''; @@ -253,7 +306,7 @@ async function servePosImage(req, res, { isCimage = false } = {}) { } else if (formatParam === 'webp' || (!formatParam && accept.includes('image/webp'))) { pipeline = pipeline.webp({ quality: 85 }); contentType = 'image/webp'; - } else if (formatParam === 'png' || (!formatParam && meta.hasAlpha)) { + } else if (formatParam === 'png') { pipeline = pipeline.png(); contentType = 'image/png'; } else {