# Compression level for image conversion Date: 2026-07-30 ## Goal Let users specify a compression/quality level for image conversions, when the destination format supports it. Currently `src/converters/image.js` calls `sharp(...).toFormat(target)` with no options at all — every conversion uses sharp's built-in defaults, and there is no way for a user to trade file size against quality. ## Scope - Image → image conversions (jpeg/jpg, webp, avif, tiff, png, gif) via `src/converters/image.js`. - Image → PDF conversions via `src/converters/imageToPdf.js`. - Out of scope: document converters (`src/converters/document.js`), any non-image conversion family. ## Data model & value semantics Compression is per-file, per-selected-target-format. The frontend sends a `qualities` array parallel to the existing `targetFormats` array (one entry per uploaded file; entries may be `null`). | Target format | Parameter passed to sharp | Range | Sharp default | Sent when user hasn't touched the control? | |---|---|---|---|---| | jpeg / jpg | `quality` | 1-100 | 80 | yes, pre-filled with 80 | | webp | `quality` | 1-100 | 80 | yes, pre-filled with 80 | | avif | `quality` | 1-100 | 50 | yes, pre-filled with 50 | | tiff | `quality` | 1-100 | 80 | yes, pre-filled with 80 | | png | `compressionLevel` | 0-9 | 6 | yes, pre-filled with 6 | | gif | — (no relevant sharp option) | — | — | no control shown; always `null` | | pdf (image→pdf) | `quality` (drives a JPEG embed) | 1-100 | *no default — opt-in* | no; `null` unless the user explicitly enables it | For `pdf`: `quality: null` (the default, untouched state) preserves today's behavior exactly — `sharp(inputPath).png().toBuffer()` + `pdfDoc.embedPng(...)`. Any non-null `quality` switches that one job to `sharp(inputPath).jpeg({ quality }).toBuffer()` + `pdfDoc.embedJpg(...)`. This is a deliberate, opt-in behavior change: PDF output only becomes lossy if the user explicitly asks for a quality level. ### Database `conversion_jobs` gets one new nullable column: ```sql ALTER TABLE conversion_jobs ADD COLUMN quality SMALLINT UNSIGNED NULL; ``` The column stores the raw value in whichever unit applies to that job's `target_format` (0-100 for quality-based formats and pdf, 0-9 for png's `compressionLevel`, `NULL` for gif or an untouched pdf job). This project has no migration runner — `db/schema.sql` is the source of truth and is applied by hand (see `.env.local` / local MariaDB setup in `CLAUDE.md`). This ALTER must be run manually against the local dev DB after implementation, and against the o2switch DB at deploy time. ## Backend flow **`src/converters/registry.js`**: `convert` functions gain an optional third `options` parameter: `convert(inputPath, outputPath, options)`. `register()` / `resolve()` signatures are unchanged — only the shape of the stored `convert` function changes. **`src/converters/image.js`**: introduce a small pure helper that maps `(targetFormat, quality)` to the sharp options object: ```js function buildFormatOptions(targetFormat, quality) { if (quality == null) return {}; if (targetFormat === 'png') return { compressionLevel: quality }; if (targetFormat === 'gif') return {}; return { quality }; // jpeg/jpg, webp, avif, tiff } ``` Each registered `convert` becomes: ```js convert: async (inputPath, outputPath, { quality } = {}) => { await sharp(inputPath) .toFormat(sharpFormatName(targetFormat), buildFormatOptions(targetFormat, quality)) .toFile(outputPath); } ``` **`src/converters/imageToPdf.js`**: `convert(inputPath, outputPath, { quality } = {})`. - `quality == null`: unchanged — PNG buffer + `pdfDoc.embedPng(...)`. - `quality` set: `sharp(inputPath).jpeg({ quality }).toBuffer()` + `pdfDoc.embedJpg(...)`. Page dimensions still come from `sharp(...).metadata()` on the produced buffer. **`src/app.js` (`POST /api/jobs`)**: parse `qualities` the same way `targetFormats` is parsed today: ```js let qualities; try { qualities = JSON.parse(req.body.qualities ?? '[]'); } catch { return res.status(400).json({ error: 'qualities must be a JSON array' }); } ``` - If `req.body.qualities` is absent, this defaults to `[]`; treat a shorter/empty array as "all null" rather than a hard length-mismatch error, so older frontend builds that never send `qualities` keep working unmodified. - If `qualities` is provided non-empty, it must have one entry per uploaded file (same length check pattern as `targetFormats`). - Per-file validation happens inline, in the same per-file loop that already resolves the converter: given the file's resolved `targetFormat`, check the corresponding entry is `null` or an integer in the valid range (1-100 for quality formats and pdf, 0-9 for png, always `null`-only for gif). An invalid entry pushes a per-file error into `results` (mirroring the existing "Unsupported conversion" branch) rather than failing the whole batch. - The validated `quality` is included in the `createJob(pool, { ... })` call. **`src/jobs/jobRepository.js`**: add `quality` to: - the `INSERT` column list and params in `createJob` - the `SELECT` column lists in `getJobByUuid`, `findPendingJobs`, `findExpiredJobs` - `toCamelJob` **`src/worker.js`**: `entry.convert(inputFilePath, outputFilePath, { quality: job.quality })`. ## Frontend **`frontend/src/App.jsx`**: - Each `pendingFiles` item gains a `quality` field, set whenever `targetFormat` is initialized or changed via `updateTargetFormat` — pre-filled to the format's sharp default (see table above), or `null` for gif/pdf. - Next to the existing target-format `` for jpeg/jpg/webp/avif/tiff, with the current numeric value displayed alongside. - `` for png. - Nothing for gif. - For pdf: an opt-in control (e.g. a checkbox "Compresser en JPEG" that reveals a 1-100 slider when checked) — reflects that pdf's default must stay `null` until the user explicitly opts in. - Changing `targetFormat` resets `quality` to the new format's default/`null` — a slider value tuned for one format must not silently carry over to another. **`frontend/src/api.js`**: `uploadFiles` builds and appends a `qualities` array (`item.quality ?? null` per item), JSON-stringified, alongside the existing `targetFormats` append. ## Validation & edge cases - The frontend resets `quality` on format change, but the backend independently re-validates every `quality` value against the resolved target format — the frontend is not trusted as the sole guard. - Omitting `qualities` entirely (old client) behaves identically to sending an array of `null`s. - Out-of-range or wrong-type `quality` for one file produces a per-file error in the `POST /api/jobs` response, without blocking the other files in the same batch. ## Testing - `test/converters/image.test.js`: add cases asserting that `quality`/`compressionLevel` actually affects output — e.g. convert the same PNG fixture to jpg at quality 10 vs 95 and assert the low-quality output is smaller (sharp doesn't surface the applied quality in output metadata, so file-size comparison is the practical assertion). - `test/converters/imageToPdf.test.js`: add a case with `quality` set, asserting the produced file is a valid PDF and is smaller than the default (no-quality) PNG-embed output for the same source image. - `test/api/jobs.test.js`: extend to cover `qualities` parsing — valid array, mismatched length, out-of-range value, omitted field entirely. - `test/jobs/jobRepository.test.js`: extend the `createJob`/`getJobByUuid` round-trip test to include `quality`. - No frontend test framework exists in `frontend/` today (confirmed via `frontend/package.json` — no vitest/RTL/jest). The UI changes get a manual browser check only, consistent with how `targetFormat` selection is tested today.