pixeli 0.1.6 → 0.1.8

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
@@ -11,9 +11,15 @@ The tool currently supports two main layout modes: ***Grid*** and ***Masonry***
11
11
 
12
12
  | Grid (1:1 images) | Contact Sheet Grid |
13
13
  |---|---|
14
- | <img src="samples/grid.png" width="400"> | <img src="samples/grid-with-captions.png" width="400"> |
14
+ | <img src="samples/grid.jpg" width="400"> | <img src="samples/grid-with-captions.jpg" width="400"> |
15
15
  | **Masonry (Horizontal)** | **Masonry (Vertical)** |
16
- | <img src="samples/masonry-horizontal.png" width="400"> | <img src="samples/masonry-vertical.png" width="400"> |
16
+ | <img src="samples/masonry-horizontal.jpg" width="400"> | <img src="samples/masonry-vertical.jpg" width="400"> |
17
+ | **Collag (Instagram Grid)** | **Collage (Vertical Book Spread)** |
18
+ | <img src="samples/instagram-grid.jpg" width="400"> | <img src="samples/vertical-book-spread.jpg" width="400"> |
19
+ | **Collage (Horizontal Book Spread)** | **Collage (Dashboard Shot)** |
20
+ | <img src="samples/horizontal-book-spread.jpg" width="400"> | <img src="samples/dashboard-shot.jpg" width="400"> |
21
+ | **Collage (Art Gallery)** |
22
+ | <img src="samples/art-gallery.jpg" width="400"> |
17
23
 
18
24
  ## Installation
19
25
  Pixeli can be installed using npm. Simply run the following command to install it globally on your machine:
@@ -73,6 +79,19 @@ By default, the masonry merge command uses a horizontal flow, but a vertical one
73
79
  pixeli merge masonry -rd ./samples/images -f vertical --cvh 4000
74
80
  ```
75
81
 
82
+ ### Collage Layout
83
+ Collage layouts require a JSON template, or an inline JSON string, which describe your specific layout. The `-t` flag is used to specify the path to a JSON template, whereas the `-m` flag is used to provide inline JSON:
84
+ ```bash
85
+ pixeli merge collage -rd ./samples/images -t ./template.json
86
+ ```
87
+
88
+ You could also use a preset, and also round all the image corners in your collage:
89
+ ```bash
90
+ pixeli merge collage -rd ./samples/images -t ./template.json --cr 100
91
+ ```
92
+
93
+ To learn about the JSON template, see [collage templates](#collage-templates).
94
+
76
95
  ## Full Documentation
77
96
 
78
97
  ### pixeli merge
@@ -125,6 +144,95 @@ The masonry mode preserves each image’s natural shape, creating an organic bri
125
144
  | `--ha`, `--h-align <left\|center\|right\|justified>` | `justified` | Controls **horizontal alignment** of rows when in `horizontal` flow. `justified` overfills each row and crops the final image to fill up the canvas. |
126
145
  | `--va`, `--v-align <top\|middle\|bottom\|justified>` | `justified` | Controls **vertical alignment** of columns when in `vertical` flow. `justified` overfills each column and crops the final image to fill up the canvas. |
127
146
 
147
+ ### pixeli merge collage
148
+ Usage: `pixeli merge collage [options] [files...]`
149
+
150
+ The collage merge requires a specified JSON template file, or JSON string. Images will be placed as per the template. If a preset ID is provided, both `--template` and `--mapping` are ignored.
151
+
152
+ | Option/Flag | Default | Description |
153
+ | ---------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
154
+ | `-t`, `--template <path>` | `null` | Sets the **path to the JSON template file** which will be used to arrange the collage. Priority is given to this option if `--mapping` is not provided. |
155
+ | `-m`, `--mapping <string>` | `null` | Sets the **JSON string** which will be parsed to later arrange the collage. Priority is given to `--template` if both options are provided. |
156
+ | `-p`, `--preset <preset-id>` | `null` | Use a **predefined collage preset** instead of providing your own. Available preset IDs: `instagram-grid`, `dashboardShot`, `horizontal-book-spread`, `vertical-book-spread`, `art-gallery` |
157
+
158
+ ### Collage Templates
159
+ The following javascript object thoroughly describes the shape of the JSON objects which are expected to be received. Logical checks are performed on the values after the template is validated. This is to ensure the collage can be created, for example, without any overlaps or 0 pixel-wide images:
160
+ ```javascript
161
+ {
162
+ type: 'object',
163
+ required: ['canvas', 'slots'],
164
+ properties: {
165
+ canvas: {
166
+ type: 'object',
167
+ required: ['width', 'height', 'columns', 'rows'],
168
+ properties: {
169
+ width: { type: 'number', minimum: 1, multipleOf: 1 },
170
+ height: { type: 'number', minimum: 1, multipleOf: 1 },
171
+ columns: { type: 'number', minimum: 1, multipleOf: 1 },
172
+ rows: { type: 'number', minimum: 1, multipleOf: 1 },
173
+ gap: { type: 'number', minimum: 0, multipleOf: 1 },
174
+ background: { type: 'string' },
175
+ },
176
+ },
177
+
178
+ slots: {
179
+ type: 'array',
180
+ items: {
181
+ type: 'object',
182
+ required: ['col', 'row', 'colSpan', 'rowSpan'],
183
+ properties: {
184
+ col: { type: 'number', minimum: 1, multipleOf: 1 },
185
+ row: { type: 'number', minimum: 1, multipleOf: 1 },
186
+ colSpan: { type: 'number', minimum: 1, multipleOf: 1 },
187
+ rowSpan: { type: 'number', minimum: 1, multipleOf: 1 },
188
+ },
189
+ },
190
+ },
191
+ },
192
+ }
193
+ ```
194
+
195
+ The `template.canvas` object defines key properties of the canvas, and the `template.slots` array lists all the slots in which images are to be placed.
196
+
197
+ A slot object takes 4 properties: `col`, `row`, `colSpan`, and `rowSpan`. `col` and `row` specify which column and row to place the image in, while `colSpan` and `rowSpan` define how many units the image should take up horizontally and vertically.
198
+
199
+ The width of a single unit is equal to canvas width divided by the number of columns, and the height of a single unit is equal to the canvas height divided by the number of rows.
200
+
201
+ For example:
202
+ ```json
203
+ {
204
+ "col": 1,
205
+ "row": 5,
206
+ "colSpan": 3,
207
+ "rowSpan": 2
208
+ }
209
+ ```
210
+ This slot will be placed at the 1st column from the left, at the 5th row from the top. It will span 3 columns, including the one it has been placed in, meaning it will take up column 1, 2, and 3. The same goes for the rows; the slot will take up row 5 and 6.
211
+
212
+ This is an example of a full JSON template:
213
+ ```json
214
+ {
215
+ "canvas": {
216
+ "width": 1200,
217
+ "height": 1600,
218
+ "columns": 3,
219
+ "rows": 6,
220
+ "gap": 12,
221
+ "background": "#000"
222
+ },
223
+ "slots": [
224
+ { "col": 1, "row": 1, "colSpan": 2, "rowSpan": 2 },
225
+ { "col": 3, "row": 1, "colSpan": 1, "rowSpan": 1 },
226
+ { "col": 3, "row": 2, "colSpan": 1, "rowSpan": 1 },
227
+ { "col": 1, "row": 3, "colSpan": 1, "rowSpan": 2 },
228
+ { "col": 2, "row": 3, "colSpan": 2, "rowSpan": 2 },
229
+ { "col": 1, "row": 5, "colSpan": 3, "rowSpan": 2 }
230
+ ]
231
+ }
232
+ ```
233
+
234
+ Note that the `canvas.background` and `canvas.gap` properties are optional. If they are not provided, the defaults from the CLI options will be used. If both the CLI and template options exists, the template options take priority.
235
+
128
236
  ## License
129
237
  This project is licensed under the [MIT License](./LICENSE).
130
238
 
@@ -0,0 +1,83 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import {
4
+ cliConfirm,
5
+ displayInfoMessage,
6
+ displaySuccessMessage,
7
+ displayWarningMessage,
8
+ handleError,
9
+ writeImage,
10
+ } from '../../lib/helpers/utils.js';
11
+ import { validateCollageOptions, validateSharedOptions } from './helpers/validations.js';
12
+ import { loadImages } from '../../lib/helpers/loadImages.js';
13
+ import { addSharedOptions } from './helpers/utils.js';
14
+ import { validateTemplate } from '../../lib/helpers/templateValidator.js';
15
+ import { collageMerge } from '../../lib/merges/collage-merge/index.js';
16
+
17
+ const collageCommand = new Command('collage');
18
+
19
+ collageCommand
20
+ .description('Use JSON layouts to build custom collages.')
21
+ .option('-t, --template <path>', 'The path to the JSON file describing the collage template', null)
22
+ .option('-m, --mapping <json>', 'Inline JSON template override straight from the command line', null)
23
+ .option('-p, --preset <preset-id>', 'Collage preset ID to use. Available collage IDs: ', null)
24
+ .action(async (files, opts) => {
25
+ await main(files, opts);
26
+ });
27
+
28
+ const main = async (files, opts) => {
29
+ // Collect and validate parameters
30
+ try {
31
+ const params = { files, ...opts };
32
+ const sharedOptions = await validateSharedOptions(params);
33
+ const collageOptions = await validateCollageOptions(opts);
34
+ const validatedParams = { ...sharedOptions, ...collageOptions };
35
+
36
+ // Load images, create collage, and write on disk
37
+ await generateAndSaveCollage(validatedParams);
38
+
39
+ // Output success message
40
+ } catch (e) {
41
+ handleError(e);
42
+ }
43
+ };
44
+
45
+ const generateAndSaveCollage = async (validatedParams) => {
46
+ // Replace gap and background if not provided with command line values
47
+ if (validatedParams.template?.canvas?.gap === undefined) {
48
+ validatedParams.template.canvas.gap = validatedParams.gap;
49
+ }
50
+ if (validatedParams.template?.canvas?.background === undefined) {
51
+ validatedParams.template.canvas.background = validatedParams.canvasColor;
52
+ }
53
+
54
+ // Validate the template and update it
55
+ const validatedTemplate = validateTemplate(validatedParams.template);
56
+ validatedParams.template = validatedTemplate;
57
+
58
+ // Load images from file
59
+ const { images, ignoredFiles } = await loadImages({ ...validatedParams, count: validatedParams.template.slots.length });
60
+
61
+ // Display warnings if needed
62
+ if (ignoredFiles.length) {
63
+ displayWarningMessage('\nThese files will be ignored due to unsupported formats:');
64
+ for (const file of ignoredFiles) {
65
+ displayInfoMessage(file);
66
+ }
67
+
68
+ const confirmation = await cliConfirm('\nAre you sure you want to continue?');
69
+ if (!confirmation) return;
70
+ }
71
+
72
+ const collage = await collageMerge(images, validatedParams);
73
+
74
+ const success = await writeImage(collage, validatedParams.output);
75
+
76
+ // Display success message
77
+ if (success) {
78
+ displaySuccessMessage(`\nImage has been created successfully: ${chalk.bold(validatedParams.output)}\n`);
79
+ }
80
+ };
81
+
82
+ addSharedOptions(collageCommand);
83
+ export default collageCommand;
@@ -8,8 +8,8 @@ import {
8
8
  handleError,
9
9
  writeImage,
10
10
  } from '../../lib/helpers/utils.js';
11
- import { addSharedOptions, getValidatedParams } from './helpers/utils.js';
12
- import { validateGridOptions } from './helpers/validations.js';
11
+ import { addSharedOptions } from './helpers/utils.js';
12
+ import { validateGridOptions, validateSharedOptions } from './helpers/validations.js';
13
13
  import { loadImages } from '../../lib/helpers/loadImages.js';
14
14
  import { gridMerge } from '../../lib/merges/grid-merge/index.js';
15
15
 
@@ -30,7 +30,10 @@ gridCommand
30
30
  const main = async (files, opts) => {
31
31
  // Collect and validate parameters
32
32
  try {
33
- const validatedParams = await getValidatedParams(files, opts, validateGridOptions);
33
+ const params = { files, ...opts };
34
+ const sharedOptions = await validateSharedOptions(params);
35
+ const gridOptions = validateGridOptions(sharedOptions, params);
36
+ const validatedParams = { ...sharedOptions, ...gridOptions };
34
37
 
35
38
  // Load images, create grid, and write grid on disk
36
39
  await generateAndSaveGrid(validatedParams);
@@ -1,5 +1,3 @@
1
- import { validateSharedOptions } from './validations.js';
2
-
3
1
  export const addSharedOptions = (cmd) => {
4
2
  return cmd
5
3
  .argument('[files...]', 'Image filepaths to merge (use --dir for directories)')
@@ -11,10 +9,3 @@ export const addSharedOptions = (cmd) => {
11
9
  .option('--bg, --canvas-color <hex|transparent>', 'Background color for canvas', '#ffffff')
12
10
  .option('-o, --output <file>', 'Output file path', './pixeli.png');
13
11
  };
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
- };
@@ -8,6 +8,7 @@ import {
8
8
  parseAspectRatio,
9
9
  SUPPORTED_OUTPUT_FORMATS,
10
10
  } from '../../../lib/helpers/utils.js';
11
+ import { isValidPreset, PRESETS } from '../../../lib/merges/collage-merge/presets.js';
11
12
 
12
13
  export const validateSharedOptions = async (sharedOptions) => {
13
14
  // Extract params
@@ -212,3 +213,57 @@ export const validateGridOptions = (sharedOptions, gridOptions) => {
212
213
 
213
214
  return formattedParams;
214
215
  };
216
+
217
+ export const validateCollageOptions = async (collageOptions) => {
218
+ const { template, mapping, preset } = collageOptions;
219
+
220
+ // If a valid preset is given, use it
221
+ if (preset && isValidPreset(preset)) {
222
+ return { template: PRESETS[preset] };
223
+ }
224
+ // If preset is invalid, throw error
225
+ else if (preset && !isValidPreset(preset)) {
226
+ const validPresets = Object.keys(PRESETS).join(', ');
227
+ throw new Error(`"${preset}" is not a valid preset ID. Choose one of the following: \n${validPresets}`);
228
+ }
229
+
230
+ // Ensure only one of the two are given
231
+ if (!template && !mapping) {
232
+ throw new Error('Either --template, --mapping, or --preset need to be provided.');
233
+ } else if (template && mapping) {
234
+ throw new Error('Either use --template or --mapping, not both.');
235
+ }
236
+
237
+ // Ensure mapping is actually a json string
238
+ if (!template && mapping) {
239
+ try {
240
+ JSON.parse(mapping);
241
+ } catch {
242
+ throw new Error('--mapping should be a valid JSON string.');
243
+ }
244
+ }
245
+
246
+ // Ensure template is a valid dir path
247
+ if (template && !mapping) {
248
+ let stats;
249
+
250
+ try {
251
+ stats = await fs.stat(template);
252
+ } catch (e) {
253
+ throw new Error('Template path does not exist.');
254
+ }
255
+ }
256
+
257
+ // Parse and return respective JSON data
258
+ try {
259
+ if (template) {
260
+ const jsonStr = await fs.readFile(template, 'utf8');
261
+ return { template: JSON.parse(jsonStr) };
262
+ }
263
+
264
+ // mapping is already a JSON string
265
+ return { template: JSON.parse(mapping) };
266
+ } catch (err) {
267
+ throw new Error('Could not read or parse the provided template or mapping JSON.');
268
+ }
269
+ };
@@ -1,10 +1,12 @@
1
1
  import { Command } from 'commander';
2
2
  import masonryCommand from './masonry.js';
3
3
  import gridCommand from './grid.js';
4
+ import collageCommand from './collage.js';
4
5
 
5
6
  const mergeCommand = new Command('merge').description('Merge images into a grid layout.');
6
7
 
7
8
  mergeCommand.addCommand(masonryCommand);
8
9
  mergeCommand.addCommand(gridCommand);
10
+ mergeCommand.addCommand(collageCommand);
9
11
 
10
12
  export default mergeCommand;
@@ -8,8 +8,8 @@ import {
8
8
  handleError,
9
9
  writeImage,
10
10
  } from '../../lib/helpers/utils.js';
11
- import { addSharedOptions, getValidatedParams } from './helpers/utils.js';
12
- import { validateMasonryOptions } from './helpers/validations.js';
11
+ import { addSharedOptions } from './helpers/utils.js';
12
+ import { validateMasonryOptions, validateSharedOptions } from './helpers/validations.js';
13
13
  import { loadImages } from '../../lib/helpers/loadImages.js';
14
14
  import { masonryMerge } from '../../lib/merges/masonry-merge/index.js';
15
15
 
@@ -31,7 +31,10 @@ masonryCommand
31
31
  const main = async (files, opts) => {
32
32
  try {
33
33
  // Collect and validate parameters
34
- const validatedParams = await getValidatedParams(files, opts, validateMasonryOptions);
34
+ const params = { files, ...opts };
35
+ const sharedOptions = await validateSharedOptions(params);
36
+ const masonryOptions = validateMasonryOptions(sharedOptions, opts);
37
+ const validatedParams = { ...sharedOptions, ...masonryOptions };
35
38
 
36
39
  // Load images, create grid, and write grid on disk
37
40
  generateAndSaveGrid(validatedParams);
@@ -5,20 +5,25 @@ import { isSupportedInputImage, shuffleTogether } from './utils.js';
5
5
 
6
6
  const MAX_RECURSION_DEPTH = 10;
7
7
 
8
- export const loadImages = async ({ files, dir, recursive, shuffle }) => {
8
+ export const loadImages = async ({ files, dir, recursive, shuffle, count }) => {
9
9
  let ignoredFiles = [];
10
10
  let filepaths = files;
11
11
  let images = [];
12
12
 
13
13
  if (files && files.length) {
14
14
  // Load directly from provided file list
15
- images = await loadFromFiles(files);
15
+ images = await loadFromFiles(files, count);
16
16
  } else {
17
17
  // Get all files from directory
18
18
  const { skippedFiles, paths } = await getFilesFromDirectory(dir, recursive);
19
19
  filepaths = paths;
20
20
  ignoredFiles = skippedFiles;
21
- images = await loadFromFiles(filepaths);
21
+ images = await loadFromFiles(filepaths, count);
22
+ }
23
+
24
+ // Ensure filepaths and images match
25
+ if (images.length !== filepaths.length) {
26
+ filepaths = filepaths.slice(0, images.length);
22
27
  }
23
28
 
24
29
  // Optional: shuffle filepaths and images together
@@ -29,10 +34,15 @@ export const loadImages = async ({ files, dir, recursive, shuffle }) => {
29
34
  return { images, files: filepaths, ignoredFiles };
30
35
  };
31
36
 
32
- const loadFromFiles = async (files) => {
37
+ const loadFromFiles = async (files, count) => {
33
38
  const images = [];
39
+ const total = count || files.length;
40
+ for (let i = 0; i < total; i++) {
41
+ // End the loop if count is higher than number of available files
42
+ if (i >= files.length) break;
34
43
 
35
- for (const filepath of files) {
44
+ // Load images
45
+ const filepath = files[i];
36
46
  let image;
37
47
 
38
48
  if (filepath.endsWith('.svg')) {
@@ -0,0 +1,139 @@
1
+ import Ajv from 'ajv';
2
+ import { isValidHexadecimal } from './utils.js';
3
+
4
+ // Define entire template schema
5
+ const TEMPLATE_SCHEMA = {
6
+ type: 'object',
7
+ additionalProperties: false,
8
+ required: ['canvas', 'slots'],
9
+ properties: {
10
+ canvas: {
11
+ type: 'object',
12
+ additionalProperties: false,
13
+ required: ['width', 'height', 'columns', 'rows'],
14
+ properties: {
15
+ width: { type: 'number', minimum: 1, multipleOf: 1 },
16
+ height: { type: 'number', minimum: 1, multipleOf: 1 },
17
+ columns: { type: 'number', minimum: 1, multipleOf: 1 },
18
+ rows: { type: 'number', minimum: 1, multipleOf: 1 },
19
+ gap: { type: 'number', minimum: 0, multipleOf: 1 },
20
+ background: { type: 'string' },
21
+ },
22
+ },
23
+
24
+ slots: {
25
+ type: 'array',
26
+ items: {
27
+ type: 'object',
28
+ additionalProperties: false,
29
+ required: ['col', 'row', 'colSpan', 'rowSpan'],
30
+ properties: {
31
+ col: { type: 'number', minimum: 1, multipleOf: 1 },
32
+ row: { type: 'number', minimum: 1, multipleOf: 1 },
33
+ colSpan: { type: 'number', minimum: 1, multipleOf: 1 },
34
+ rowSpan: { type: 'number', minimum: 1, multipleOf: 1 },
35
+ borderRadius: { type: 'number', minimum: 0, multipleOf: 1 },
36
+ },
37
+ },
38
+ },
39
+ },
40
+ };
41
+
42
+ const ajv = new Ajv({ allErrors: true });
43
+
44
+ const validate = ajv.compile(TEMPLATE_SCHEMA);
45
+
46
+ export const validateTemplate = (json) => {
47
+ const valid = validate(json);
48
+
49
+ // Handle schema validation
50
+ if (!valid) {
51
+ const path = validate.errors[0].instancePath.slice(1).replaceAll('/', '.');
52
+ const message = validate.errors[0].message;
53
+ throw new Error(`${path} ${message}.`);
54
+ }
55
+
56
+ // Handle canvas background color
57
+ if (json.canvas.background !== 'transparent' && !isValidHexadecimal(json.canvas.background)) {
58
+ throw new Error(`Canvas color must be a valid hex value or "transparent".`);
59
+ }
60
+ json.canvas.background = json.canvas.background === 'transparent' ? { r: 0, g: 0, b: 0, alpha: 0 } : json.canvas.background;
61
+
62
+ // Ensure canvas is wide enough for at least a single 1px column
63
+ if (json.canvas.width <= json.canvas.gap * 2) {
64
+ throw new Error(`Canvas width must be greater than ${json.canvas.gap * 2}.`);
65
+ }
66
+
67
+ // Ensure canvas is long enough for at least a single 1px row
68
+ if (json.canvas.height <= json.canvas.gap * 2) {
69
+ throw new Error(`Canvas height must be greater than ${json.canvas.gap * 2}.`);
70
+ }
71
+
72
+ // Calculate column width and row height
73
+ const workableCanvasWidth = json.canvas.width - json.canvas.gap * (json.canvas.columns + 1);
74
+ const workableCanvasHeight = json.canvas.height - json.canvas.gap * (json.canvas.rows + 1);
75
+ const columnWidth = Math.floor(workableCanvasWidth / json.canvas.columns);
76
+ const rowHeight = Math.floor(workableCanvasHeight / json.canvas.rows);
77
+
78
+ // Ensure columns are thick enough
79
+ if (columnWidth <= 0) {
80
+ throw new Error(`Columns are too thin. Increase canvas width, reduce gap, or reduce number of columns.`);
81
+ }
82
+
83
+ // Ensure rows are thick enough
84
+ if (rowHeight <= 0) {
85
+ throw new Error(`Rows are too thin. Increase canvas height or reduce number of rows.`);
86
+ }
87
+
88
+ // For each slot...
89
+ for (let i = 0; i < json.slots.length; i++) {
90
+ const slot = json.slots[i];
91
+
92
+ // Ensure slot is placed inside given canvas columns
93
+ if (slot.col > json.canvas.columns) {
94
+ throw new Error(`json.slots[${i}].col must be between 1 and ${json.canvas.columns}.`);
95
+ }
96
+
97
+ // Ensure slot is placed inside given canvas rows
98
+ if (slot.row > json.canvas.rows) {
99
+ throw new Error(`json.slots[${i}].row must be between 1 and ${json.canvas.rows}.`);
100
+ }
101
+
102
+ // Ensure slot spans within given canvas columns
103
+ if (slot.col + slot.colSpan - 1 > json.canvas.columns) {
104
+ throw new Error(`json.slots[${i}] spans past the right edge of the grid (col + colSpan exceeds columns).`);
105
+ }
106
+
107
+ // Ensure slot spans within given canvas rows
108
+ if (slot.row + slot.rowSpan - 1 > json.canvas.rows) {
109
+ throw new Error(`json.slots[${i}] spans past the bottom edge of the grid (row + rowSpan exceeds rows).`);
110
+ }
111
+ }
112
+
113
+ // Ensure no slots overlap
114
+ validateSlotOverlaps(json.slots);
115
+
116
+ return json;
117
+ };
118
+
119
+ const validateSlotOverlaps = (slots) => {
120
+ for (let i = 0; i < slots.length; i++) {
121
+ const A = slots[i];
122
+
123
+ const A_right = A.col + A.colSpan - 1;
124
+ const A_bottom = A.row + A.rowSpan - 1;
125
+
126
+ for (let j = i + 1; j < slots.length; j++) {
127
+ const B = slots[j];
128
+
129
+ const B_right = B.col + B.colSpan - 1;
130
+ const B_bottom = B.row + B.rowSpan - 1;
131
+
132
+ const overlap = A.col <= B_right && A_right >= B.col && A.row <= B_bottom && A_bottom >= B.row;
133
+
134
+ if (overlap) {
135
+ throw new Error(`Slot ${i} overlaps with slot ${j}.`);
136
+ }
137
+ }
138
+ }
139
+ };
@@ -0,0 +1,110 @@
1
+ import sharp from 'sharp';
2
+ import { progressBar, WRITING_TO_FILE_PERCENTAGE } from '../../helpers/progressBar.js';
3
+ import { roundImages } from '../merge-utils.js';
4
+
5
+ export const collageMerge = async (images, validatedParams) => {
6
+ const { template, cornerRadius } = validatedParams;
7
+
8
+ // Set up progress bar
9
+ const total = Math.min(images.length, template.slots.length) * 2;
10
+ const totalFileWrite = Math.ceil(total * WRITING_TO_FILE_PERCENTAGE);
11
+ progressBar.start(total + totalFileWrite, 0, {
12
+ stage: 'Calculating dimensions',
13
+ });
14
+
15
+ // Calculate each column's width and height
16
+ const workableCanvasWidth = template.canvas.width - template.canvas.gap * (template.canvas.columns + 1);
17
+ const workableCanvasHeight = template.canvas.height - template.canvas.gap * (template.canvas.rows + 1);
18
+ const columnWidth = workableCanvasWidth / template.canvas.columns;
19
+ const rowHeight = workableCanvasHeight / template.canvas.rows;
20
+
21
+ // Each block has its resized image with its respective slot coordinates
22
+ progressBar.update({ stage: 'Resizing images' });
23
+ const blocks = await getBlocks({
24
+ slots: template.slots,
25
+ images,
26
+ gap: template.canvas.gap,
27
+ columnWidth,
28
+ rowHeight,
29
+ cornerRadius,
30
+ });
31
+
32
+ // Lay blocks
33
+ progressBar.update({ stage: 'Merging images' });
34
+ const collage = layBlocks({
35
+ canvasOptions: template.canvas,
36
+ blocks,
37
+ columnWidth,
38
+ rowHeight,
39
+ });
40
+
41
+ return collage;
42
+ };
43
+
44
+ const getBlocks = async ({ slots, images, gap, columnWidth, rowHeight, cornerRadius }) => {
45
+ const blocks = [];
46
+
47
+ for (let i = 0; i < slots.length && i < images.length; i++) {
48
+ const slot = slots[i];
49
+ const image = images[i];
50
+ let imageBuffer;
51
+
52
+ // Calculate image width and height
53
+ const width = slot.colSpan * columnWidth + (slot.colSpan - 1) * gap;
54
+ const height = slot.rowSpan * rowHeight + (slot.rowSpan - 1) * gap;
55
+
56
+ // Resize image respectively
57
+ const resizedImage = image.resize({ width: Math.floor(width), height: Math.floor(height) });
58
+
59
+ // Round corners of images if needed
60
+ if (cornerRadius > 0) {
61
+ const roundingOptions = {
62
+ width: Math.floor(width),
63
+ height: Math.floor(height),
64
+ cornerRadius,
65
+ };
66
+
67
+ const roundedImage = (await roundImages([resizedImage], roundingOptions))[0];
68
+ imageBuffer = await roundedImage.toBuffer();
69
+ } else {
70
+ imageBuffer = await resizedImage.toBuffer();
71
+ }
72
+
73
+ blocks.push({ imageBuffer, col: slot.col, row: slot.row });
74
+ progressBar.increment();
75
+ }
76
+
77
+ return blocks;
78
+ };
79
+
80
+ const layBlocks = ({ canvasOptions, blocks, columnWidth, rowHeight }) => {
81
+ // Create canvas
82
+ const canvas = sharp({
83
+ limitInputPixels: false,
84
+ create: {
85
+ background: canvasOptions.background,
86
+ channels: 4,
87
+ width: canvasOptions.width,
88
+ height: canvasOptions.height,
89
+ },
90
+ });
91
+
92
+ const composites = [];
93
+
94
+ // Collect composites
95
+ for (const block of blocks) {
96
+ const x = (block.col - 1) * columnWidth + block.col * canvasOptions.gap;
97
+ const y = (block.row - 1) * rowHeight + block.row * canvasOptions.gap;
98
+
99
+ composites.push({
100
+ input: block.imageBuffer,
101
+ left: Math.floor(x),
102
+ top: Math.floor(y),
103
+ });
104
+
105
+ progressBar.increment();
106
+ }
107
+
108
+ canvas.composite(composites);
109
+ return canvas;
110
+ };
@@ -0,0 +1,17 @@
1
+ export default {
2
+ canvas: {
3
+ width: 2000,
4
+ height: 1500,
5
+ columns: 5,
6
+ rows: 5,
7
+ gap: 20,
8
+ background: '#000',
9
+ },
10
+ slots: [
11
+ { col: 1, row: 1, colSpan: 3, rowSpan: 3 },
12
+ { col: 4, row: 1, colSpan: 2, rowSpan: 2 },
13
+ { col: 4, row: 3, colSpan: 2, rowSpan: 1 },
14
+ { col: 1, row: 4, colSpan: 2, rowSpan: 2 },
15
+ { col: 3, row: 4, colSpan: 3, rowSpan: 2 },
16
+ ],
17
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ canvas: {
3
+ width: 1800,
4
+ height: 1200,
5
+ columns: 6,
6
+ rows: 4,
7
+ gap: 10,
8
+ background: '#000',
9
+ },
10
+ slots: [
11
+ { col: 1, row: 1, colSpan: 3, rowSpan: 2 },
12
+ { col: 4, row: 1, colSpan: 3, rowSpan: 1 },
13
+ { col: 4, row: 2, colSpan: 3, rowSpan: 1 },
14
+ { col: 1, row: 3, colSpan: 2, rowSpan: 2 },
15
+ { col: 3, row: 3, colSpan: 2, rowSpan: 2 },
16
+ { col: 5, row: 3, colSpan: 2, rowSpan: 2 },
17
+ ],
18
+ };
@@ -0,0 +1,15 @@
1
+ export default {
2
+ canvas: {
3
+ width: 2400,
4
+ height: 1400,
5
+ columns: 8,
6
+ rows: 3,
7
+ gap: 16,
8
+ background: '#000',
9
+ },
10
+ slots: [
11
+ { col: 1, row: 1, colSpan: 3, rowSpan: 3 },
12
+ { col: 4, row: 1, colSpan: 5, rowSpan: 1 },
13
+ { col: 4, row: 2, colSpan: 5, rowSpan: 2 },
14
+ ],
15
+ };
@@ -0,0 +1,18 @@
1
+ export default {
2
+ canvas: {
3
+ width: 1200,
4
+ height: 1600,
5
+ columns: 3,
6
+ rows: 6,
7
+ gap: 12,
8
+ background: '#000',
9
+ },
10
+ slots: [
11
+ { col: 1, row: 1, colSpan: 2, rowSpan: 2 },
12
+ { col: 3, row: 1, colSpan: 1, rowSpan: 1 },
13
+ { col: 3, row: 2, colSpan: 1, rowSpan: 1 },
14
+ { col: 1, row: 3, colSpan: 1, rowSpan: 2 },
15
+ { col: 2, row: 3, colSpan: 2, rowSpan: 2 },
16
+ { col: 1, row: 5, colSpan: 3, rowSpan: 2 },
17
+ ],
18
+ };
@@ -0,0 +1,15 @@
1
+ export default {
2
+ canvas: {
3
+ width: 1400,
4
+ height: 2100,
5
+ columns: 2,
6
+ rows: 3,
7
+ gap: 20,
8
+ background: '#000',
9
+ },
10
+ slots: [
11
+ { col: 1, row: 1, colSpan: 2, rowSpan: 1 },
12
+ { col: 1, row: 2, colSpan: 1, rowSpan: 2 },
13
+ { col: 2, row: 2, colSpan: 1, rowSpan: 2 },
14
+ ],
15
+ };
@@ -0,0 +1,17 @@
1
+ import instagramGrid from './presets/instagramGrid.js';
2
+ import dashboardShot from './presets/dashboardShot.js';
3
+ import horizontalBookSpread from './presets/horizontalBookSpread.js';
4
+ import verticalBookSpread from './presets/verticalBookSpread.js';
5
+ import artGallery from './presets/artGallery.js';
6
+
7
+ export const PRESETS = {
8
+ 'instagram-grid': instagramGrid,
9
+ 'dashboard-shot': dashboardShot,
10
+ 'horizontal-book-spread': horizontalBookSpread,
11
+ 'vertical-book-spread': verticalBookSpread,
12
+ 'art-gallery': artGallery,
13
+ };
14
+
15
+ export const isValidPreset = (presetId) => {
16
+ return presetId in PRESETS;
17
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pixeli",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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": {
@@ -33,6 +33,7 @@
33
33
  "test": "echo \"Error: no test specified\" && exit 1"
34
34
  },
35
35
  "dependencies": {
36
+ "ajv": "^8.17.1",
36
37
  "chalk": "^5.6.2",
37
38
  "cli-progress": "^3.12.0",
38
39
  "commander": "^14.0.2",