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>
7.7 KiB
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:
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:
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:
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(...).qualityset:sharp(inputPath).jpeg({ quality }).toBuffer()+pdfDoc.embedJpg(...). Page dimensions still come fromsharp(...).metadata()on the produced buffer.
src/app.js (POST /api/jobs): parse qualities the same way targetFormats is parsed today:
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.qualitiesis 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 sendqualitieskeep working unmodified. - If
qualitiesis provided non-empty, it must have one entry per uploaded file (same length check pattern astargetFormats). - 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 isnullor an integer in the valid range (1-100 for quality formats and pdf, 0-9 for png, alwaysnull-only for gif). An invalid entry pushes a per-file error intoresults(mirroring the existing "Unsupported conversion" branch) rather than failing the whole batch. - The validated
qualityis included in thecreateJob(pool, { ... })call.
src/jobs/jobRepository.js: add quality to:
- the
INSERTcolumn list and params increateJob - the
SELECTcolumn lists ingetJobByUuid,findPendingJobs,findExpiredJobs toCamelJob
src/worker.js: entry.convert(inputFilePath, outputFilePath, { quality: job.quality }).
Frontend
frontend/src/App.jsx:
- Each
pendingFilesitem gains aqualityfield, set whenevertargetFormatis initialized or changed viaupdateTargetFormat— pre-filled to the format's sharp default (see table above), ornullfor 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
nulluntil the user explicitly opts in.
- Changing
targetFormatresetsqualityto 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
qualityon format change, but the backend independently re-validates everyqualityvalue against the resolved target format — the frontend is not trusted as the sole guard. - Omitting
qualitiesentirely (old client) behaves identically to sending an array ofnulls. - Out-of-range or wrong-type
qualityfor one file produces a per-file error in thePOST /api/jobsresponse, without blocking the other files in the same batch.
Testing
test/converters/image.test.js: add cases asserting thatquality/compressionLevelactually 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 withqualityset, 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 coverqualitiesparsing — valid array, mismatched length, out-of-range value, omitted field entirely.test/jobs/jobRepository.test.js: extend thecreateJob/getJobByUuidround-trip test to includequality.- No frontend test framework exists in
frontend/today (confirmed viafrontend/package.json— no vitest/RTL/jest). The UI changes get a manual browser check only, consistent with howtargetFormatselection is tested today.