Drops HDR from scope: the installed sharp/libvips build has it fully disabled and no viable library exists for either direction.
12 KiB
HEIC/HEIF/ICO image format support
Date: 2026-07-31
Goal
Extend the image converter with three new formats: ICO (bidirectional, full support) and HEIC/HEIF (source-only — decode only). A fourth requested format, HDR (Radiance .hdr), is explicitly dropped: the installed sharp/libvips build has it fully disabled (sharp.format.rad reports input/output all false), and the only npm package found (parse-hdr) is decode-only, unmaintained since 2016, and built for WebGL textures rather than general conversion.
Why HEIC/HEIF are source-only
The installed sharp build's heif codec only supports the .avif alias (already supported today); encoding true .heic/.heif (HEVC) requires an x265 encoder that isn't in prebuilt sharp/libvips builds for licensing reasons. No pure-JS/WASM package fills this gap either — heic-convert (built on heic-decode → libheif-js, a WASM build of libheif) only decodes HEIC/HEIF to JPEG/PNG, with no encode path. So HEIC/HEIF can only be a source format: decode into a buffer, then feed that buffer through the existing sharp pipeline.
Scope
- New source formats:
heic,heif→ every existing image target (jpg, jpeg, png, webp, gif, tiff, avif) plus the newicotarget. Nothing registersheic/heifas a target —listTargetFormats(and therefore the frontend's dropdown) reflects only what's registered, so no target-side special-casing is needed. - New bidirectional format:
ico↔ every existing image format. - Out of scope: HDR, document converters, any non-image conversion family.
New dependencies
icojs— pure JS, no native bindings. Both decodes (decodeIco(buffer, 'image/png')→ array of embedded PNG buffers) and encodes (encodeIco([{ buffer }, ...])→ ICO buffer from PNG buffers). Safe for o2switch (no compile step).heic-convert— pure JS wrapper aroundlibheif-js(WASM build of libheif, no native compile step).convert({ buffer, format: 'PNG' })decodes the main image in a HEIC/HEIF file to a PNG buffer. Safe for o2switch for the same reasonsharp/puppeteeralready are — the binary is prebuilt/shipped, not compiled locally.
ICO size selection
ICO encoding needs a target resolution; there's no natural single "quality" scalar the way jpeg/png/pdf have. The frontend offers a dropdown of 16, 32, 48, 256, 512 px (default 256), single choice — not a multi-size bundle.
This value doesn't reuse the existing generic quality column (which stays strictly about quality/compression, matching its use for jpeg/webp/avif/tiff quality and png compression level). Instead, ConversionJob gets a new nullable column:
iconSize Int? @map("icon_size") @db.UnsignedSmallInt
Per the project's documented shadow-db-free migration process (CLAUDE.md), generate this with:
DATABASE_URL=$(node scripts/printDatabaseUrl.js) npx prisma migrate diff \
--from-schema-datasource prisma/schema.prisma --to-schema-datamodel prisma/schema.prisma --script
against the schema with iconSize added, review the output SQL, then hand-create the migration folder and prisma migrate resolve --applied.
Allowed values: 16, 32, 48, 256, 512. null/omitted defaults to 256 at conversion time. Any other value is a validation error, mirrored the same way an invalid quality is today.
Backend flow
src/converters/ico.js (new):
import fs from 'node:fs/promises';
import sharp from 'sharp';
import { decodeIco, encodeIco } from 'icojs';
import { register } from './registry.js';
import { sharpFormatName, buildFormatOptions } from './image.js';
const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif'];
export const DEFAULT_ICON_SIZE = 256;
export async function encodeIcoFromInput(input, size = DEFAULT_ICON_SIZE) {
const pngBuffer = await sharp(input).resize(size, size, { fit: 'contain' }).png().toBuffer();
return Buffer.from(await encodeIco([{ buffer: pngBuffer }]));
}
async function decodeIcoLargestImage(inputPath) {
const buffer = await fs.readFile(inputPath);
const images = await decodeIco(buffer, 'image/png');
const largest = images.reduce((a, b) => (a.width * a.height >= b.width * b.height ? a : b));
return Buffer.from(largest.buffer);
}
export function registerIcoConverter() {
for (const format of IMAGE_FORMATS) {
register({
family: 'image',
sourceFormat: 'ico',
targetFormat: format,
convert: async (inputPath, outputPath, { quality } = {}) => {
const pngBuffer = await decodeIcoLargestImage(inputPath);
await sharp(pngBuffer)
.toFormat(sharpFormatName(format), buildFormatOptions(format, quality))
.toFile(outputPath);
},
});
register({
family: 'image',
sourceFormat: format,
targetFormat: 'ico',
convert: async (inputPath, outputPath, { iconSize } = {}) => {
const icoBuffer = await encodeIcoFromInput(inputPath, iconSize ?? DEFAULT_ICON_SIZE);
await fs.writeFile(outputPath, icoBuffer);
},
});
}
}
(sharpFormatName/buildFormatOptions are exported from image.js rather than duplicated.)
src/converters/heic.js (new):
import fs from 'node:fs/promises';
import sharp from 'sharp';
import convert from 'heic-convert';
import { register } from './registry.js';
import { sharpFormatName, buildFormatOptions } from './image.js';
import { encodeIcoFromInput, DEFAULT_ICON_SIZE } from './ico.js';
const IMAGE_FORMATS = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'tiff', 'avif'];
const HEIC_SOURCE_FORMATS = ['heic', 'heif'];
async function decodeToPng(inputPath) {
const buffer = await fs.readFile(inputPath);
return convert({ buffer, format: 'PNG' });
}
export function registerHeicConverter() {
for (const sourceFormat of HEIC_SOURCE_FORMATS) {
for (const targetFormat of IMAGE_FORMATS) {
register({
family: 'image',
sourceFormat,
targetFormat,
convert: async (inputPath, outputPath, { quality } = {}) => {
const pngBuffer = await decodeToPng(inputPath);
await sharp(pngBuffer)
.toFormat(sharpFormatName(targetFormat), buildFormatOptions(targetFormat, quality))
.toFile(outputPath);
},
});
}
register({
family: 'image',
sourceFormat,
targetFormat: 'ico',
convert: async (inputPath, outputPath, { iconSize } = {}) => {
const pngBuffer = await decodeToPng(inputPath);
const icoBuffer = await encodeIcoFromInput(pngBuffer, iconSize ?? DEFAULT_ICON_SIZE);
await fs.writeFile(outputPath, icoBuffer);
},
});
}
}
src/converters/image.js: export sharpFormatName and buildFormatOptions (currently only buildFormatOptions is exported; sharpFormatName is private) so ico.js/heic.js can reuse them instead of redefining.
src/app.js / src/worker.js: both currently call registerImageConverters(), registerImageToPdfConverter(), registerDocumentConverters() independently at startup. Add registerIcoConverter() and registerHeicConverter() to both call sites.
src/mime.js:
OUTPUT_MIME_TYPES.ico = 'image/x-icon'— matches whatfile-typeitself reports for real ICO content (verified against the installedfile-typesource), keeping input-detection and output-mime symmetric with every other existing entry.normalizeFormat: addheif → heicalongside the existingjpg → jpeg. Verified against the installedfile-typev22 source: its HEIF-family detector always returnsext: 'heic'for any brand in that family (mif1,msf1,heic,heix,hevc,hevx) — themimefield differs (image/heifvsimage/heic) butextnever does. Without this alias, uploading a file namedphoto.heifwould havesourceFormat = 'heif'(from the filename) butdetectInputMimewould reportext: 'heic', failing theresolveInputFormatcontent-matches-extension check.
src/app.js (POST /api/jobs): parse a new iconSizes array the same way qualities is parsed today (JSON array, one entry per file, empty/omitted array = all null). Add isValidIconSize(targetFormat, iconSize):
const VALID_ICON_SIZES = [16, 32, 48, 256, 512];
function isValidIconSize(targetFormat, iconSize) {
if (iconSize === null || iconSize === undefined) return true;
if (targetFormat !== 'ico') return false; // only meaningful for ico targets
return VALID_ICON_SIZES.includes(iconSize);
}
Also extend isValidQuality to reject ico (no quality knob), mirroring the existing gif case:
if (targetFormat === 'gif' || targetFormat === 'ico') return false;
Both quality and iconSize get passed to createJob.
src/jobs/jobRepository.js: add iconSize to the jobSelect projection and to createJob's data object (iconSize: job.iconSize ?? null).
src/worker.js: entry.convert(inputFilePath, outputFilePath, { quality: job.quality, iconSize: job.iconSize }).
Frontend
frontend/src/App.jsx:
- Each
pendingFilesitem gains aniconSizefield, defaulted to256whentargetFormatbecomes'ico'(viaupdateTargetFormat), reset tonullfor every other format. - New conditional control, alongside the existing png/pdf ones:
{item.targetFormat === 'ico' && ( <label> Taille de l'icône <select value={item.iconSize ?? 256} onChange={(event) => updateIconSize(index, Number(event.target.value))}> {[16, 32, 48, 256, 512].map((size) => ( <option key={size} value={size}>{size}px</option> ))} </select> </label> )} - New
updateIconSizesetter, mirroringupdateQuality.
frontend/src/api.js: uploadFiles builds and appends an iconSizes array (item.iconSize ?? null per item), JSON-stringified, alongside targetFormats/qualities.
Validation & edge cases
- Backend independently re-validates
iconSizeagainst the resolvedtargetFormat— never trusts the frontend default. - Omitting
iconSizesentirely (old client) behaves like an array ofnulls; everyicoconversion then falls back to the 256px default inside the converter itself. - An
iconSizeprovided for a non-icotarget, or a value outside the fixed set, is a per-file error in thePOST /api/jobsresponse (same pattern as invalidquality/unsupported conversion pairs today) — it does not fail the rest of the batch. ico → X: if the source.icocontains multiple embedded resolutions, the largest is used (matches "give me the best available image" as the least surprising default; there's no size-selection concern on the decode side, since the destination format has no notion of embedded multi-resolution).
Testing
test/converters/ico.test.js: round-trip test (png → ico → png, or similar) confirming pixel dimensions match the requestediconSize; a decode test using a multi-resolution ICO (produced byicojs.encodeIcoinbeforeAllfrom several sharp-resized PNGs) confirming the largest embedded image is selected.test/converters/heic.test.js: decode tests for both.heicand.heiffixtures → jpg/png/ico. Requires committing small real sample files undertest/fixtures/(e.g.test/fixtures/sample.heic,test/fixtures/sample.heif) since nothing in the toolchain can encode HEIC/HEIF to generate these programmatically.test/mime.test.js(or wherevernormalizeFormat/resolveInputFormatis currently tested): add a case asserting a file named*.heifwith real HEIF content passesresolveInputFormat's validity check.test/api/jobs.test.js: extend to covericonSizesparsing (valid array, value on non-ico target, out-of-range value, omitted field).test/jobs/jobRepository.test.js: extend thecreateJob/getJobByUuidround-trip test to includeiconSize.- Frontend: manual browser check only (no frontend test framework exists today, consistent with how
quality/targetFormatcontrols are verified).