diff --git a/packages/jimp/src/jimp.test.ts b/packages/jimp/src/jimp.test.ts index 54eabe1e..0da9bca6 100644 --- a/packages/jimp/src/jimp.test.ts +++ b/packages/jimp/src/jimp.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { Jimp } from "./index.js"; -import { getTestImagePath } from "@jimp/test-utils"; +import { getTestImagePath, makeTestImage } from "@jimp/test-utils"; describe("hasAlpha", () => { test("image with no alpha", async () => { @@ -12,4 +12,25 @@ describe("hasAlpha", () => { const image = await Jimp.read(getTestImagePath("dice.png")); expect(image.hasAlpha()).toBe(true); }); + + test("autocropRect is available on the public Jimp entrypoint", () => { + const image = Jimp.fromBitmap( + makeTestImage( + " ", + " ◆◆ ", + " ◆▫▫◆ ", + " ◆▫▫▫▫◆ ", + " ◆▫▫◆ ", + " ◆◆ ", + " " + ) + ); + + expect(image.autocropRect()).toEqual({ + x: 2, + y: 1, + w: 6, + h: 5, + }); + }); }); diff --git a/plugins/plugin-crop/README.md b/plugins/plugin-crop/README.md index 6f33021a..4e719879 100644 --- a/plugins/plugin-crop/README.md +++ b/plugins/plugin-crop/README.md @@ -7,3 +7,4 @@ - [crop](http://jimp-dev.github.io/jimp/api/jimp/classes/jimp#crop) - [autocrop](http://jimp-dev.github.io/jimp/api/jimp/classes/jimp#autocrop) +- [autocropRect](http://jimp-dev.github.io/jimp/api/jimp/classes/jimp#autocropRect) diff --git a/plugins/plugin-crop/src/autocrop-rect.test.ts b/plugins/plugin-crop/src/autocrop-rect.test.ts new file mode 100644 index 00000000..5fae6d96 --- /dev/null +++ b/plugins/plugin-crop/src/autocrop-rect.test.ts @@ -0,0 +1,80 @@ +import { expect, test, describe } from "vitest"; + +import { methods } from "./index.js"; +import { createJimp } from "@jimp/core"; +import { makeTestImage } from "@jimp/test-utils"; + +const jimp = createJimp({ plugins: [methods] }); + +describe("autocropRect", () => { + test("returns the rectangle autocrop would apply", () => { + const imgSrc = jimp.fromBitmap( + makeTestImage( + " ", + " ◆◆ ", + " ◆▫▫◆ ", + " ◆▫▫▫▫◆ ", + " ◆▫▫◆ ", + " ◆◆ ", + " " + ) + ); + + expect(imgSrc.autocropRect()).toEqual({ + x: 2, + y: 1, + w: 6, + h: 5, + }); + }); + + test("returns the full image when cropOnlyFrames prevents cropping", () => { + const imgSrc = jimp.fromBitmap( + makeTestImage( + "▥▥ ◆◆ ", + "▥▥ ◆▫▫◆ ", + "▥▥ ◆▫▫▫▫◆ ", + "▥▥ ◆▫▫◆ ", + "▥▥ ◆◆ ", + "▥▥▥▥▥▥▥▥▥▥", + "▥▥▥▥▥▥▥▥▥▥" + ) + ); + + expect(imgSrc.autocropRect()).toEqual({ + x: 0, + y: 0, + w: 10, + h: 7, + }); + }); + + test("can be passed directly to crop for the same result as autocrop", () => { + const imgSrc = jimp.fromBitmap( + makeTestImage( + "▥▥▥▥▥▥▥▥", + "▥▥▥▥▥▥▥▥", + "▥▥▥▥▥▥▥▥", + " ◆◆ ", + " ◆▫▫◆ ", + " ◆▫▫▫▫◆ ", + " ◆▫▫◆ ", + " ◆◆ ", + "▥▥▥▥▥▥▥▥", + "▥▥▥▥▥▥▥▥", + "▥▥▥▥▥▥▥▥" + ) + ); + const options = { + cropSymmetric: true, + cropOnlyFrames: false, + leaveBorder: 2, + } as const; + + const rect = imgSrc.autocropRect(options); + const croppedWithRect = imgSrc.clone().crop(rect); + const croppedWithAutocrop = imgSrc.clone().autocrop(options); + + expect(croppedWithRect.bitmap).toEqual(croppedWithAutocrop.bitmap); + }); +}); diff --git a/plugins/plugin-crop/src/index.ts b/plugins/plugin-crop/src/index.ts index e4d0dba7..3baad43c 100644 --- a/plugins/plugin-crop/src/index.ts +++ b/plugins/plugin-crop/src/index.ts @@ -39,6 +39,187 @@ export type AutocropComplexOptions = z.infer< >; export type AutocropOptions = number | AutocropComplexOptions; +function getAutocropRect( + image: I, + options: AutocropOptions = {} +): CropOptions { + const { + tolerance = 0.0002, + cropOnlyFrames = true, + cropSymmetric = false, + leaveBorder = 0, + ignoreSides: ignoreSidesArg, + } = typeof options === "number" + ? ({ tolerance: options } as AutocropComplexOptions) + : AutocropComplexOptionsSchema.parse(options); + const w = image.bitmap.width; + const h = image.bitmap.height; + const minPixelsPerSide = 1; // to avoid cropping completely the image, resulting in an invalid 0 sized image + + // i.e. north and south / east and west are cropped by the same value + const ignoreSides = { + north: false, + south: false, + east: false, + west: false, + ...ignoreSidesArg, + }; + + /** + * All borders must be of the same color as the top left pixel, to be cropped. + * It should be possible to crop borders each with a different color, + * but since there are many ways for corners to intersect, it would + * introduce unnecessary complexity to the algorithm. + */ + + // scan each side for same color borders + const rgba1 = intToRGBA(image.getPixelColor(0, 0)); + + let northPixelsToCrop = 0; + let eastPixelsToCrop = 0; + let southPixelsToCrop = 0; + let westPixelsToCrop = 0; + + // north side (scan rows from north to south) + if (!ignoreSides.north) { + north: for (let y = 0; y < h - minPixelsPerSide; y++) { + for (let x = 0; x < w; x++) { + const colorXY = image.getPixelColor(x, y); + const rgba2 = intToRGBA(colorXY); + + if (colorDiff(rgba1, rgba2) > tolerance) { + // this pixel is too distant from the first one: abort this side scan + break north; + } + } + + // this row contains all pixels with the same color: increment this side pixels to crop + northPixelsToCrop++; + } + } + + // west side (scan columns from west to east) + if (!ignoreSides.west) { + west: for (let x = 0; x < w - minPixelsPerSide; x++) { + for (let y = 0 + northPixelsToCrop; y < h; y++) { + const colorXY = image.getPixelColor(x, y); + const rgba2 = intToRGBA(colorXY); + + if (colorDiff(rgba1, rgba2) > tolerance) { + // this pixel is too distant from the first one: abort this side scan + break west; + } + } + + // this column contains all pixels with the same color: increment this side pixels to crop + westPixelsToCrop++; + } + } + + // south side (scan rows from south to north) + if (!ignoreSides.south) { + south: for ( + let y = h - 1; + y >= northPixelsToCrop + minPixelsPerSide; + y-- + ) { + for (let x = w - eastPixelsToCrop - 1; x >= 0; x--) { + const colorXY = image.getPixelColor(x, y); + const rgba2 = intToRGBA(colorXY); + + if (colorDiff(rgba1, rgba2) > tolerance) { + // this pixel is too distant from the first one: abort this side scan + break south; + } + } + + // this row contains all pixels with the same color: increment this side pixels to crop + southPixelsToCrop++; + } + } + + // east side (scan columns from east to west) + if (!ignoreSides.east) { + east: for ( + let x = w - 1; + x >= 0 + westPixelsToCrop + minPixelsPerSide; + x-- + ) { + for (let y = h - 1; y >= 0 + northPixelsToCrop; y--) { + const colorXY = image.getPixelColor(x, y); + const rgba2 = intToRGBA(colorXY); + + if (colorDiff(rgba1, rgba2) > tolerance) { + // this pixel is too distant from the first one: abort this side scan + break east; + } + } + + // this column contains all pixels with the same color: increment this side pixels to crop + eastPixelsToCrop++; + } + } + + // apply leaveBorder + westPixelsToCrop -= leaveBorder; + eastPixelsToCrop -= leaveBorder; + northPixelsToCrop -= leaveBorder; + southPixelsToCrop -= leaveBorder; + + if (cropSymmetric) { + const horizontal = Math.min(eastPixelsToCrop, westPixelsToCrop); + const vertical = Math.min(northPixelsToCrop, southPixelsToCrop); + westPixelsToCrop = horizontal; + eastPixelsToCrop = horizontal; + northPixelsToCrop = vertical; + southPixelsToCrop = vertical; + } + + // make sure that crops are >= 0 + westPixelsToCrop = westPixelsToCrop >= 0 ? westPixelsToCrop : 0; + eastPixelsToCrop = eastPixelsToCrop >= 0 ? eastPixelsToCrop : 0; + northPixelsToCrop = northPixelsToCrop >= 0 ? northPixelsToCrop : 0; + southPixelsToCrop = southPixelsToCrop >= 0 ? southPixelsToCrop : 0; + + // safety checks + const widthOfRemainingPixels = w - (westPixelsToCrop + eastPixelsToCrop); + const heightOfRemainingPixels = h - (southPixelsToCrop + northPixelsToCrop); + + let doCrop = false; + + if (cropOnlyFrames) { + // crop image if all sides should be cropped + doCrop = + eastPixelsToCrop !== 0 && + northPixelsToCrop !== 0 && + westPixelsToCrop !== 0 && + southPixelsToCrop !== 0; + } else { + // crop image if at least one side should be cropped + doCrop = + eastPixelsToCrop !== 0 || + northPixelsToCrop !== 0 || + westPixelsToCrop !== 0 || + southPixelsToCrop !== 0; + } + + if (!doCrop) { + return { + x: 0, + y: 0, + w, + h, + }; + } + + return { + x: westPixelsToCrop, + y: northPixelsToCrop, + w: widthOfRemainingPixels, + h: heightOfRemainingPixels, + }; +} + export const methods = { /** * Crops the image at a given point to a give size. @@ -84,6 +265,29 @@ export const methods = { return image; }, + /** + * Measure the crop rectangle that {@link autocrop} would apply. + * This is useful when you need the crop offsets for packing, alignment, + * or when you want to inspect the crop area before mutating the image. + * If no crop would be applied, this returns the current image bounds. + * + * @example + * ```ts + * import { Jimp } from "jimp"; + * + * const image = await Jimp.read("test/image.png"); + * const rect = image.autocropRect(); + * + * image.crop(rect); + * ``` + */ + autocropRect( + image: I, + options: AutocropOptions = {} + ) { + return getAutocropRect(image, options); + }, + /** * Autocrop same color borders from this image. * This function will attempt to crop out transparent pixels from the image. @@ -97,182 +301,15 @@ export const methods = { * ``` */ autocrop(image: I, options: AutocropOptions = {}) { - const { - tolerance = 0.0002, - cropOnlyFrames = true, - cropSymmetric = false, - leaveBorder = 0, - ignoreSides: ignoreSidesArg, - } = typeof options === "number" - ? ({ tolerance: options } as AutocropComplexOptions) - : AutocropComplexOptionsSchema.parse(options); - const w = image.bitmap.width; - const h = image.bitmap.height; - const minPixelsPerSide = 1; // to avoid cropping completely the image, resulting in an invalid 0 sized image - - // i.e. north and south / east and west are cropped by the same value - const ignoreSides = { - north: false, - south: false, - east: false, - west: false, - ...ignoreSidesArg, - }; - - /** - * All borders must be of the same color as the top left pixel, to be cropped. - * It should be possible to crop borders each with a different color, - * but since there are many ways for corners to intersect, it would - * introduce unnecessary complexity to the algorithm. - */ - - // scan each side for same color borders - let colorTarget = image.getPixelColor(0, 0); // top left pixel color is the target color - const rgba1 = intToRGBA(colorTarget); - - // for north and east sides - let northPixelsToCrop = 0; - let eastPixelsToCrop = 0; - let southPixelsToCrop = 0; - let westPixelsToCrop = 0; - - // north side (scan rows from north to south) - colorTarget = image.getPixelColor(0, 0); - if (!ignoreSides.north) { - north: for (let y = 0; y < h - minPixelsPerSide; y++) { - for (let x = 0; x < w; x++) { - const colorXY = image.getPixelColor(x, y); - const rgba2 = intToRGBA(colorXY); - - if (colorDiff(rgba1, rgba2) > tolerance) { - // this pixel is too distant from the first one: abort this side scan - break north; - } - } - - // this row contains all pixels with the same color: increment this side pixels to crop - northPixelsToCrop++; - } - } - - // west side (scan columns from west to east) - colorTarget = image.getPixelColor(w, 0); - if (!ignoreSides.west) { - west: for (let x = 0; x < w - minPixelsPerSide; x++) { - for (let y = 0 + northPixelsToCrop; y < h; y++) { - const colorXY = image.getPixelColor(x, y); - const rgba2 = intToRGBA(colorXY); - - if (colorDiff(rgba1, rgba2) > tolerance) { - // this pixel is too distant from the first one: abort this side scan - break west; - } - } - - // this column contains all pixels with the same color: increment this side pixels to crop - westPixelsToCrop++; - } - } - - // south side (scan rows from south to north) - colorTarget = image.getPixelColor(0, h); - - if (!ignoreSides.south) { - south: for ( - let y = h - 1; - y >= northPixelsToCrop + minPixelsPerSide; - y-- - ) { - for (let x = w - eastPixelsToCrop - 1; x >= 0; x--) { - const colorXY = image.getPixelColor(x, y); - const rgba2 = intToRGBA(colorXY); - - if (colorDiff(rgba1, rgba2) > tolerance) { - // this pixel is too distant from the first one: abort this side scan - break south; - } - } - - // this row contains all pixels with the same color: increment this side pixels to crop - southPixelsToCrop++; - } - } - - // east side (scan columns from east to west) - colorTarget = image.getPixelColor(w, h); - if (!ignoreSides.east) { - east: for ( - let x = w - 1; - x >= 0 + westPixelsToCrop + minPixelsPerSide; - x-- - ) { - for (let y = h - 1; y >= 0 + northPixelsToCrop; y--) { - const colorXY = image.getPixelColor(x, y); - const rgba2 = intToRGBA(colorXY); - - if (colorDiff(rgba1, rgba2) > tolerance) { - // this pixel is too distant from the first one: abort this side scan - break east; - } - } - - // this column contains all pixels with the same color: increment this side pixels to crop - eastPixelsToCrop++; - } - } - - // decide if a crop is needed - let doCrop = false; - - // apply leaveBorder - westPixelsToCrop -= leaveBorder; - eastPixelsToCrop -= leaveBorder; - northPixelsToCrop -= leaveBorder; - southPixelsToCrop -= leaveBorder; - - if (cropSymmetric) { - const horizontal = Math.min(eastPixelsToCrop, westPixelsToCrop); - const vertical = Math.min(northPixelsToCrop, southPixelsToCrop); - westPixelsToCrop = horizontal; - eastPixelsToCrop = horizontal; - northPixelsToCrop = vertical; - southPixelsToCrop = vertical; - } - - // make sure that crops are >= 0 - westPixelsToCrop = westPixelsToCrop >= 0 ? westPixelsToCrop : 0; - eastPixelsToCrop = eastPixelsToCrop >= 0 ? eastPixelsToCrop : 0; - northPixelsToCrop = northPixelsToCrop >= 0 ? northPixelsToCrop : 0; - southPixelsToCrop = southPixelsToCrop >= 0 ? southPixelsToCrop : 0; - - // safety checks - const widthOfRemainingPixels = w - (westPixelsToCrop + eastPixelsToCrop); - const heightOfRemainingPixels = h - (southPixelsToCrop + northPixelsToCrop); - - if (cropOnlyFrames) { - // crop image if all sides should be cropped - doCrop = - eastPixelsToCrop !== 0 && - northPixelsToCrop !== 0 && - westPixelsToCrop !== 0 && - southPixelsToCrop !== 0; - } else { - // crop image if at least one side should be cropped - doCrop = - eastPixelsToCrop !== 0 || - northPixelsToCrop !== 0 || - westPixelsToCrop !== 0 || - southPixelsToCrop !== 0; - } - - if (doCrop) { - // do the real crop - this.crop(image, { - x: westPixelsToCrop, - y: northPixelsToCrop, - w: widthOfRemainingPixels, - h: heightOfRemainingPixels, - }); + const crop = this.autocropRect(image, options); + + if ( + crop.x !== 0 || + crop.y !== 0 || + crop.w !== image.bitmap.width || + crop.h !== image.bitmap.height + ) { + this.crop(image, crop); } return image; diff --git a/plugins/plugin-print/package.json b/plugins/plugin-print/package.json index 75a17df4..c7dc1900 100644 --- a/plugins/plugin-print/package.json +++ b/plugins/plugin-print/package.json @@ -9,7 +9,7 @@ "scripts": { "lint": "eslint .", "test": "vitest", - "build": "tshy && cp -r fonts dist", + "build": "tshy && node scripts/copy-fonts.mjs", "dev": "tshy --watch", "clean": "rm -rf node_modules .tshy .tshy-build dist .turbo" }, diff --git a/plugins/plugin-print/scripts/copy-fonts.mjs b/plugins/plugin-print/scripts/copy-fonts.mjs new file mode 100644 index 00000000..d5cacb1b --- /dev/null +++ b/plugins/plugin-print/scripts/copy-fonts.mjs @@ -0,0 +1,12 @@ +import { cp } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const source = join(packageRoot, "fonts"); +const destination = join(packageRoot, "dist", "fonts"); + +await cp(source, destination, { + force: true, + recursive: true, +});