pixeli 0.1.0
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 +3 -0
- package/bin/pixeli.js +23 -0
- package/commands/aspect.js +69 -0
- package/commands/masonry.js +70 -0
- package/commands/merge.js +12 -0
- package/commands/square.js +71 -0
- package/lib/helpers/loadImages.js +84 -0
- package/lib/helpers/progressBar.js +20 -0
- package/lib/helpers/utils.js +208 -0
- package/lib/helpers/validations.js +246 -0
- package/lib/merges/aspect-merge/index.js +148 -0
- package/lib/merges/masonry-merge/horizontal.js +147 -0
- package/lib/merges/masonry-merge/index.js +57 -0
- package/lib/merges/masonry-merge/vertical.js +146 -0
- package/lib/merges/merge-utils.js +156 -0
- package/lib/merges/square-merge/index.js +141 -0
- package/package.json +34 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import {
|
|
2
|
+
displayWarningMessage,
|
|
3
|
+
isSupportedOutputImage,
|
|
4
|
+
isValidHexadecimal,
|
|
5
|
+
parseAspectRatio,
|
|
6
|
+
SUPPORTED_OUTPUT_FORMATS,
|
|
7
|
+
} from './utils.js';
|
|
8
|
+
|
|
9
|
+
export const validateSharedOptions = (sharedOptions) => {
|
|
10
|
+
// Extract params
|
|
11
|
+
const { files, dir, recursive, shuffle, gap, canvasColor, output } = sharedOptions;
|
|
12
|
+
|
|
13
|
+
// Conduct validations
|
|
14
|
+
if ((!files || !files.length) && !dir) {
|
|
15
|
+
throw new Error('You must specify either [files...] or --dir.');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (isNaN(gap) || !Number.isInteger(Number(gap)) || gap < 0) {
|
|
19
|
+
throw new Error('--gap must be a positive integer.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (canvasColor !== 'transparent' && !isValidHexadecimal(canvasColor)) {
|
|
23
|
+
throw new Error('--canvas-color must be a valid hexadecimal value.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!isSupportedOutputImage(output)) {
|
|
27
|
+
throw new Error('Invalid output format. Choose one of the following: ' + SUPPORTED_OUTPUT_FORMATS.join(', '));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const formattedParams = {
|
|
31
|
+
files: files || [],
|
|
32
|
+
dir,
|
|
33
|
+
recursive,
|
|
34
|
+
shuffle,
|
|
35
|
+
gap: Number(gap),
|
|
36
|
+
canvasColor: canvasColor === 'transparent' ? { r: 0, g: 0, b: 0, alpha: 0 } : canvasColor,
|
|
37
|
+
output,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return formattedParams;
|
|
41
|
+
};
|
|
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
|
+
export const validateMasonryOptions = (sharedOptions, masonryOptions) => {
|
|
88
|
+
// Extract params
|
|
89
|
+
const { gap } = sharedOptions;
|
|
90
|
+
const { rowHeight, columnWidth, canvasWidth, canvasHeight, orientation, hAlign, vAlign } = masonryOptions;
|
|
91
|
+
|
|
92
|
+
// Define orientations and alignments for validation
|
|
93
|
+
const ORIENTATIONS = ['horizontal', 'vertical'];
|
|
94
|
+
const HORIZONTAL_ALIGNMENTS = ['left', 'center', 'right', 'justified'];
|
|
95
|
+
const VERTICAL_ALIGNMENTS = ['top', 'middle', 'bottom', 'justified'];
|
|
96
|
+
|
|
97
|
+
// Define orientation dependent options which are ignored if defined for the wrong orientation
|
|
98
|
+
const IGNORED_ORIENTATION_DEPENDENT_OPTIONS = {
|
|
99
|
+
horizontal: [
|
|
100
|
+
{
|
|
101
|
+
option: '--v-align',
|
|
102
|
+
value: vAlign,
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
option: '--canvas-height',
|
|
106
|
+
value: canvasHeight,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
option: '--column-width',
|
|
110
|
+
value: columnWidth,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
vertical: [
|
|
114
|
+
{
|
|
115
|
+
option: '--h-align',
|
|
116
|
+
value: hAlign,
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
option: '--canvas-width',
|
|
120
|
+
value: canvasWidth,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
option: '--row-height',
|
|
124
|
+
value: rowHeight,
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Validate orientations and alignments
|
|
130
|
+
if (!ORIENTATIONS.includes(orientation)) {
|
|
131
|
+
throw new Error('Invalid orientation. Choose one of the following: ' + ORIENTATIONS.join(', '));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (hAlign && !HORIZONTAL_ALIGNMENTS.includes(hAlign)) {
|
|
135
|
+
throw new Error('Invalid horizontal alignment. Choose one of the following: ' + HORIZONTAL_ALIGNMENTS.join(', '));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (vAlign && !VERTICAL_ALIGNMENTS.includes(vAlign)) {
|
|
139
|
+
throw new Error('Invalid vertical orientation. Choose one of the following: ' + VERTICAL_ALIGNMENTS.join(', '));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Ensure numeric values are positive integers
|
|
143
|
+
if (rowHeight && (isNaN(rowHeight) || !Number.isInteger(Number(rowHeight)) || Number(rowHeight) < 1)) {
|
|
144
|
+
throw new Error('--row-height must be a positive integer.');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (columnWidth && (isNaN(columnWidth) || !Number.isInteger(Number(columnWidth)) || Number(columnWidth) < 1)) {
|
|
148
|
+
throw new Error('--column-width must be a positive integer.');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Ensure canvas width is given
|
|
152
|
+
if (orientation === 'horizontal' && !canvasWidth) {
|
|
153
|
+
throw new Error('--canvas-width must be given.');
|
|
154
|
+
}
|
|
155
|
+
// and is a positive integer
|
|
156
|
+
else if (
|
|
157
|
+
orientation === 'horizontal' &&
|
|
158
|
+
(isNaN(canvasWidth) || !Number.isInteger(Number(canvasWidth)) || Number(canvasWidth) < 1)
|
|
159
|
+
) {
|
|
160
|
+
throw new Error('--canvas-width must be a positive integer.');
|
|
161
|
+
}
|
|
162
|
+
// and it accomodates for the minimum width needed
|
|
163
|
+
else if (orientation === 'horizontal' && canvasWidth <= gap * 2) {
|
|
164
|
+
throw new Error(`--canvas-width must be greater than 2 gaps or ${gap * 2}px.`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Ensure canvas height is given
|
|
168
|
+
if (orientation === 'vertical' && !canvasHeight) {
|
|
169
|
+
throw new Error('--canvas-height must be given.');
|
|
170
|
+
}
|
|
171
|
+
// and is a positive integer
|
|
172
|
+
else if (
|
|
173
|
+
orientation === 'vertical' &&
|
|
174
|
+
(isNaN(canvasHeight) || !Number.isInteger(Number(canvasHeight)) || Number(canvasHeight) < 1)
|
|
175
|
+
) {
|
|
176
|
+
throw new Error('--canvas-height must be a positive integer.');
|
|
177
|
+
}
|
|
178
|
+
// and it accomodates for the minimum height needed
|
|
179
|
+
else if (orientation === 'vertical' && canvasHeight <= gap * 2) {
|
|
180
|
+
throw new Error(`--canvas-height must be greater than 2 gaps or ${gap * 2}px.`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Validate dependent options by showing warnings when incorrect parameters
|
|
184
|
+
// are used with incorrect orientation
|
|
185
|
+
const ignoredOrientationOptions = IGNORED_ORIENTATION_DEPENDENT_OPTIONS[orientation];
|
|
186
|
+
for (const { option, value } of ignoredOrientationOptions) {
|
|
187
|
+
if (value) {
|
|
188
|
+
displayWarningMessage(`"${option}" option is ignored due to ${orientation} orientation.`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const params = {
|
|
193
|
+
rowHeight: Number(rowHeight) || null,
|
|
194
|
+
columnWidth: Number(columnWidth) || null,
|
|
195
|
+
canvasHeight: Number(canvasHeight) || null,
|
|
196
|
+
canvasWidth: Number(canvasWidth) || null,
|
|
197
|
+
orientation,
|
|
198
|
+
hAlign,
|
|
199
|
+
vAlign,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return params;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export const validateAspectOptions = (sharedOptions, squareOptions) => {
|
|
206
|
+
// Extract params
|
|
207
|
+
const { aspectRatio, imageWidth, columns, caption, captionColor, maxCaptionSize } = squareOptions;
|
|
208
|
+
|
|
209
|
+
// Ensure aspect ratio is given
|
|
210
|
+
if (!aspectRatio) {
|
|
211
|
+
throw new Error('--aspect-ratio must be provided.');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Ensure aspect ratio is valid
|
|
215
|
+
const parsedAspectRatio = parseAspectRatio(aspectRatio);
|
|
216
|
+
if (!parsedAspectRatio) {
|
|
217
|
+
throw new Error('--aspect-ratio must be a valid ratio. Examples: 16/9, 2:3, 1x2, 1.77');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (imageWidth && (isNaN(imageWidth) || !Number.isInteger(Number(imageWidth)) || Number(imageWidth) < 1)) {
|
|
221
|
+
throw new Error('--image-width must be a positive integer.');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (isNaN(columns) || !Number.isInteger(Number(columns)) || Number(columns) < 1) {
|
|
225
|
+
throw new Error('--columns must be a positive integer.');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (isNaN(maxCaptionSize) || !Number.isInteger(Number(maxCaptionSize)) || Number(maxCaptionSize) < 2) {
|
|
229
|
+
throw new Error('--max-caption-size must be a positive integer >= 2 (minimum caption size).');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!isValidHexadecimal(captionColor)) {
|
|
233
|
+
throw new Error('--caption-color must be a valid hexadecimal value.');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const formattedParams = {
|
|
237
|
+
aspectRatio: parsedAspectRatio,
|
|
238
|
+
imageWidth: Number(imageWidth) || null,
|
|
239
|
+
columns: Number(columns),
|
|
240
|
+
caption,
|
|
241
|
+
captionColor,
|
|
242
|
+
maxCaptionSize,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
return formattedParams;
|
|
246
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import sharp from 'sharp';
|
|
3
|
+
import { getSmallestImageDimensions, getFontSize, createSvgTextBuffer } from '../merge-utils.js';
|
|
4
|
+
import { progressBar, WRITING_TO_FILE_PERCENTAGE } from '../../helpers/progressBar.js';
|
|
5
|
+
|
|
6
|
+
export const aspectMerge = async (files, images, validatedParams) => {
|
|
7
|
+
// Destructure params
|
|
8
|
+
const { aspectRatio, imageWidth, columns, gap, canvasColor, caption, captionColor, maxCaptionSize } = validatedParams;
|
|
9
|
+
|
|
10
|
+
// Calculate width if needed, and height from aspect ratio
|
|
11
|
+
const width = imageWidth || (await getSmallestImageDimensions(images)).smallestWidth;
|
|
12
|
+
const height = Math.floor(width / aspectRatio);
|
|
13
|
+
|
|
14
|
+
// resize images to match width and height
|
|
15
|
+
const resizedImages = images.map((image) => {
|
|
16
|
+
return image.resize({
|
|
17
|
+
width,
|
|
18
|
+
height,
|
|
19
|
+
fit: 'fill',
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Get filenames if needed
|
|
24
|
+
let filenames = null;
|
|
25
|
+
if (caption) {
|
|
26
|
+
filenames = files.map((file) => path.basename(file));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Lay images in a grid
|
|
30
|
+
const gridParams = {
|
|
31
|
+
images: resizedImages,
|
|
32
|
+
width,
|
|
33
|
+
height,
|
|
34
|
+
columns,
|
|
35
|
+
gap,
|
|
36
|
+
canvasColor,
|
|
37
|
+
filenames,
|
|
38
|
+
caption,
|
|
39
|
+
captionColor,
|
|
40
|
+
maxCaptionSize,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return await layImagesInGrid(gridParams);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const layImagesInGrid = async (opts) => {
|
|
47
|
+
// Destructure params
|
|
48
|
+
const { images, width, height, columns, gap, canvasColor, filenames, caption, captionColor, maxCaptionSize } = opts;
|
|
49
|
+
|
|
50
|
+
// Use 5% of images.length for writing to file
|
|
51
|
+
const fileWriteAmount = Math.ceil(images.length * WRITING_TO_FILE_PERCENTAGE);
|
|
52
|
+
progressBar.start(images.length + fileWriteAmount, 0, {
|
|
53
|
+
stage: 'Merging images',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Set constant
|
|
57
|
+
const CAPTION_HEIGHT_TO_CANVAS_WIDTH_RATIO = 0.04;
|
|
58
|
+
|
|
59
|
+
// Calculate number of rows
|
|
60
|
+
const rows = Math.ceil(images.length / columns);
|
|
61
|
+
|
|
62
|
+
// Calculate canvas width and caption height
|
|
63
|
+
const canvasWidth = width * columns + (columns + 1) * gap;
|
|
64
|
+
const captionHeight = Math.floor(canvasWidth * CAPTION_HEIGHT_TO_CANVAS_WIDTH_RATIO);
|
|
65
|
+
|
|
66
|
+
// Calculate canvas height
|
|
67
|
+
const minimumCanvasHeight = height * rows + (rows + 1) * gap;
|
|
68
|
+
const canvasHeight = caption ? minimumCanvasHeight + rows * captionHeight : minimumCanvasHeight;
|
|
69
|
+
|
|
70
|
+
// Calculate font size if needed
|
|
71
|
+
let fontSize = null;
|
|
72
|
+
if (caption) {
|
|
73
|
+
const longestFilename = filenames.reduce((longest, current) => {
|
|
74
|
+
return current.length > longest.length ? current : longest;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
fontSize = await getFontSize({
|
|
78
|
+
text: longestFilename,
|
|
79
|
+
maxWidth: width,
|
|
80
|
+
maxHeight: captionHeight,
|
|
81
|
+
initialFontSize: maxCaptionSize,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Create canvas
|
|
86
|
+
const canvas = sharp({
|
|
87
|
+
create: {
|
|
88
|
+
width: canvasWidth,
|
|
89
|
+
height: canvasHeight,
|
|
90
|
+
channels: 4,
|
|
91
|
+
background: canvasColor,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Collect composites
|
|
96
|
+
const composites = [];
|
|
97
|
+
|
|
98
|
+
let x = gap;
|
|
99
|
+
let y = gap;
|
|
100
|
+
|
|
101
|
+
for (let row = 0; row < rows; row++) {
|
|
102
|
+
for (let col = 0; col < columns; col++) {
|
|
103
|
+
const index = row * columns + col;
|
|
104
|
+
if (index >= images.length) break;
|
|
105
|
+
|
|
106
|
+
const image = images[index];
|
|
107
|
+
|
|
108
|
+
composites.push({
|
|
109
|
+
input: await image.toBuffer(),
|
|
110
|
+
left: x,
|
|
111
|
+
top: y,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Add caption if required
|
|
115
|
+
if (caption) {
|
|
116
|
+
// Create text
|
|
117
|
+
const svgBuffer = createSvgTextBuffer({
|
|
118
|
+
text: filenames[index],
|
|
119
|
+
maxWidth: width,
|
|
120
|
+
maxHeight: captionHeight,
|
|
121
|
+
fontSize,
|
|
122
|
+
fill: captionColor,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Add text to composites
|
|
126
|
+
composites.push({
|
|
127
|
+
input: svgBuffer,
|
|
128
|
+
left: x,
|
|
129
|
+
top: y + height,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Update coordinates
|
|
134
|
+
x += width + gap;
|
|
135
|
+
|
|
136
|
+
// Update progress bar
|
|
137
|
+
progressBar.increment();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Update coordinates
|
|
141
|
+
y += caption ? height + gap + captionHeight : height + gap;
|
|
142
|
+
x = gap;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Create final grid
|
|
146
|
+
canvas.composite(composites);
|
|
147
|
+
return canvas;
|
|
148
|
+
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { scaleImages } from '../merge-utils.js';
|
|
3
|
+
import { progressBar, WRITING_TO_FILE_PERCENTAGE } from '../../helpers/progressBar.js';
|
|
4
|
+
|
|
5
|
+
export const buildHorizontalMasonry = async (images, params) => {
|
|
6
|
+
const { gap, canvasColor, canvasWidth, rowHeight, hAlign } = params;
|
|
7
|
+
|
|
8
|
+
// Use 5% of images.length for writing to file
|
|
9
|
+
const fileWriteAmount = Math.ceil(images.length * WRITING_TO_FILE_PERCENTAGE);
|
|
10
|
+
progressBar.start(images.length + fileWriteAmount, 0, {
|
|
11
|
+
stage: 'Merging images',
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Rescale images to match rowHeight
|
|
15
|
+
const scaledImages = await scaleImages(images, { height: rowHeight });
|
|
16
|
+
|
|
17
|
+
// Split images into rows, then calculate canvasHeight
|
|
18
|
+
const rows = await splitIntoRows(scaledImages, canvasWidth, gap, hAlign);
|
|
19
|
+
const canvasHeight = rows.length * rowHeight + (rows.length + 1) * gap;
|
|
20
|
+
|
|
21
|
+
// Create and return grid of images
|
|
22
|
+
return await createMasonryLayout(rows, rowHeight, canvasWidth, canvasHeight, canvasColor, gap, hAlign);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const createMasonryLayout = async (rows, rowHeight, canvasWidth, canvasHeight, canvasColor, gap, hAlign) => {
|
|
26
|
+
const canvas = sharp({
|
|
27
|
+
create: {
|
|
28
|
+
width: canvasWidth,
|
|
29
|
+
height: canvasHeight,
|
|
30
|
+
channels: 4,
|
|
31
|
+
background: canvasColor,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const composites = [];
|
|
36
|
+
|
|
37
|
+
let currentWidth = gap;
|
|
38
|
+
let x = gap;
|
|
39
|
+
let y = gap;
|
|
40
|
+
|
|
41
|
+
for (const row of rows) {
|
|
42
|
+
const rowXStart = await computeRowXOffset(row, canvasWidth, gap, hAlign);
|
|
43
|
+
x = rowXStart;
|
|
44
|
+
|
|
45
|
+
for (const im of row) {
|
|
46
|
+
const meta = await im.metadata();
|
|
47
|
+
let finalizedImage = im;
|
|
48
|
+
let finalizedMeta = meta;
|
|
49
|
+
currentWidth += meta.width + gap;
|
|
50
|
+
|
|
51
|
+
if (currentWidth >= canvasWidth) {
|
|
52
|
+
// Calculate overflow
|
|
53
|
+
const overflow = currentWidth - canvasWidth;
|
|
54
|
+
|
|
55
|
+
// Resize (crop) image to justify
|
|
56
|
+
const resizeOptions = {
|
|
57
|
+
width: meta.width - overflow,
|
|
58
|
+
height: meta.height,
|
|
59
|
+
fit: 'cover',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Update finalized image and metadata
|
|
63
|
+
const buff = await im.resize(resizeOptions).toBuffer();
|
|
64
|
+
finalizedImage = sharp(buff);
|
|
65
|
+
finalizedMeta = await finalizedImage.metadata();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
composites.push({
|
|
69
|
+
input: await finalizedImage.toBuffer(),
|
|
70
|
+
left: x,
|
|
71
|
+
top: y,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
x += finalizedMeta.width + gap;
|
|
75
|
+
|
|
76
|
+
// Update progress
|
|
77
|
+
progressBar.increment();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
x = gap;
|
|
81
|
+
currentWidth = gap;
|
|
82
|
+
y += rowHeight + gap;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return canvas.composite(composites);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const splitIntoRows = async (images, canvasWidth, gap, hAlign) => {
|
|
89
|
+
const rows = [];
|
|
90
|
+
let currentRow = [];
|
|
91
|
+
let currentWidth = gap; // initial leading gap
|
|
92
|
+
|
|
93
|
+
for (const im of images) {
|
|
94
|
+
const meta = await im.metadata();
|
|
95
|
+
const nextWidth = currentWidth + meta.width + gap;
|
|
96
|
+
|
|
97
|
+
if (hAlign === 'justified') {
|
|
98
|
+
// Greedy: always push image, fix overflow later
|
|
99
|
+
currentRow.push(im);
|
|
100
|
+
currentWidth = nextWidth;
|
|
101
|
+
|
|
102
|
+
if (currentWidth + gap >= canvasWidth) {
|
|
103
|
+
rows.push(currentRow.slice());
|
|
104
|
+
currentRow.length = 0;
|
|
105
|
+
currentWidth = gap;
|
|
106
|
+
}
|
|
107
|
+
} else {
|
|
108
|
+
// Non-greedy: break BEFORE adding image that doesn't fit
|
|
109
|
+
if (nextWidth > canvasWidth && currentRow.length > 0) {
|
|
110
|
+
rows.push(currentRow.slice());
|
|
111
|
+
currentRow = [];
|
|
112
|
+
currentWidth = gap;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Add the image (may be first in a new row)
|
|
116
|
+
currentRow.push(im);
|
|
117
|
+
currentWidth += meta.width + gap;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (currentRow.length > 0) {
|
|
122
|
+
rows.push(currentRow);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return rows;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const computeRowXOffset = async (row, canvasWidth, gap, hAlign) => {
|
|
129
|
+
// Calculate total row width
|
|
130
|
+
let totalWidth = gap * (row.length + 1);
|
|
131
|
+
for (const im of row) {
|
|
132
|
+
const meta = await im.metadata();
|
|
133
|
+
totalWidth += meta.width;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Get x offset
|
|
137
|
+
if (hAlign === 'left' || hAlign === 'justified') {
|
|
138
|
+
return gap;
|
|
139
|
+
}
|
|
140
|
+
if (hAlign === 'right') {
|
|
141
|
+
return canvasWidth - totalWidth + gap;
|
|
142
|
+
}
|
|
143
|
+
if (hAlign === 'center') {
|
|
144
|
+
const canvasGap = gap * 2;
|
|
145
|
+
return Math.floor((canvasWidth + canvasGap - totalWidth) / 2);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { calculateAvgWidth, calculateAvgHeight } from '../merge-utils.js';
|
|
2
|
+
import { buildHorizontalMasonry } from './horizontal.js';
|
|
3
|
+
import { buildVerticalMasonry } from './vertical.js';
|
|
4
|
+
|
|
5
|
+
const ORIENTATION_DEFAULTS = {
|
|
6
|
+
horizontal: {
|
|
7
|
+
needed: ['canvasWidth', 'rowHeight', 'hAlign'],
|
|
8
|
+
defaults: {
|
|
9
|
+
rowHeight: calculateAvgHeight,
|
|
10
|
+
hAlign: () => 'justified',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
vertical: {
|
|
14
|
+
needed: ['canvasHeight', 'columnWidth', 'vAlign'],
|
|
15
|
+
defaults: {
|
|
16
|
+
columnWidth: calculateAvgWidth,
|
|
17
|
+
vAlign: () => 'justified',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const masonryMerge = async (images, opts) => {
|
|
23
|
+
const { orientation } = opts;
|
|
24
|
+
const params = await getOrientationSpecificParams(images, opts);
|
|
25
|
+
|
|
26
|
+
return await generateGrid(orientation, images, params);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const getOrientationSpecificParams = async (images, currentParams) => {
|
|
30
|
+
const { orientation, gap, canvasColor } = currentParams;
|
|
31
|
+
const config = ORIENTATION_DEFAULTS[orientation];
|
|
32
|
+
|
|
33
|
+
const output = { gap, canvasColor };
|
|
34
|
+
|
|
35
|
+
for (const key of config.needed) {
|
|
36
|
+
if (currentParams[key] != null) {
|
|
37
|
+
output[key] = currentParams[key];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Assign static defaults
|
|
42
|
+
for (const [key, getter] of Object.entries(config.defaults)) {
|
|
43
|
+
if (output[key] == null) {
|
|
44
|
+
output[key] = await getter(images);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return output;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const generateGrid = async (orientation, images, params) => {
|
|
52
|
+
if (orientation === 'horizontal') {
|
|
53
|
+
return await buildHorizontalMasonry(images, params);
|
|
54
|
+
} else {
|
|
55
|
+
return buildVerticalMasonry(images, params);
|
|
56
|
+
}
|
|
57
|
+
};
|