pixeli 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -92,6 +92,7 @@ The following options and flags are shared for all of the subcommands under the
92
92
  | `-r`, `--recursive` | `false` | Include **images in all subdirectories** of the specified directory recursively. |
93
93
  | `--sh`, `--shuffle` | `false` | **Randomize the order** of images before merging. Useful for creating visually varied grids or collages. |
94
94
  | `-g`, `--gap <px>` | `50` | **Spacing (in pixels) between images** in the layout. Applies to both horizontal and vertical gaps. |
95
+ | `--cr`, `--corner-radius <px>` | `0` | How much to **round the corners** of each image in pixels. |
95
96
  | `--bg`, `--canvas-color <hex>` | `#ffffff` | Sets the **background color of the canvas**. Accepts HEX values (e.g., `#000000` for black). |
96
97
  | `-o`, `--output <file>` | `./pixeli.png` | Path for the **merged output image**. The format is inferred from the file extension (`.png`, `.jpg`, `.webp`, etc.). |
97
98
 
@@ -126,3 +127,6 @@ The masonry mode preserves each image’s natural shape, creating an organic bri
126
127
 
127
128
  ## License
128
129
  This project is licensed under the [MIT License](./LICENSE).
130
+
131
+ ## Other
132
+ This project was submitted to [Hack Club](https://hackclub.com/), a group consisting of over 100,000 teen hackers from around the world who work on cool projects and get to participate in awesome programs like [Midnight](https://midnight.hackclub.com).
package/bin/pixeli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { Command } from 'commander';
4
- import mergeCommand from '../commands/merge.js';
5
- import { handleError } from '../lib/helpers/utils.js';
4
+ import mergeCommand from '../commands/merge/index.js';
5
+ import { configureCommandErrors, handleError } from '../lib/helpers/utils.js';
6
6
 
7
7
  const program = new Command();
8
8
 
@@ -15,6 +15,9 @@ program
15
15
  // Add subcommands
16
16
  program.addCommand(mergeCommand);
17
17
 
18
+ // Configure errors for all subcommands
19
+ configureCommandErrors(program);
20
+
18
21
  // Parse arguments
19
22
  try {
20
23
  program.parse();
@@ -1,18 +1,17 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import {
4
- addSharedOptions,
5
4
  cliConfirm,
6
5
  displayInfoMessage,
7
6
  displaySuccessMessage,
8
7
  displayWarningMessage,
9
- getValidatedParams,
10
8
  handleError,
11
9
  writeImage,
12
- } from '../lib/helpers/utils.js';
13
- import { validateGridOptions } from '../lib/helpers/validations.js';
14
- import { loadImages } from '../lib/helpers/loadImages.js';
15
- import { gridMerge } from '../lib/merges/grid-merge/index.js';
10
+ } from '../../lib/helpers/utils.js';
11
+ import { addSharedOptions, getValidatedParams } from './helpers/utils.js';
12
+ import { validateGridOptions } from './helpers/validations.js';
13
+ import { loadImages } from '../../lib/helpers/loadImages.js';
14
+ import { gridMerge } from '../../lib/merges/grid-merge/index.js';
16
15
 
17
16
  const gridCommand = new Command('grid');
18
17
 
@@ -31,7 +30,7 @@ gridCommand
31
30
  const main = async (files, opts) => {
32
31
  // Collect and validate parameters
33
32
  try {
34
- const validatedParams = getValidatedParams(files, opts, validateGridOptions);
33
+ const validatedParams = await getValidatedParams(files, opts, validateGridOptions);
35
34
 
36
35
  // Load images, create grid, and write grid on disk
37
36
  await generateAndSaveGrid(validatedParams);
@@ -0,0 +1,20 @@
1
+ import { validateSharedOptions } from './validations.js';
2
+
3
+ export const addSharedOptions = (cmd) => {
4
+ return cmd
5
+ .argument('[files...]', 'Image filepaths to merge (use --dir for directories)')
6
+ .option('-d, --dir <path>', 'Directory of images to merge')
7
+ .option('-r, --recursive', 'Recursively include subdirectories', false)
8
+ .option('--sh, --shuffle', 'Shuffle up images to randomize order in the grid', false)
9
+ .option('--cr, --corner-radius <px>', 'How much to round the corners of each image', 0)
10
+ .option('-g, --gap <px>', 'Gap between images', 50)
11
+ .option('--bg, --canvas-color <hex|transparent>', 'Background color for canvas', '#ffffff')
12
+ .option('-o, --output <file>', 'Output file path', './pixeli.png');
13
+ };
14
+
15
+ export const getValidatedParams = async (files, opts, validationFunc) => {
16
+ const params = { files, ...opts };
17
+ const sharedOptions = await validateSharedOptions(params);
18
+ const commandOptions = validationFunc(sharedOptions, params);
19
+ return { ...sharedOptions, ...commandOptions };
20
+ };
@@ -1,24 +1,46 @@
1
+ import fs from 'node:fs/promises';
2
+
1
3
  import {
2
4
  displayWarningMessage,
5
+ handleError,
3
6
  isSupportedOutputImage,
4
7
  isValidHexadecimal,
5
8
  parseAspectRatio,
6
9
  SUPPORTED_OUTPUT_FORMATS,
7
- } from './utils.js';
10
+ } from '../../../lib/helpers/utils.js';
8
11
 
9
- export const validateSharedOptions = (sharedOptions) => {
12
+ export const validateSharedOptions = async (sharedOptions) => {
10
13
  // Extract params
11
- const { files, dir, recursive, shuffle, gap, canvasColor, output } = sharedOptions;
14
+ const { files, dir, recursive, shuffle, cornerRadius, gap, canvasColor, output } = sharedOptions;
12
15
 
13
16
  // Conduct validations
14
17
  if ((!files || !files.length) && !dir) {
15
18
  throw new Error('You must specify either [files...] or --dir.');
16
19
  }
17
20
 
21
+ // Ensure dir is a valid dir path
22
+ if (dir.length) {
23
+ let stats;
24
+
25
+ try {
26
+ stats = await fs.stat(dir);
27
+ } catch (e) {
28
+ throw new Error('Path does not exist.');
29
+ }
30
+
31
+ if (!stats.isDirectory()) {
32
+ throw new Error('Path is not a directory.');
33
+ }
34
+ }
35
+
18
36
  if (isNaN(gap) || !Number.isInteger(Number(gap)) || gap < 0) {
19
37
  throw new Error('--gap must be a positive integer.');
20
38
  }
21
39
 
40
+ if (isNaN(cornerRadius) || !Number.isInteger(Number(cornerRadius)) || cornerRadius < 0) {
41
+ throw new Error('--corner-radius must be a positive integer.');
42
+ }
43
+
22
44
  if (canvasColor !== 'transparent' && !isValidHexadecimal(canvasColor)) {
23
45
  throw new Error('--canvas-color must be a valid hexadecimal value.');
24
46
  }
@@ -32,6 +54,7 @@ export const validateSharedOptions = (sharedOptions) => {
32
54
  dir,
33
55
  recursive,
34
56
  shuffle,
57
+ cornerRadius: Number(cornerRadius),
35
58
  gap: Number(gap),
36
59
  canvasColor: canvasColor === 'transparent' ? { r: 0, g: 0, b: 0, alpha: 0 } : canvasColor,
37
60
  output,
@@ -1,18 +1,17 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import {
4
- addSharedOptions,
5
4
  cliConfirm,
6
5
  displayInfoMessage,
7
6
  displaySuccessMessage,
8
7
  displayWarningMessage,
9
- getValidatedParams,
10
8
  handleError,
11
9
  writeImage,
12
- } from '../lib/helpers/utils.js';
13
- import { validateMasonryOptions } from '../lib/helpers/validations.js';
14
- import { loadImages } from '../lib/helpers/loadImages.js';
15
- import { masonryMerge } from '../lib/merges/masonry-merge/index.js';
10
+ } from '../../lib/helpers/utils.js';
11
+ import { addSharedOptions, getValidatedParams } from './helpers/utils.js';
12
+ import { validateMasonryOptions } from './helpers/validations.js';
13
+ import { loadImages } from '../../lib/helpers/loadImages.js';
14
+ import { masonryMerge } from '../../lib/merges/masonry-merge/index.js';
16
15
 
17
16
  const masonryCommand = new Command('masonry');
18
17
 
@@ -32,7 +31,7 @@ masonryCommand
32
31
  const main = async (files, opts) => {
33
32
  try {
34
33
  // Collect and validate parameters
35
- const validatedParams = getValidatedParams(files, opts, validateMasonryOptions);
34
+ const validatedParams = await getValidatedParams(files, opts, validateMasonryOptions);
36
35
 
37
36
  // Load images, create grid, and write grid on disk
38
37
  generateAndSaveGrid(validatedParams);
@@ -53,7 +53,7 @@ const getFilesFromDirectory = async (dir, recursive, depth = 0) => {
53
53
  const skippedFiles = [];
54
54
 
55
55
  // Ensure recursiveness ends at the max recursion depth
56
- if (depth >= MAX_RECURSION_DEPTH) return [];
56
+ if (depth >= MAX_RECURSION_DEPTH) return { paths: [], skippedFiles: [] };
57
57
 
58
58
  // Get entries
59
59
  const entries = await fs.readdir(dir, { withFileTypes: true });
@@ -75,7 +75,7 @@ const getFilesFromDirectory = async (dir, recursive, depth = 0) => {
75
75
  else if (recursive && entry.isDirectory() && !entry.isSymbolicLink()) {
76
76
  const dirpath = path.join(entry.parentPath, entry.name);
77
77
  const dirObj = await getFilesFromDirectory(dirpath, recursive, depth + 1);
78
- files.push(...dirObj.paths);
78
+ files.push(...dirObj?.paths);
79
79
  skippedFiles.push(...dirObj.skippedFiles);
80
80
  }
81
81
  }
@@ -1,7 +1,6 @@
1
1
  import readline from 'node:readline';
2
2
  import path from 'node:path';
3
3
  import chalk from 'chalk';
4
- import { validateSharedOptions } from './validations.js';
5
4
  import { progressBar } from './progressBar.js';
6
5
 
7
6
  export const SUPPORTED_INPUT_FORMATS = ['.webp', '.gif', '.jpeg', '.jpg', '.png', '.tiff', '.avif', '.svg'];
@@ -33,17 +32,6 @@ export class Message {
33
32
  }
34
33
  }
35
34
 
36
- export const addSharedOptions = (cmd) => {
37
- return cmd
38
- .argument('[files...]', 'Image filepaths to merge (use --dir for directories)')
39
- .option('-d, --dir <path>', 'Directory of images to merge')
40
- .option('-r, --recursive', 'Recursively include subdirectories', false)
41
- .option('--sh, --shuffle', 'Shuffle up images to randomize order in the grid', false)
42
- .option('-g, --gap <px>', 'Gap between images', 50)
43
- .option('--bg, --canvas-color <hex|transparent>', 'Background color for canvas', '#ffffff')
44
- .option('-o, --output <file>', 'Output file path', './pixeli.png');
45
- };
46
-
47
35
  export const isValidHexadecimal = (str) => {
48
36
  const hexRegex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
49
37
  return hexRegex.test(str);
@@ -75,6 +63,25 @@ export const handleError = (error) => {
75
63
  console.log(m.message);
76
64
  };
77
65
 
66
+ export const configureCommandErrors = (cmd) => {
67
+ // Configure error message for the current command
68
+ cmd.configureOutput({
69
+ writeErr: (str) => {
70
+ if (str.includes('error:')) {
71
+ const err = new Message(str.replace('error: ', ''), 'error');
72
+ return process.stderr.write(err.message);
73
+ }
74
+
75
+ process.stderr.write(str);
76
+ },
77
+ });
78
+
79
+ // Recursively configure error messages for all subcommands
80
+ for (const subCmd of cmd.commands) {
81
+ configureCommandErrors(subCmd);
82
+ }
83
+ };
84
+
78
85
  export const displayInfoMessage = (message) => {
79
86
  const m = new Message(message, 'neutral');
80
87
  console.log(m.message);
@@ -169,13 +176,6 @@ export const writeImage = async (image, output) => {
169
176
  return true;
170
177
  };
171
178
 
172
- export const getValidatedParams = (files, opts, validationFunc) => {
173
- const params = { files, ...opts };
174
- const sharedOptions = validateSharedOptions(params);
175
- const commandOptions = validationFunc(sharedOptions, params);
176
- return { ...sharedOptions, ...commandOptions };
177
- };
178
-
179
179
  export const shuffleArray = (array) => {
180
180
  let currentIndex = array.length;
181
181
  let randomIndex;
@@ -1,11 +1,11 @@
1
1
  import path from 'node:path';
2
2
  import sharp from 'sharp';
3
- import { getSmallestImageDimensions, getFontSize, createSvgTextBuffer } from '../merge-utils.js';
3
+ import { getSmallestImageDimensions, getFontSize, createSvgTextBuffer, roundImages } from '../merge-utils.js';
4
4
  import { progressBar, WRITING_TO_FILE_PERCENTAGE } from '../../helpers/progressBar.js';
5
5
 
6
6
  export const gridMerge = async (files, images, validatedParams) => {
7
7
  // Destructure params
8
- const { aspectRatio, imageWidth, columns, gap, canvasColor, caption, captionColor, maxCaptionSize } = validatedParams;
8
+ const { aspectRatio, imageWidth, columns, gap, cornerRadius, canvasColor, caption, captionColor, maxCaptionSize } = validatedParams;
9
9
 
10
10
  // Calculate width if needed, and height from aspect ratio
11
11
  const width = imageWidth || (await getSmallestImageDimensions(images)).smallestWidth;
@@ -20,6 +20,9 @@ export const gridMerge = async (files, images, validatedParams) => {
20
20
  });
21
21
  });
22
22
 
23
+ // Round images if needed
24
+ const roundedImages = await roundImages(resizedImages, { width, height, cornerRadius });
25
+
23
26
  // Get filenames if needed
24
27
  let filenames = null;
25
28
  if (caption) {
@@ -28,7 +31,7 @@ export const gridMerge = async (files, images, validatedParams) => {
28
31
 
29
32
  // Lay images in a grid
30
33
  const gridParams = {
31
- images: resizedImages,
34
+ images: roundedImages,
32
35
  width,
33
36
  height,
34
37
  columns,
@@ -1,9 +1,9 @@
1
1
  import sharp from 'sharp';
2
- import { scaleImages } from '../merge-utils.js';
2
+ import { roundImages, scaleImages } from '../merge-utils.js';
3
3
  import { progressBar, WRITING_TO_FILE_PERCENTAGE } from '../../helpers/progressBar.js';
4
4
 
5
5
  export const buildHorizontalMasonry = async (images, params) => {
6
- const { gap, canvasColor, canvasWidth, rowHeight, hAlign } = params;
6
+ const { gap, canvasColor, cornerRadius, canvasWidth, rowHeight, hAlign } = params;
7
7
 
8
8
  // Use 5% of images.length for writing to file
9
9
  const fileWriteAmount = Math.ceil(images.length * WRITING_TO_FILE_PERCENTAGE);
@@ -19,10 +19,10 @@ export const buildHorizontalMasonry = async (images, params) => {
19
19
  const canvasHeight = rows.length * rowHeight + (rows.length + 1) * gap;
20
20
 
21
21
  // Create and return grid of images
22
- return await createMasonryLayout(rows, rowHeight, canvasWidth, canvasHeight, canvasColor, gap, hAlign);
22
+ return await createMasonryLayout(rows, rowHeight, canvasWidth, canvasHeight, canvasColor, cornerRadius, gap, hAlign);
23
23
  };
24
24
 
25
- const createMasonryLayout = async (rows, rowHeight, canvasWidth, canvasHeight, canvasColor, gap, hAlign) => {
25
+ const createMasonryLayout = async (rows, rowHeight, canvasWidth, canvasHeight, canvasColor, cornerRadius, gap, hAlign) => {
26
26
  const canvas = sharp({
27
27
  limitInputPixels: false,
28
28
  create: {
@@ -66,8 +66,17 @@ const createMasonryLayout = async (rows, rowHeight, canvasWidth, canvasHeight, c
66
66
  finalizedMeta = await finalizedImage.metadata();
67
67
  }
68
68
 
69
+ // Round the finalized image
70
+ const roundingOptions = {
71
+ width: finalizedMeta.width,
72
+ height: finalizedMeta.height,
73
+ cornerRadius,
74
+ };
75
+
76
+ const roundedImage = (await roundImages([finalizedImage], roundingOptions))[0];
77
+
69
78
  composites.push({
70
- input: await finalizedImage.toBuffer(),
79
+ input: await roundedImage.toBuffer(),
71
80
  left: x,
72
81
  top: y,
73
82
  });
@@ -27,10 +27,10 @@ export const masonryMerge = async (images, opts) => {
27
27
  };
28
28
 
29
29
  const getFlowSpecificParams = async (images, currentParams) => {
30
- const { flow, gap, canvasColor } = currentParams;
30
+ const { flow, gap, canvasColor, cornerRadius } = currentParams;
31
31
  const config = FLOW_DEFAULTS[flow];
32
32
 
33
- const output = { gap, canvasColor };
33
+ const output = { gap, canvasColor, cornerRadius };
34
34
 
35
35
  for (const key of config.needed) {
36
36
  if (currentParams[key] != null) {
@@ -1,9 +1,9 @@
1
1
  import sharp from 'sharp';
2
- import { scaleImages } from '../merge-utils.js';
2
+ import { roundImages, scaleImages } from '../merge-utils.js';
3
3
  import { progressBar, WRITING_TO_FILE_PERCENTAGE } from '../../helpers/progressBar.js';
4
4
 
5
5
  export const buildVerticalMasonry = async (images, params) => {
6
- const { gap, canvasColor, canvasHeight, columnWidth, vAlign } = params;
6
+ const { gap, canvasColor, cornerRadius, canvasHeight, columnWidth, vAlign } = params;
7
7
 
8
8
  // Use 5% of images.length for writing to file
9
9
  const fileWriteAmount = Math.ceil(images.length * WRITING_TO_FILE_PERCENTAGE);
@@ -19,10 +19,10 @@ export const buildVerticalMasonry = async (images, params) => {
19
19
  const canvasWidth = columns.length * columnWidth + (columns.length + 1) * gap;
20
20
 
21
21
  // Create and return grid of images
22
- return await createMasonryLayout(columns, columnWidth, canvasWidth, canvasHeight, canvasColor, gap, vAlign);
22
+ return await createMasonryLayout(columns, columnWidth, canvasWidth, canvasHeight, canvasColor, cornerRadius, gap, vAlign);
23
23
  };
24
24
 
25
- const createMasonryLayout = async (cols, columnWidth, canvasWidth, canvasHeight, canvasColor, gap, vAlign) => {
25
+ const createMasonryLayout = async (cols, columnWidth, canvasWidth, canvasHeight, canvasColor, cornerRadius, gap, vAlign) => {
26
26
  const composites = [];
27
27
 
28
28
  const canvas = sharp({
@@ -61,8 +61,17 @@ const createMasonryLayout = async (cols, columnWidth, canvasWidth, canvasHeight,
61
61
  finalizedMeta = await finalizedImage.metadata();
62
62
  }
63
63
 
64
+ // Round the finalized image
65
+ const roundingOptions = {
66
+ width: finalizedMeta.width,
67
+ height: finalizedMeta.height,
68
+ cornerRadius,
69
+ };
70
+
71
+ const roundedImage = (await roundImages([finalizedImage], roundingOptions))[0];
72
+
64
73
  composites.push({
65
- input: await finalizedImage.toBuffer(),
74
+ input: await roundedImage.toBuffer(),
66
75
  left: x,
67
76
  top: y,
68
77
  });
@@ -33,7 +33,10 @@ export const scaleImages = async (images, { width = null, height = null }) => {
33
33
 
34
34
  let targetWidth, targetHeight;
35
35
 
36
- if (width) {
36
+ if (width && height) {
37
+ targetWidth = width;
38
+ targetHeight = height;
39
+ } else if (width) {
37
40
  const f = width / meta.width;
38
41
  targetWidth = width;
39
42
  targetHeight = Math.floor(meta.height * f);
@@ -150,7 +153,24 @@ const escapeXML = (str) => {
150
153
  );
151
154
  };
152
155
 
153
- /*
154
-
156
+ export const roundImages = async (images, { width, height, cornerRadius }) => {
157
+ // Skip if the cornerRadius = zero
158
+ if (!cornerRadius) return images;
155
159
 
156
- */
160
+ // Round images respectively
161
+ return await Promise.all(
162
+ images.map(async (image) => {
163
+ const mask = Buffer.from(`
164
+ <svg width="${width}" height="${height}">
165
+ <rect x="0" y="0" width="${width}" height="${height}" rx="${cornerRadius}" ry="${cornerRadius}" />
166
+ </svg>
167
+ `);
168
+
169
+ const buff = await image
170
+ .composite([{ input: mask, blend: 'dest-in' }])
171
+ .png()
172
+ .toBuffer();
173
+ return sharp(buff);
174
+ })
175
+ );
176
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixeli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "A lightweight command-line tool for merging multiple images into customizable grid layouts.",
5
5
  "homepage": "https://github.com/pakdad-mousavi/pixeli#readme",
6
6
  "bugs": {
@@ -36,6 +36,7 @@
36
36
  "chalk": "^5.6.2",
37
37
  "cli-progress": "^3.12.0",
38
38
  "commander": "^14.0.2",
39
- "sharp": "^0.34.5"
39
+ "sharp": "^0.34.5",
40
+ "table": "^6.9.0"
40
41
  }
41
42
  }
File without changes