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.
@@ -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
+ };