Add design spec for per-format image compression level

Covers data model, backend threading through registry/image/imageToPdf/worker, frontend controls, validation, and testing for a new quality/compressionLevel option on image conversions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 22:26:00 +02:00
co-authored by Claude Sonnet 5
parent f8e46ae955
commit b826338a61
@@ -0,0 +1,115 @@
# 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 `<select>`, conditionally render:
- `<input type="range" min="1" max="100">` for jpeg/jpg/webp/avif/tiff, with the current numeric value displayed alongside.
- `<input type="range" min="0" max="9">` 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.