diff --git a/package.json b/package.json index 6ba29584..75fe13d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.18.148", + "version": "0.18.149", "type": "module", "scripts": { "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve", diff --git a/src/js/components/image-cropper.js b/src/js/components/image-cropper.js index f104bd21..4b6b5024 100644 --- a/src/js/components/image-cropper.js +++ b/src/js/components/image-cropper.js @@ -337,6 +337,8 @@ class ImageCropper extends Component { outputCanvas.width = outputWidth; outputCanvas.height = outputHeight; const outputCtx = outputCanvas.getContext("2d"); + outputCtx.imageSmoothingEnabled = true; + outputCtx.imageSmoothingQuality = "high"; outputCtx.fillStyle = "#ffffff"; outputCtx.fillRect(0, 0, outputWidth, outputHeight); outputCtx.drawImage( @@ -351,7 +353,7 @@ class ImageCropper extends Component { outputHeight, ); - return outputCanvas.toDataURL("image/jpeg", 0.9); + return outputCanvas.toDataURL("image/png"); } } diff --git a/src/js/imageCompressor.js b/src/js/imageCompressor.js index 0710b4f1..c4882033 100644 --- a/src/js/imageCompressor.js +++ b/src/js/imageCompressor.js @@ -27,15 +27,17 @@ export class ImageCompressor { return Math.round((base64.length * 3) / 4); } - drawImageToCanvas({ img, width, height, quality }) { + renderImageToCanvas({ img, width, height }) { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, width, height); ctx.drawImage(img, 0, 0, width, height); - return canvas.toDataURL("image/jpeg", quality); + return canvas; } dataUrlToBlob(dataUrl) { @@ -62,6 +64,10 @@ export class ImageCompressor { let bestWidth = 0; let bestHeight = 0; + let resizedCanvas = null; + let resizedWidth = 0; + let resizedHeight = 0; + while (maxQuality - minQuality > 1) { if (attempts >= 4) break; @@ -82,12 +88,17 @@ export class ImageCompressor { maxHeight: maxDimension, }); - const result = this.drawImageToCanvas({ - img, - width, - height, - quality: quality / 100, - }); + if ( + !resizedCanvas || + resizedWidth !== width || + resizedHeight !== height + ) { + resizedCanvas = this.renderImageToCanvas({ img, width, height }); + resizedWidth = width; + resizedHeight = height; + } + + const result = resizedCanvas.toDataURL("image/jpeg", quality / 100); if (this.estimateDataUrlSize(result) <= MAX_IMAGE_SIZE) { bestDataUrl = result; diff --git a/tests/unit/specs/components/image-cropper.test.js b/tests/unit/specs/components/image-cropper.test.js index 72c14d36..8e592fb3 100644 --- a/tests/unit/specs/components/image-cropper.test.js +++ b/tests/unit/specs/components/image-cropper.test.js @@ -149,8 +149,8 @@ describe("image-cropper", () => { const result = element.cropImage(); assert(result !== null, "cropImage should return a value"); assert( - result.startsWith("data:image/jpeg"), - "cropImage should return a JPEG data URL", + result.startsWith("data:image/png"), + "cropImage should return a lossless PNG data URL", ); }); diff --git a/tests/unit/specs/imageCompressor.test.js b/tests/unit/specs/imageCompressor.test.js index 1f1477dc..05e8f200 100644 --- a/tests/unit/specs/imageCompressor.test.js +++ b/tests/unit/specs/imageCompressor.test.js @@ -22,6 +22,7 @@ describe("imageCompressor", () => { const originalCreateElement = document.createElement; function installImageStubs(toDataURL = () => "data:image/jpeg;base64,") { + const calls = { contexts: [], drawnSizes: [], encodeCount: 0 }; globalThis.Image = class { /** @param {string} _ */ set src(_) { @@ -32,19 +33,31 @@ describe("imageCompressor", () => { }; document.createElement = function (tag) { if (tag === "canvas") { - return { + const canvas = { width: 0, height: 0, - getContext: () => ({ - fillStyle: "", - fillRect: () => {}, - drawImage: () => {}, - }), - toDataURL: () => toDataURL(), + getContext: () => { + const ctx = { + fillStyle: "", + imageSmoothingEnabled: false, + imageSmoothingQuality: "low", + fillRect: () => {}, + drawImage: (img, x, y, width, height) => + calls.drawnSizes.push({ width, height }), + }; + calls.contexts.push(ctx); + return ctx; + }, + toDataURL: () => { + calls.encodeCount++; + return toDataURL(); + }, }; + return canvas; } return originalCreateElement.call(document, tag); }; + return calls; } afterEach(() => { @@ -204,5 +217,30 @@ describe("imageCompressor", () => { assert(result.width > 0 && result.width <= 4000); assert(result.height > 0 && result.height <= 4000); }); + + it("resamples with high-quality smoothing", async () => { + const small = `data:image/jpeg;base64,${"A".repeat(16)}`; + const calls = installImageStubs(() => small); + + await compressImage("data:image/jpeg;base64,AAAA"); + assert(calls.contexts.length > 0, "expected a canvas context"); + for (const ctx of calls.contexts) { + assert.deepEqual(ctx.imageSmoothingEnabled, true); + assert.deepEqual(ctx.imageSmoothingQuality, "high"); + } + }); + + it("resamples once per output size, not once per quality probe", async () => { + const small = `data:image/jpeg;base64,${"A".repeat(16)}`; + const calls = installImageStubs(() => small); + + await compressImage("data:image/jpeg;base64,AAAA"); + // Every probe fits, so dimensions never shrink: one resample, many encodes. + assert.deepEqual(calls.drawnSizes, [{ width: 4000, height: 4000 }]); + assert( + calls.encodeCount > 1, + `expected multiple quality probes, got ${calls.encodeCount}`, + ); + }); }); });