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 0000000..1708726 Binary files /dev/null and b/scratch/curl-pimage.jpg differ diff --git a/src/v1/routes/pimage.js b/src/v1/routes/pimage.js index 1c7aaff..59d68d0 100644 --- a/src/v1/routes/pimage.js +++ b/src/v1/routes/pimage.js @@ -3,7 +3,7 @@ import * as db from '../../db/posterminal.js'; const PIMAGE_DEFAULT_W = 200; const PIMAGE_DEFAULT_H = 200; -const CIMAGE_DEFAULT_W = 150; +const CIMAGE_DEFAULT_W = 200; const CIMAGE_DEFAULT_H = 200; /** @@ -24,6 +24,13 @@ const CIMAGE_DEFAULT_H = 200; * description: SHA-256 hash of the product image * example: "71de4085ce2b1a53317832421009d844eec0c36a3ec071a31661cc2a212e15e6" * - in: query + * name: size + * required: false + * schema: + * type: integer + * default: 200 + * description: Max dimension (width & height) + * - in: query * name: w * required: false * schema: @@ -99,7 +106,7 @@ export async function handlePimage(req, res) { * tags: [POS] * summary: POS category image delivery * description: | - * 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). * x-markdown: pos/cimage.md * parameters: * - in: query @@ -110,11 +117,18 @@ export async function handlePimage(req, res) { * description: SHA-256 hash of the category image * example: "71de4085ce2b1a53317832421009d844eec0c36a3ec071a31661cc2a212e15e6" * - in: query + * name: size + * required: false + * schema: + * type: integer + * default: 200 + * description: Max dimension (width & height) + * - in: query * name: w * required: false * schema: * type: integer - * default: 150 + * default: 200 * description: Max width * - in: query * name: h @@ -184,6 +198,7 @@ async function servePosImage(req, res, { isCimage = false } = {}) { return res.status(400).json({ Message: "Missing required query parameter 'path'." }); } + const hasSize = req.query.size !== undefined && Number.isFinite(Number(req.query.size)) && Number(req.query.size) > 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 {