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:
@@ -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.
|
||||
Reference in New Issue
Block a user