pixeli 0.1.0 → 0.1.2
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/LICENSE +7 -0
- package/README.md +101 -2
- package/commands/{aspect.js → grid.js} +10 -10
- package/commands/merge.js +2 -4
- package/lib/helpers/validations.js +2 -51
- package/lib/merges/{aspect-merge → grid-merge}/index.js +3 -2
- package/lib/merges/masonry-merge/horizontal.js +1 -0
- package/lib/merges/masonry-merge/vertical.js +1 -0
- package/package.json +1 -1
- package/commands/square.js +0 -71
- package/lib/merges/square-merge/index.js +0 -141
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2025 Pakdad Mousavi
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,102 @@
|
|
|
1
|
-
# Pixeli (Pre-release)
|
|
2
1
|
|
|
3
|
-
|
|
2
|
+
# Pixeli (Pre-release) [](https://www.npmjs.com/package/pixeli) [](./LICENSE)
|
|
3
|
+
|
|
4
|
+
<img src="./assets/logo.svg" width="150" align="right">
|
|
5
|
+
|
|
6
|
+
**Pixeli** is a lightweight and flexible command-line tool for merging multiple images into clean, customizable grid layouts. It’s designed for speed and simplicity, making it ideal for generating collages, previews, gallery layouts, inspiration boards, and composite images without relying on heavy desktop software.
|
|
7
|
+
|
|
8
|
+
Pixeli uses Sharp, a Node.js wrapper for the libvips library which is based on C. This makes it an extremely fast tool with support for PNG, JPG, GIF, SVG, AVIF, etc.
|
|
9
|
+
|
|
10
|
+
The tool currently supports two main layout modes: ***Grid*** and ***Masonry*** (horizontal / vertical). Each of them provide a distinct visual style to match a project's needs, for example:
|
|
11
|
+
|
|
12
|
+
| Grid (1:1 images) | Contact Sheet Grid |
|
|
13
|
+
|---|---|
|
|
14
|
+
| <img src="samples/grid.png" width="400"> | <img src="samples/grid-with-captions.png" width="400"> |
|
|
15
|
+
| **Masonry (Horizontal)** | **Masonry (Vertical)** |
|
|
16
|
+
| <img src="samples/masonry-horizontal.png" width="400"> | <img src="samples/masonry-vertical.png" width="400"> |
|
|
17
|
+
|
|
18
|
+
# Installation
|
|
19
|
+
Pixeli can be installed using NPM. Simply run the following command to install it globally on your machine:
|
|
20
|
+
```bash
|
|
21
|
+
npm i -g pixeli
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
# Quick Examples
|
|
25
|
+
To run these examples, you can visit the [GitHub Repository](https://github.com/pakdad-mousavi/pixeli) and use the images in the [Samples](https://github.com/pakdad-mousavi/pixeli/blob/main/samples/) directory, if you don't already have your own set of images.
|
|
26
|
+
|
|
27
|
+
All merge commands are under the `pixeli merge` command and can be used like so: `pixeli merge [merge-mode] [options]`
|
|
28
|
+
|
|
29
|
+
## Basic Grid
|
|
30
|
+
To create a basic grid with 1:1 images, you can use the grid merge command. You'll also need to provide the individual filepaths to use, or use the `-rd` (--recursive and --directory) flags to get all the images from the specified directory:
|
|
31
|
+
```bash
|
|
32
|
+
pixeli merge grid -rd ./samples/images
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Without the `-r` flag, only the images in the directory will be scanned, and any sub-directories will be ignored.
|
|
36
|
+
|
|
37
|
+
## Grid with Rectangular Images
|
|
38
|
+
To create a grid with images that all have the same aspect ratio, you can specify the aspect ratio to use for all images using the `--ar` flag:
|
|
39
|
+
```bash
|
|
40
|
+
pixeli merge grid -rd ./samples/images --ar 16:9
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Grid with 8 Columns
|
|
44
|
+
You can also customize the number of columns that you'd like the final image to have using the `-c` flag, followed by the number of columns:
|
|
45
|
+
```bash
|
|
46
|
+
pixeli merge grid -rd ./samples/images -c 8
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Contact Sheet
|
|
50
|
+
Contact sheet style grids can also be made using pixeli. To include each file name under its respective image, the `--ca` flag can be used:
|
|
51
|
+
```bash
|
|
52
|
+
pixeli merge grid -rd ./samples/images --ca
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The caption color can also be specified using the `--cc` flag, followed by a hex color:
|
|
56
|
+
```bash
|
|
57
|
+
pixeli merge grid -rd ./samples/images --ca --cc "#ff0000"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Masonry Layout
|
|
61
|
+
To create a masonry style image, you can use the masonry merge command. The `-rd` flag is used to specify which directory to use, and the canvas width can be specified using the `--cvw` flag:
|
|
62
|
+
```bash
|
|
63
|
+
pixeli merge masonry -rd ./samples/images --cvw 4000
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
By default, the masonry merge command uses a horizontal orientation, but a vertical one can be specified using the `--or` flag, followed by the `--cvh` to specify the canvas height:
|
|
67
|
+
```bash
|
|
68
|
+
pixeli merge masonry -rd ./samples/images --or vertical --cvh 4000
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
# Full Documentation
|
|
72
|
+
|
|
73
|
+
## `pixeli merge grid`
|
|
74
|
+
Usage: `pixeli merge grid [options] <input...> -o <output>`
|
|
75
|
+
|
|
76
|
+
The grid mode arranges images into a clean, uniform grid with fixed columns and automatic row calculation. The table below displays all of the options available to this command:
|
|
77
|
+
| Option/Flag | Default | Description |
|
|
78
|
+
| ----------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
79
|
+
| `--ar`, `--aspect-ratio <width/height\|number>` | `1:1` | Sets the **per-image aspect ratio**. Accepts ratio expressions (`16/9`, `4:3`) or decimal values (`1.777`). Images are scaled as needed to match this ratio before placement. |
|
|
80
|
+
| `-w`, `--image-width <px>` | *smallest input width* | Sets the **final width of each processed image** in the grid. The height is derived automatically based on the chosen aspect ratio. |
|
|
81
|
+
| `-c`, `--columns <n>` | `4` | Defines how many **images per row** are placed in the grid. The total number of rows is calculated from the number of inputs. |
|
|
82
|
+
| `--ca`, `--caption` | `false` | Enables **automatic captions** under each image. Captions are derived from the filename (with extensions). |
|
|
83
|
+
| `--cc`, `--caption-color <hex>` | `#000000` | HEX color value for caption text (e.g., `#ffffff`, `#ff9900`). Affects all captions uniformly. |
|
|
84
|
+
| `--mcs`, `--max-caption-size <pt>` | `100` | Sets the **maximum allowed caption font size**. Useful when images are extremely large and the caption is not big enough. The renderer may auto-reduce the font size if necessary. |
|
|
85
|
+
|
|
86
|
+
## `pixeli merge masonry`
|
|
87
|
+
Usage: `pixeli merge masonry [options] <input...> -o <output>`
|
|
88
|
+
|
|
89
|
+
The masonry mode preserves each image’s natural shape, creating an organic brick-wall layout similar to Pinterest boards.
|
|
90
|
+
|
|
91
|
+
| Option/Flag | Default | Description |
|
|
92
|
+
| ---------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
93
|
+
| `--rh`, `--row-height <px>` | *smallest input height* | Sets the **target height for all images in a row** when using `horizontal` orientation. Images are scaled proportionally based on this height. |
|
|
94
|
+
| `--cw`, `--column-width <px>` | *smallest input width* | Sets the **target width for all images in a column** when using `vertical` orientation. Images are scaled proportionally based on this width. |
|
|
95
|
+
| `--cvw`, `--canvas-width <px>` | – | Sets the **fixed width** of the final output canvas. Required when using a `horizontal` orientation to know when to break a row. |
|
|
96
|
+
| `--cvh`, `--canvas-height <px>` | – | Sets the **fixed height** of the final output canvas. Required when using a `vertical` orientation to know when to break a column. |
|
|
97
|
+
| `--or`, `--orientation <horizontal\|vertical>` | `horizontal` | Determines the **flow direction** of the masonry layout. `horizontal` creates rows of varying widths; `vertical` creates columns of varying heights. |
|
|
98
|
+
| `--ha`, `--h-align <left\|center\|right\|justified>` | `justified` | Controls **horizontal alignment** of rows when in `horizontal` orientation. `justified` overfills each row and crops the final image to fill up the canvas. |
|
|
99
|
+
| `--va`, `--v-align <top\|middle\|bottom\|justified>` | `justified` | Controls **vertical alignment** of columns when in `vertical` orientation. `justified` overfills each column and crops the final image to fill up the canvas. |
|
|
100
|
+
|
|
101
|
+
# License
|
|
102
|
+
This project is licensed under the [MIT License](./LICENSE).
|
|
@@ -10,15 +10,15 @@ import {
|
|
|
10
10
|
handleError,
|
|
11
11
|
writeImage,
|
|
12
12
|
} from '../lib/helpers/utils.js';
|
|
13
|
-
import {
|
|
13
|
+
import { validateGridOptions } from '../lib/helpers/validations.js';
|
|
14
14
|
import { loadImages } from '../lib/helpers/loadImages.js';
|
|
15
|
-
import {
|
|
15
|
+
import { gridMerge } from '../lib/merges/grid-merge/index.js';
|
|
16
16
|
|
|
17
|
-
const
|
|
17
|
+
const gridCommand = new Command('grid');
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
.description('
|
|
21
|
-
.option('--ar, --aspect-ratio <width/height|number>', 'The aspect ratio of all the images (examples: 16/9, 4:3, 1.777)',
|
|
19
|
+
gridCommand
|
|
20
|
+
.description('Arranges images in an organized grid.')
|
|
21
|
+
.option('--ar, --aspect-ratio <width/height|number>', 'The aspect ratio of all the images (examples: 16/9, 4:3, 1.777)', '1:1')
|
|
22
22
|
.option('-w, --image-width <px>', 'The width of each image, defaults to the smallest image', null)
|
|
23
23
|
.option('-c, --columns <n>', 'The number of columns', 4)
|
|
24
24
|
.option('--ca, --caption', 'Whether to caption each image', false)
|
|
@@ -31,7 +31,7 @@ aspectCommand
|
|
|
31
31
|
const main = async (files, opts) => {
|
|
32
32
|
// Collect and validate parameters
|
|
33
33
|
try {
|
|
34
|
-
const validatedParams = getValidatedParams(files, opts,
|
|
34
|
+
const validatedParams = getValidatedParams(files, opts, validateGridOptions);
|
|
35
35
|
|
|
36
36
|
// Load images, create grid, and write grid on disk
|
|
37
37
|
await generateAndSaveGrid(validatedParams);
|
|
@@ -56,7 +56,7 @@ const generateAndSaveGrid = async (validatedParams) => {
|
|
|
56
56
|
if (!confirmation) return;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
const grid = await
|
|
59
|
+
const grid = await gridMerge(files, images, validatedParams);
|
|
60
60
|
const success = await writeImage(grid, validatedParams.output);
|
|
61
61
|
|
|
62
62
|
// Display success message
|
|
@@ -65,5 +65,5 @@ const generateAndSaveGrid = async (validatedParams) => {
|
|
|
65
65
|
}
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
-
addSharedOptions(
|
|
69
|
-
export default
|
|
68
|
+
addSharedOptions(gridCommand);
|
|
69
|
+
export default gridCommand;
|
package/commands/merge.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import squareCommand from './square.js';
|
|
3
2
|
import masonryCommand from './masonry.js';
|
|
4
|
-
import
|
|
3
|
+
import gridCommand from './grid.js';
|
|
5
4
|
|
|
6
5
|
const mergeCommand = new Command('merge').description('Merge images into a grid layout.');
|
|
7
6
|
|
|
8
|
-
mergeCommand.addCommand(squareCommand);
|
|
9
7
|
mergeCommand.addCommand(masonryCommand);
|
|
10
|
-
mergeCommand.addCommand(
|
|
8
|
+
mergeCommand.addCommand(gridCommand);
|
|
11
9
|
|
|
12
10
|
export default mergeCommand;
|
|
@@ -40,50 +40,6 @@ export const validateSharedOptions = (sharedOptions) => {
|
|
|
40
40
|
return formattedParams;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
export const validateSquareOptions = (sharedOptions, squareOptions) => {
|
|
44
|
-
// Extract params
|
|
45
|
-
const { fitMode, imageSize, paddingColor, columns, caption, captionColor, maxCaptionSize } = squareOptions;
|
|
46
|
-
|
|
47
|
-
// Define fit modes for validation
|
|
48
|
-
const FIT_MODES = ['contain', 'cover'];
|
|
49
|
-
|
|
50
|
-
if (!FIT_MODES.includes(fitMode)) {
|
|
51
|
-
throw new Error('Invalid fit mode. Choose one of the following: ' + FIT_MODES.join(', '));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (imageSize && (isNaN(imageSize) || !Number.isInteger(Number(imageSize)) || Number(imageSize) < 1)) {
|
|
55
|
-
throw new Error('--image-size must be a positive integer.');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (paddingColor !== 'transparent' && !isValidHexadecimal(paddingColor)) {
|
|
59
|
-
throw new Error('--padding-color must be a valid hexadecimal value.');
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (isNaN(columns) || !Number.isInteger(Number(columns)) || Number(columns) < 1) {
|
|
63
|
-
throw new Error('--columns must be a positive integer.');
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if (isNaN(maxCaptionSize) || !Number.isInteger(Number(maxCaptionSize)) || Number(maxCaptionSize) < 2) {
|
|
67
|
-
throw new Error('--max-caption-size must be a positive integer >= 2 (minimum caption size).');
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (!isValidHexadecimal(captionColor)) {
|
|
71
|
-
throw new Error('--caption-color must be a valid hexadecimal value.');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const formattedParams = {
|
|
75
|
-
fitMode,
|
|
76
|
-
imageSize: Number(imageSize) || null,
|
|
77
|
-
paddingColor: paddingColor === 'transparent' ? { r: 0, g: 0, b: 0, alpha: 0 } : paddingColor,
|
|
78
|
-
columns: Number(columns),
|
|
79
|
-
caption,
|
|
80
|
-
captionColor,
|
|
81
|
-
maxCaptionSize,
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
return formattedParams;
|
|
85
|
-
};
|
|
86
|
-
|
|
87
43
|
export const validateMasonryOptions = (sharedOptions, masonryOptions) => {
|
|
88
44
|
// Extract params
|
|
89
45
|
const { gap } = sharedOptions;
|
|
@@ -202,14 +158,9 @@ export const validateMasonryOptions = (sharedOptions, masonryOptions) => {
|
|
|
202
158
|
return params;
|
|
203
159
|
};
|
|
204
160
|
|
|
205
|
-
export const
|
|
161
|
+
export const validateGridOptions = (sharedOptions, gridOptions) => {
|
|
206
162
|
// Extract params
|
|
207
|
-
const { aspectRatio, imageWidth, columns, caption, captionColor, maxCaptionSize } =
|
|
208
|
-
|
|
209
|
-
// Ensure aspect ratio is given
|
|
210
|
-
if (!aspectRatio) {
|
|
211
|
-
throw new Error('--aspect-ratio must be provided.');
|
|
212
|
-
}
|
|
163
|
+
const { aspectRatio, imageWidth, columns, caption, captionColor, maxCaptionSize } = gridOptions;
|
|
213
164
|
|
|
214
165
|
// Ensure aspect ratio is valid
|
|
215
166
|
const parsedAspectRatio = parseAspectRatio(aspectRatio);
|
|
@@ -3,7 +3,7 @@ import sharp from 'sharp';
|
|
|
3
3
|
import { getSmallestImageDimensions, getFontSize, createSvgTextBuffer } from '../merge-utils.js';
|
|
4
4
|
import { progressBar, WRITING_TO_FILE_PERCENTAGE } from '../../helpers/progressBar.js';
|
|
5
5
|
|
|
6
|
-
export const
|
|
6
|
+
export const gridMerge = async (files, images, validatedParams) => {
|
|
7
7
|
// Destructure params
|
|
8
8
|
const { aspectRatio, imageWidth, columns, gap, canvasColor, caption, captionColor, maxCaptionSize } = validatedParams;
|
|
9
9
|
|
|
@@ -16,7 +16,7 @@ export const aspectMerge = async (files, images, validatedParams) => {
|
|
|
16
16
|
return image.resize({
|
|
17
17
|
width,
|
|
18
18
|
height,
|
|
19
|
-
fit: '
|
|
19
|
+
fit: 'cover',
|
|
20
20
|
});
|
|
21
21
|
});
|
|
22
22
|
|
|
@@ -84,6 +84,7 @@ const layImagesInGrid = async (opts) => {
|
|
|
84
84
|
|
|
85
85
|
// Create canvas
|
|
86
86
|
const canvas = sharp({
|
|
87
|
+
limitInputPixels: false,
|
|
87
88
|
create: {
|
|
88
89
|
width: canvasWidth,
|
|
89
90
|
height: canvasHeight,
|
|
@@ -24,6 +24,7 @@ export const buildHorizontalMasonry = async (images, params) => {
|
|
|
24
24
|
|
|
25
25
|
const createMasonryLayout = async (rows, rowHeight, canvasWidth, canvasHeight, canvasColor, gap, hAlign) => {
|
|
26
26
|
const canvas = sharp({
|
|
27
|
+
limitInputPixels: false,
|
|
27
28
|
create: {
|
|
28
29
|
width: canvasWidth,
|
|
29
30
|
height: canvasHeight,
|
package/package.json
CHANGED
package/commands/square.js
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { Command } from 'commander';
|
|
2
|
-
import chalk from 'chalk';
|
|
3
|
-
import {
|
|
4
|
-
addSharedOptions,
|
|
5
|
-
cliConfirm,
|
|
6
|
-
displayInfoMessage,
|
|
7
|
-
displaySuccessMessage,
|
|
8
|
-
displayWarningMessage,
|
|
9
|
-
getValidatedParams,
|
|
10
|
-
handleError,
|
|
11
|
-
writeImage,
|
|
12
|
-
} from '../lib/helpers/utils.js';
|
|
13
|
-
import { validateSquareOptions } from '../lib/helpers/validations.js';
|
|
14
|
-
import { loadImages } from '../lib/helpers/loadImages.js';
|
|
15
|
-
import { squareMerge } from '../lib/merges/square-merge/index.js';
|
|
16
|
-
|
|
17
|
-
const squareCommand = new Command('square');
|
|
18
|
-
|
|
19
|
-
squareCommand
|
|
20
|
-
.description('Use a uniform grid layout (all images are 1:1)')
|
|
21
|
-
.option('-m, --fit-mode <contain|cover>', 'Determines how to fit the images in their cells', 'contain')
|
|
22
|
-
.option('--is, --image-size <px>', 'The width and height of each image, defaults to the smallest image', null)
|
|
23
|
-
.option('-p, --padding-color <hex|transparent>', 'Image padding color', 'transparent')
|
|
24
|
-
.option('-c, --columns <n>', 'The number of columns', 4)
|
|
25
|
-
.option('--ca, --caption', 'Whether to caption each image', false)
|
|
26
|
-
.option('--cc, --caption-color <hex>', 'Caption color', '#000000')
|
|
27
|
-
.option('--mcs, --max-caption-size <pt>', 'The maximum allowed caption size', 100)
|
|
28
|
-
.action(async (files, opts) => {
|
|
29
|
-
await main(files, opts);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
const main = async (files, opts) => {
|
|
33
|
-
// Collect and validate parameters
|
|
34
|
-
try {
|
|
35
|
-
const validatedParams = getValidatedParams(files, opts, validateSquareOptions);
|
|
36
|
-
// console.log(validatedParams);
|
|
37
|
-
|
|
38
|
-
// Load images, create grid, and write grid on disk
|
|
39
|
-
await generateAndSaveGrid(validatedParams);
|
|
40
|
-
|
|
41
|
-
// Output success message
|
|
42
|
-
} catch (e) {
|
|
43
|
-
handleError(e);
|
|
44
|
-
}
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const generateAndSaveGrid = async (validatedParams) => {
|
|
48
|
-
const { files, images, ignoredFiles } = await loadImages(validatedParams);
|
|
49
|
-
|
|
50
|
-
// Display warnings if needed
|
|
51
|
-
if (ignoredFiles.length) {
|
|
52
|
-
displayWarningMessage('\nThese files will be ignored due to unsupported formats:');
|
|
53
|
-
for (const file of ignoredFiles) {
|
|
54
|
-
displayInfoMessage(file);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const confirmation = await cliConfirm('\nAre you sure you want to continue?');
|
|
58
|
-
if (!confirmation) return;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const grid = await squareMerge(files, images, validatedParams);
|
|
62
|
-
const success = await writeImage(grid, validatedParams.output);
|
|
63
|
-
|
|
64
|
-
// Display success message
|
|
65
|
-
if (success) {
|
|
66
|
-
displaySuccessMessage(`\nImage has been created successfully: ${chalk.bold(validatedParams.output)}\n`);
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
addSharedOptions(squareCommand);
|
|
71
|
-
export default squareCommand;
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
|
-
import sharp from 'sharp';
|
|
3
|
-
import { createSvgTextBuffer, getFontSize, getSmallestImageDimensions } from '../merge-utils.js';
|
|
4
|
-
import { progressBar, WRITING_TO_FILE_PERCENTAGE } from '../../helpers/progressBar.js';
|
|
5
|
-
|
|
6
|
-
export const squareMerge = async (files, images, opts) => {
|
|
7
|
-
const { gap, canvasColor, fitMode, imageSize, paddingColor, columns, caption, captionColor, maxCaptionSize } = opts;
|
|
8
|
-
|
|
9
|
-
// Determine target size
|
|
10
|
-
let newImageSize;
|
|
11
|
-
if (imageSize) {
|
|
12
|
-
// Use user-provided size directly
|
|
13
|
-
newImageSize = imageSize;
|
|
14
|
-
} else {
|
|
15
|
-
// Use smallest dimensions if imageSize is not provided
|
|
16
|
-
const { smallestWidth, smallestHeight } = await getSmallestImageDimensions(images);
|
|
17
|
-
newImageSize = Math.min(smallestWidth, smallestHeight);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Convert / normalize images (square, pad/crop, resize)
|
|
21
|
-
const normalizedImages = await normalizeImages(images, paddingColor, newImageSize, fitMode);
|
|
22
|
-
|
|
23
|
-
// Lay images in a grid and return
|
|
24
|
-
const grid = await layImagesInGrid({
|
|
25
|
-
files,
|
|
26
|
-
images: normalizedImages,
|
|
27
|
-
columns,
|
|
28
|
-
size: newImageSize,
|
|
29
|
-
gap,
|
|
30
|
-
canvasColor,
|
|
31
|
-
caption,
|
|
32
|
-
captionColor,
|
|
33
|
-
maxCaptionSize,
|
|
34
|
-
});
|
|
35
|
-
return grid;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export const layImagesInGrid = async (opts) => {
|
|
39
|
-
const CAPTION_HEIGHT_TO_CANVAS_WIDTH_RATIO = 0.04;
|
|
40
|
-
const { files, images, columns, size, gap, canvasColor, caption, captionColor, maxCaptionSize } = opts;
|
|
41
|
-
|
|
42
|
-
// Use 5% of images.length for writing to file
|
|
43
|
-
const fileWriteAmount = Math.ceil(images.length * WRITING_TO_FILE_PERCENTAGE);
|
|
44
|
-
progressBar.start(images.length + fileWriteAmount, 0, {
|
|
45
|
-
stage: 'Merging images',
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
// Calculate base variables
|
|
49
|
-
const rows = Math.ceil(images.length / columns);
|
|
50
|
-
const canvasWidth = columns * size + (columns + 1) * gap;
|
|
51
|
-
const captionHeight = Math.floor(canvasWidth * CAPTION_HEIGHT_TO_CANVAS_WIDTH_RATIO);
|
|
52
|
-
|
|
53
|
-
// Get filenames and fontsize if needed
|
|
54
|
-
let filenames = null;
|
|
55
|
-
let fontSize = null;
|
|
56
|
-
if (caption) {
|
|
57
|
-
filenames = files.map((file) => path.basename(file));
|
|
58
|
-
|
|
59
|
-
const longestFilename = filenames.reduce((longest, current) => {
|
|
60
|
-
return current.length > longest.length ? current : longest;
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
fontSize = await getFontSize({
|
|
64
|
-
text: longestFilename,
|
|
65
|
-
maxWidth: size,
|
|
66
|
-
maxHeight: captionHeight,
|
|
67
|
-
initialFontSize: maxCaptionSize,
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const minimumCanvasHeight = rows * size + (rows + 1) * gap;
|
|
72
|
-
const canvasHeight = caption ? minimumCanvasHeight + rows * captionHeight : minimumCanvasHeight;
|
|
73
|
-
|
|
74
|
-
// Create blank canvas
|
|
75
|
-
let canvas = sharp({
|
|
76
|
-
limitInputPixels: false,
|
|
77
|
-
create: {
|
|
78
|
-
width: canvasWidth,
|
|
79
|
-
height: canvasHeight,
|
|
80
|
-
channels: 4,
|
|
81
|
-
background: canvasColor,
|
|
82
|
-
},
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
// Build composite array
|
|
86
|
-
const composites = [];
|
|
87
|
-
|
|
88
|
-
for (let row = 0; row < rows; row++) {
|
|
89
|
-
for (let col = 0; col < columns; col++) {
|
|
90
|
-
const idx = row * columns + col;
|
|
91
|
-
if (idx >= images.length) break;
|
|
92
|
-
|
|
93
|
-
const x = gap + col * (size + gap);
|
|
94
|
-
const y = caption ? gap + row * (size + gap + captionHeight) : gap + row * (size + gap);
|
|
95
|
-
|
|
96
|
-
composites.push({
|
|
97
|
-
input: await images[idx].toBuffer(), // ensure buffer
|
|
98
|
-
left: x,
|
|
99
|
-
top: y,
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
// Add caption if required
|
|
103
|
-
if (caption) {
|
|
104
|
-
// Create text
|
|
105
|
-
const svgBuffer = createSvgTextBuffer({
|
|
106
|
-
text: filenames[idx],
|
|
107
|
-
maxWidth: size,
|
|
108
|
-
maxHeight: captionHeight,
|
|
109
|
-
fontSize,
|
|
110
|
-
fill: captionColor,
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// Add text to composites
|
|
114
|
-
composites.push({
|
|
115
|
-
input: svgBuffer,
|
|
116
|
-
left: x,
|
|
117
|
-
top: y + size,
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// Update progress bar
|
|
121
|
-
progressBar.increment();
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Composite all images at once
|
|
127
|
-
canvas = canvas.composite(composites);
|
|
128
|
-
|
|
129
|
-
return canvas;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const normalizeImages = async (images, paddingColor, targetSize, fitMode) => {
|
|
133
|
-
return images.map((image) =>
|
|
134
|
-
image.resize({
|
|
135
|
-
fit: fitMode,
|
|
136
|
-
width: targetSize,
|
|
137
|
-
height: targetSize,
|
|
138
|
-
background: paddingColor,
|
|
139
|
-
})
|
|
140
|
-
);
|
|
141
|
-
};
|