diff --git a/image-scale/deno.json b/image-scale/deno.json new file mode 100644 index 0000000..e69ce4f --- /dev/null +++ b/image-scale/deno.json @@ -0,0 +1,13 @@ +{ + "nodeModulesDir": "auto", + "tasks": { + "dev": "deno run --watch main.ts", + "start": "deno run --allow-read --allow-write --allow-env --allow-ffi main.ts" + }, + "imports": { + "@std/assert": "jsr:@std/assert@1", + "@std/cli": "jsr:@std/cli@1", + "@std/path": "jsr:@std/path@1", + "sharp": "npm:sharp@^0.33" + } +} diff --git a/image-scale/deno.lock b/image-scale/deno.lock new file mode 100644 index 0000000..547a744 --- /dev/null +++ b/image-scale/deno.lock @@ -0,0 +1,240 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1": "1.0.13", + "jsr:@std/cli@1": "1.0.27", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/internal@^1.0.6": "1.0.12", + "jsr:@std/path@1": "1.1.4", + "npm:sharp@0.33": "0.33.5" + }, + "jsr": { + "@std/assert@1.0.13": { + "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", + "dependencies": [ + "jsr:@std/internal@^1.0.6" + ] + }, + "@std/cli@1.0.27": { + "integrity": "eba97edd0891871a7410e835dd94b3c260c709cca5983df2689c25a71fbe04de" + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + } + }, + "npm": { + "@emnapi/runtime@1.8.1": { + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dependencies": [ + "tslib" + ] + }, + "@img/sharp-darwin-arm64@0.33.5": { + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "optionalDependencies": [ + "@img/sharp-libvips-darwin-arm64" + ], + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@img/sharp-darwin-x64@0.33.5": { + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "optionalDependencies": [ + "@img/sharp-libvips-darwin-x64" + ], + "os": ["darwin"], + "cpu": ["x64"] + }, + "@img/sharp-libvips-darwin-arm64@1.0.4": { + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-darwin-x64@1.0.4": { + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@img/sharp-libvips-linux-arm64@1.0.4": { + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-linux-arm@1.0.5": { + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@img/sharp-libvips-linux-s390x@1.0.4": { + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@img/sharp-libvips-linux-x64@1.0.4": { + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-libvips-linuxmusl-arm64@1.0.4": { + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-linuxmusl-x64@1.0.4": { + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-linux-arm64@0.33.5": { + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-arm64" + ], + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-linux-arm@0.33.5": { + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-arm" + ], + "os": ["linux"], + "cpu": ["arm"] + }, + "@img/sharp-linux-s390x@0.33.5": { + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-s390x" + ], + "os": ["linux"], + "cpu": ["s390x"] + }, + "@img/sharp-linux-x64@0.33.5": { + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-x64" + ], + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-linuxmusl-arm64@0.33.5": { + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "optionalDependencies": [ + "@img/sharp-libvips-linuxmusl-arm64" + ], + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-linuxmusl-x64@0.33.5": { + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "optionalDependencies": [ + "@img/sharp-libvips-linuxmusl-x64" + ], + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-wasm32@0.33.5": { + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "dependencies": [ + "@emnapi/runtime" + ], + "cpu": ["wasm32"] + }, + "@img/sharp-win32-ia32@0.33.5": { + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@img/sharp-win32-x64@0.33.5": { + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "os": ["win32"], + "cpu": ["x64"] + }, + "color-convert@2.0.1": { + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": [ + "color-name" + ] + }, + "color-name@1.1.4": { + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "color-string@1.9.1": { + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": [ + "color-name", + "simple-swizzle" + ] + }, + "color@4.2.3": { + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dependencies": [ + "color-convert", + "color-string" + ] + }, + "detect-libc@2.1.2": { + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" + }, + "is-arrayish@0.3.4": { + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==" + }, + "semver@7.7.4": { + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "bin": true + }, + "sharp@0.33.5": { + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dependencies": [ + "color", + "detect-libc", + "semver" + ], + "optionalDependencies": [ + "@img/sharp-darwin-arm64", + "@img/sharp-darwin-x64", + "@img/sharp-libvips-darwin-arm64", + "@img/sharp-libvips-darwin-x64", + "@img/sharp-libvips-linux-arm", + "@img/sharp-libvips-linux-arm64", + "@img/sharp-libvips-linux-s390x", + "@img/sharp-libvips-linux-x64", + "@img/sharp-libvips-linuxmusl-arm64", + "@img/sharp-libvips-linuxmusl-x64", + "@img/sharp-linux-arm", + "@img/sharp-linux-arm64", + "@img/sharp-linux-s390x", + "@img/sharp-linux-x64", + "@img/sharp-linuxmusl-arm64", + "@img/sharp-linuxmusl-x64", + "@img/sharp-wasm32", + "@img/sharp-win32-ia32", + "@img/sharp-win32-x64" + ], + "scripts": true + }, + "simple-swizzle@0.2.4": { + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "dependencies": [ + "is-arrayish" + ] + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1", + "jsr:@std/cli@1", + "jsr:@std/path@1", + "npm:sharp@0.33" + ] + } +} diff --git a/image-scale/main.ts b/image-scale/main.ts new file mode 100644 index 0000000..62a2b37 --- /dev/null +++ b/image-scale/main.ts @@ -0,0 +1,117 @@ +import sharp from "sharp"; +import { parseArgs } from "@std/cli/parse-args"; +import { basename, extname, join } from "@std/path"; + +/** + * Resize an image so that its smallest dimension equals the specified minimum pixels. + * Output format is always JPG. + * + * @param inputPath - Path to the input image (PNG or JPG) + * @param outputPath - Path to the output JPG image + * @param minPixels - The minimum dimension (width or height) in pixels + */ +export async function scaleImage( + inputPath: string, + outputPath: string, + minPixels: number, +): Promise { + if (minPixels <= 0) { + throw new Error("minPixels must be a positive number"); + } + + const metadata = await sharp(inputPath).metadata(); + const originalWidth = metadata.width; + const originalHeight = metadata.height; + + if (!originalWidth || !originalHeight) { + throw new Error("Could not determine image dimensions"); + } + + // Calculate the scale factor based on the smaller dimension + const minDimension = Math.min(originalWidth, originalHeight); + const scaleFactor = minPixels / minDimension; + + const newWidth = Math.round(originalWidth * scaleFactor); + const newHeight = Math.round(originalHeight * scaleFactor); + + await sharp(inputPath) + .resize(newWidth, newHeight) + .jpeg({ quality: 90 }) + .toFile(outputPath); + + console.log( + `Resized: ${originalWidth}x${originalHeight} -> ${newWidth}x${newHeight} (min dimension: ${minPixels}px)`, + ); +} + +/** + * Generate output path for the scaled image. + * Changes the extension to .jpg + */ +export function getOutputPath(inputPath: string, outputDir?: string): string { + const base = basename(inputPath, extname(inputPath)); + const dir = outputDir || join(inputPath, ".."); + return join(dir, `${base}.jpg`); +} + +function printUsage() { + console.log(` +Usage: deno run --allow-read --allow-write main.ts [options] + +Arguments: + input-image Path to the input image (PNG or JPG) + +Options: + --min-pixels, -m Minimum dimension in pixels (default: 1200) + --output, -o Output directory or file path + --help, -h Show this help message + +Examples: + deno run --allow-read --allow-write main.ts input.png -m 1200 + deno run --allow-read --allow-write main.ts input.jpg -m 800 -o ./output + deno run --allow-read --allow-write main.ts input.png -m 1920 -o ./resized.jpg +`); +} + +if (import.meta.main) { + const flags = parseArgs(Deno.args, { + string: ["output", "o", "min-pixels", "m"], + boolean: ["help", "h"], + alias: { + "min-pixels": "m", + output: "o", + help: "h", + }, + default: { + "min-pixels": "1200", + }, + }); + + if (flags.help) { + printUsage(); + Deno.exit(0); + } + + const inputPath = flags._[0] as string | undefined; + + if (!inputPath) { + console.error("Error: Input image path is required"); + printUsage(); + Deno.exit(1); + } + + const minPixels = parseInt(flags["min-pixels"] as string, 10); + const outputPath = flags.output + ? flags.output.endsWith(".jpg") || flags.output.endsWith(".jpeg") + ? flags.output as string + : getOutputPath(inputPath, flags.output as string) + : getOutputPath(inputPath); + + try { + await scaleImage(inputPath, outputPath, minPixels); + console.log(`Output saved to: ${outputPath}`); + } catch (error) { + console.error("Error:", error instanceof Error ? error.message : error); + Deno.exit(1); + } +} diff --git a/image-scale/main_test.ts b/image-scale/main_test.ts new file mode 100644 index 0000000..131907f --- /dev/null +++ b/image-scale/main_test.ts @@ -0,0 +1,117 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { basename, join } from "@std/path"; +import { getOutputPath, scaleImage } from "./main.ts"; + +const testDir = await Deno.makeTempDir(); + +Deno.test("getOutputPath - changes extension to jpg", () => { + assertEquals( + getOutputPath("/path/to/image.png"), + "/path/to/image.jpg", + ); + assertEquals( + getOutputPath("/path/to/image.jpeg"), + "/path/to/image.jpg", + ); + assertEquals( + getOutputPath("/path/to/image.jpg"), + "/path/to/image.jpg", + ); +}); + +Deno.test("getOutputPath - with output directory", () => { + assertEquals( + getOutputPath("/path/to/image.png", "/output/dir"), + "/output/dir/image.jpg", + ); +}); + +Deno.test("scaleImage - rejects with invalid minPixels", async () => { + await assertRejects( + () => scaleImage("test.png", "output.jpg", 0), + Error, + "minPixels must be a positive number", + ); + + await assertRejects( + () => scaleImage("test.png", "output.jpg", -100), + Error, + "minPixels must be a positive number", + ); +}); + +Deno.test("scaleImage - processes PNG image", async () => { + // Create a simple test PNG image (100x50 pixels) + const inputPath = join(testDir, "test_input.png"); + const outputPath = join(testDir, "test_output.jpg"); + + // Create a test image using sharp + await Deno.writeFile( + inputPath, + await (await import("sharp")).default({ + create: { + width: 100, + height: 50, + channels: 3, + background: { r: 255, g: 0, b: 0 }, + }, + }) + .png() + .toBuffer(), + ); + + // Scale the image with min dimension of 200px + await scaleImage(inputPath, outputPath, 200); + + // Verify output file exists + const outputStat = await Deno.stat(outputPath); + assertEquals(outputStat.isFile, true); + + // Verify the output dimensions + const metadata = await (await import("sharp")).default(outputPath).metadata(); + // Original: 100x50, min dimension is 50 + // Scale factor: 200/50 = 4 + // New dimensions: 400x200 + assertEquals(metadata.height, 200); + assertEquals(metadata.width, 400); +}); + +Deno.test("scaleImage - processes JPG image", async () => { + const inputPath = join(testDir, "test_input2.jpg"); + const outputPath = join(testDir, "test_output2.jpg"); + + // Create a test image (80x120 pixels) + await Deno.writeFile( + inputPath, + await (await import("sharp")).default({ + create: { + width: 80, + height: 120, + channels: 3, + background: { r: 0, g: 255, b: 0 }, + }, + }) + .jpeg() + .toBuffer(), + ); + + // Scale the image with min dimension of 240px + await scaleImage(inputPath, outputPath, 240); + + // Verify output file exists + const outputStat = await Deno.stat(outputPath); + assertEquals(outputStat.isFile, true); + + // Verify the output dimensions + const metadata = await (await import("sharp")).default(outputPath).metadata(); + // Original: 80x120, min dimension is 80 + // Scale factor: 240/80 = 3 + // New dimensions: 240x360 + assertEquals(metadata.width, 240); + assertEquals(metadata.height, 360); +}); + +// Cleanup after all tests +addEventListener("beforeunload", async () => { + await Deno.remove(testDir, { recursive: true }); +});