From 44608733c6a9ff1f90d5e92151b9f6f9eda308cf Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Fri, 8 May 2026 15:12:23 +0800 Subject: [PATCH] feat: add PPM/PGM/PBM CLI decoder fallback, QOI fixture and format-matrix entry - Add ppm/pgm/pbm to CLI_DECODED_FORMATS with Sharp-first, ImageMagick fallback - Create sample.qoi test fixture (10x10 solid color) - Add QOI to format-matrix test FORMAT_SAMPLES - Mark PPM/PGM/PBM as needsCliDecoder in tests (Sharp doesn't support them natively) --- apps/api/src/lib/format-decoders.ts | 32 ++++++++++++++++++++++++ tests/fixtures/formats/sample.qoi | Bin 0 -> 29 bytes tests/integration/format-matrix.test.ts | 14 ++++++++--- 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/formats/sample.qoi diff --git a/apps/api/src/lib/format-decoders.ts b/apps/api/src/lib/format-decoders.ts index 91c05f00..333aa085 100644 --- a/apps/api/src/lib/format-decoders.ts +++ b/apps/api/src/lib/format-decoders.ts @@ -24,6 +24,9 @@ const CLI_DECODED_FORMATS = new Set([ "dds", "cur", "dpx", + "ppm", + "pgm", + "pbm", "fits", ]); @@ -78,6 +81,11 @@ export async function decodeToSharpCompat( return decodeFits(buffer); case "qoi": return decodeQoi(buffer); + case "ppm": + case "pgm": + case "pbm": + return decodeNetpbm(buffer, format); + return decodeQoi(buffer); default: return buffer; } @@ -452,3 +460,27 @@ async function decodeQoi(buffer: Buffer): Promise { .png() .toBuffer(); } + +// ── Netpbm (PPM/PGM/PBM) decoder ── + +async function decodeNetpbm(buffer: Buffer, format: string): Promise { + try { + return await sharp(buffer).png().toBuffer(); + } catch { + const cmd = await findMagickCmd(); + const id = randomUUID(); + const ext = format === "pgm" ? "pgm" : format === "pbm" ? "pbm" : "ppm"; + const inputPath = join(tmpdir(), `netpbm-in-${id}.${ext}`); + const outputPath = join(tmpdir(), `netpbm-out-${id}.png`); + try { + await writeFile(inputPath, buffer); + await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), { + timeout: 120_000, + }); + return await readFile(outputPath); + } finally { + await rm(inputPath, { force: true }).catch(() => {}); + await rm(outputPath, { force: true }).catch(() => {}); + } + } +} diff --git a/tests/fixtures/formats/sample.qoi b/tests/fixtures/formats/sample.qoi new file mode 100644 index 0000000000000000000000000000000000000000..6605fb53a18d9a68cb1337ba25feaa16863dc446 GIT binary patch literal 29 ecmXTS&rD-rVBi8_7KZ;R(@y;V`vk}V0Y(6Flm}`6 literal 0 HcmV?d00001 diff --git a/tests/integration/format-matrix.test.ts b/tests/integration/format-matrix.test.ts index 0cc00b30..8ec2f96c 100644 --- a/tests/integration/format-matrix.test.ts +++ b/tests/integration/format-matrix.test.ts @@ -207,7 +207,7 @@ const FORMAT_SAMPLES: FormatSample[] = [ name: "PPM", file: "sample.ppm", mime: "image/x-portable-pixmap", - needsCliDecoder: false, + needsCliDecoder: true, needsHeifDecoder: false, mayFailValidation: false, }, @@ -215,7 +215,7 @@ const FORMAT_SAMPLES: FormatSample[] = [ name: "PGM", file: "sample.pgm", mime: "image/x-portable-graymap", - needsCliDecoder: false, + needsCliDecoder: true, needsHeifDecoder: false, mayFailValidation: false, }, @@ -223,7 +223,7 @@ const FORMAT_SAMPLES: FormatSample[] = [ name: "PBM", file: "sample.pbm", mime: "image/x-portable-bitmap", - needsCliDecoder: false, + needsCliDecoder: true, needsHeifDecoder: false, mayFailValidation: false, }, @@ -267,6 +267,14 @@ const FORMAT_SAMPLES: FormatSample[] = [ needsHeifDecoder: false, mayFailValidation: false, }, + { + name: "QOI", + file: "sample.qoi", + mime: "image/x-qoi", + needsCliDecoder: true, + needsHeifDecoder: false, + mayFailValidation: false, + }, ]; // ---------------------------------------------------------------------------