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,146 @@
|
|
|
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 buildVerticalMasonry = async (images, params) => {
|
|
6
|
+
const { gap, canvasColor, canvasHeight, columnWidth, vAlign } = 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 columnWidth
|
|
15
|
+
const scaledImages = await scaleImages(images, { width: columnWidth });
|
|
16
|
+
|
|
17
|
+
// Split images into columns, then calculate canvasWidth
|
|
18
|
+
const columns = await splitIntoColumns(scaledImages, canvasHeight, gap, vAlign);
|
|
19
|
+
console.log(columns.length);
|
|
20
|
+
columns.forEach((col) => {
|
|
21
|
+
console.log(col.length);
|
|
22
|
+
});
|
|
23
|
+
const canvasWidth = columns.length * columnWidth + (columns.length + 1) * gap;
|
|
24
|
+
|
|
25
|
+
// Create and return grid of images
|
|
26
|
+
return await createMasonryLayout(columns, columnWidth, canvasWidth, canvasHeight, canvasColor, gap, vAlign);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const createMasonryLayout = async (cols, columnWidth, canvasWidth, canvasHeight, canvasColor, gap, vAlign) => {
|
|
30
|
+
const composites = [];
|
|
31
|
+
|
|
32
|
+
const canvas = sharp({
|
|
33
|
+
create: {
|
|
34
|
+
width: canvasWidth,
|
|
35
|
+
height: canvasHeight,
|
|
36
|
+
channels: 4,
|
|
37
|
+
background: canvasColor,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
let x = gap;
|
|
42
|
+
let currentHeight = gap;
|
|
43
|
+
|
|
44
|
+
for (const col of cols) {
|
|
45
|
+
let y = await computeColYOffset(col, canvasHeight, gap, vAlign);
|
|
46
|
+
|
|
47
|
+
for (const im of col) {
|
|
48
|
+
let finalizedImage = im;
|
|
49
|
+
let finalizedMeta = await im.metadata();
|
|
50
|
+
|
|
51
|
+
currentHeight += finalizedMeta.height + gap;
|
|
52
|
+
|
|
53
|
+
if (currentHeight >= canvasHeight) {
|
|
54
|
+
const yOverflow = currentHeight - canvasHeight;
|
|
55
|
+
|
|
56
|
+
const resizeOptions = {
|
|
57
|
+
width: finalizedMeta.width,
|
|
58
|
+
height: finalizedMeta.height - yOverflow,
|
|
59
|
+
fit: 'cover',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const buffer = await finalizedImage.resize(resizeOptions).toBuffer();
|
|
63
|
+
finalizedImage = sharp(buffer);
|
|
64
|
+
finalizedMeta = await finalizedImage.metadata();
|
|
65
|
+
|
|
66
|
+
// Update progress bar
|
|
67
|
+
progressBar.increment();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
composites.push({
|
|
71
|
+
input: await finalizedImage.toBuffer(),
|
|
72
|
+
left: x,
|
|
73
|
+
top: y,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
y += finalizedMeta.height + gap;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
y = gap;
|
|
80
|
+
currentHeight = gap;
|
|
81
|
+
x += columnWidth + gap;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return canvas.composite(composites);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const splitIntoColumns = async (images, canvasHeight, gap, vAlign) => {
|
|
88
|
+
const cols = [];
|
|
89
|
+
const currentCol = [];
|
|
90
|
+
let currentHeight = gap;
|
|
91
|
+
|
|
92
|
+
for (const im of images) {
|
|
93
|
+
const meta = await im.metadata();
|
|
94
|
+
let nextHeight = currentHeight + meta.height + gap;
|
|
95
|
+
|
|
96
|
+
if (vAlign === 'justified') {
|
|
97
|
+
// Greedy: always push image, fix overflow later
|
|
98
|
+
currentCol.push(im);
|
|
99
|
+
currentHeight = nextHeight;
|
|
100
|
+
|
|
101
|
+
if (currentHeight + gap >= canvasHeight) {
|
|
102
|
+
cols.push(currentCol.slice());
|
|
103
|
+
currentCol.length = 0;
|
|
104
|
+
currentHeight = gap;
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
// Non-greedy: break BEFORE adding image that doesn't fit
|
|
108
|
+
if (nextHeight > canvasHeight && currentCol.length > 0) {
|
|
109
|
+
cols.push(currentCol.slice());
|
|
110
|
+
currentCol.length = 0;
|
|
111
|
+
currentHeight = gap;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Add the image (may be first in a new column)
|
|
115
|
+
currentCol.push(im);
|
|
116
|
+
currentHeight += meta.height + gap;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (currentCol.length > 0) {
|
|
121
|
+
cols.push(currentCol);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return cols;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const computeColYOffset = async (col, canvasHeight, gap, vAlign) => {
|
|
128
|
+
// Calculate total row width
|
|
129
|
+
let totalHeight = gap * (col.length + 1);
|
|
130
|
+
for (const im of col) {
|
|
131
|
+
const meta = await im.metadata();
|
|
132
|
+
totalHeight += meta.height;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Get x offset
|
|
136
|
+
if (vAlign === 'top' || vAlign === 'justified') {
|
|
137
|
+
return gap;
|
|
138
|
+
}
|
|
139
|
+
if (vAlign === 'bottom') {
|
|
140
|
+
return canvasHeight - totalHeight + gap;
|
|
141
|
+
}
|
|
142
|
+
if (vAlign === 'middle') {
|
|
143
|
+
const canvasGap = gap * 2;
|
|
144
|
+
return Math.floor((canvasHeight + canvasGap - totalHeight) / 2);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
|
|
3
|
+
export const calculateAvgHeight = async (images) => {
|
|
4
|
+
let totalHeight = 0;
|
|
5
|
+
|
|
6
|
+
for (const img of images) {
|
|
7
|
+
const meta = await img.metadata();
|
|
8
|
+
totalHeight += meta.height;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return Math.floor(totalHeight / images.length);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const calculateAvgWidth = async (images) => {
|
|
15
|
+
let totalWidth = 0;
|
|
16
|
+
|
|
17
|
+
for (const img of images) {
|
|
18
|
+
const meta = await img.metadata();
|
|
19
|
+
totalWidth += meta.width;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return Math.floor(totalWidth / images.length);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const scaleImages = async (images, { width = null, height = null }) => {
|
|
26
|
+
if (!width && !height) {
|
|
27
|
+
throw new Error('You must provide either width or height.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const scaledImages = await Promise.all(
|
|
31
|
+
images.map(async (image) => {
|
|
32
|
+
const meta = await image.metadata();
|
|
33
|
+
|
|
34
|
+
let targetWidth, targetHeight;
|
|
35
|
+
|
|
36
|
+
if (width) {
|
|
37
|
+
const f = width / meta.width;
|
|
38
|
+
targetWidth = width;
|
|
39
|
+
targetHeight = Math.floor(meta.height * f);
|
|
40
|
+
} else {
|
|
41
|
+
const f = height / meta.height;
|
|
42
|
+
targetHeight = height;
|
|
43
|
+
targetWidth = Math.floor(meta.width * f);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const buffer = await image.resize(targetWidth, targetHeight).toBuffer();
|
|
47
|
+
|
|
48
|
+
return sharp(buffer);
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return scaledImages;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const getSmallestImageDimensions = async (images) => {
|
|
56
|
+
const metas = await Promise.all(images.map((img) => img.metadata()));
|
|
57
|
+
|
|
58
|
+
return metas.reduce(
|
|
59
|
+
(acc, meta) => ({
|
|
60
|
+
smallestWidth: Math.min(acc.smallestWidth, meta.width),
|
|
61
|
+
smallestHeight: Math.min(acc.smallestHeight, meta.height),
|
|
62
|
+
}),
|
|
63
|
+
{ smallestWidth: Infinity, smallestHeight: Infinity }
|
|
64
|
+
);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const getFontSize = async ({
|
|
68
|
+
text,
|
|
69
|
+
maxWidth,
|
|
70
|
+
maxHeight,
|
|
71
|
+
initialFontSize = 100,
|
|
72
|
+
minFontSize = 2,
|
|
73
|
+
fontFamily = 'sans-serif',
|
|
74
|
+
}) => {
|
|
75
|
+
const THRESHOLD = 200;
|
|
76
|
+
const SMALL_CHANGE = 2;
|
|
77
|
+
const LARGE_CHANGE = 5;
|
|
78
|
+
|
|
79
|
+
let fontSize = initialFontSize;
|
|
80
|
+
|
|
81
|
+
while (fontSize >= minFontSize) {
|
|
82
|
+
// No width or viewport given so that the actual size can be determined after rasterization
|
|
83
|
+
const svg = `
|
|
84
|
+
<svg xmlns="http://www.w3.org/2000/svg">
|
|
85
|
+
<text
|
|
86
|
+
x="${maxWidth / 2}"
|
|
87
|
+
y="10"
|
|
88
|
+
font-size="${fontSize}"
|
|
89
|
+
font-family="${fontFamily}"
|
|
90
|
+
fill="#000000"
|
|
91
|
+
text-anchor="middle"
|
|
92
|
+
dominant-baseline="middle">
|
|
93
|
+
${escapeXML(text)}
|
|
94
|
+
</text>
|
|
95
|
+
</svg>
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
// Rasterize SVG: measure actual rendered size
|
|
99
|
+
const raster = await sharp(Buffer.from(svg)).png().toBuffer();
|
|
100
|
+
const meta = await sharp(raster).metadata();
|
|
101
|
+
|
|
102
|
+
if (meta.width <= maxWidth && meta.height <= maxHeight) {
|
|
103
|
+
return fontSize;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// If the difference is greater than the threshold, use large change
|
|
107
|
+
if (maxWidth - meta.width > THRESHOLD || maxHeight - meta.height) {
|
|
108
|
+
fontSize -= LARGE_CHANGE;
|
|
109
|
+
} else {
|
|
110
|
+
fontSize -= SMALL_CHANGE;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return minFontSize;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const createSvgTextBuffer = ({ text, maxWidth, maxHeight, fontSize, fill = '#000000', fontFamily = 'sans-serif' }) => {
|
|
118
|
+
// Width and viewport are assigned to this svg
|
|
119
|
+
const svg = `
|
|
120
|
+
<svg xmlns="http://www.w3.org/2000/svg"
|
|
121
|
+
width="${maxWidth}" height="${maxHeight}"
|
|
122
|
+
viewBox="0 0 ${maxWidth} ${maxHeight}">
|
|
123
|
+
<text
|
|
124
|
+
x="${maxWidth / 2}"
|
|
125
|
+
y="${maxHeight / 2}"
|
|
126
|
+
font-size="${fontSize}"
|
|
127
|
+
font-family="${fontFamily}"
|
|
128
|
+
fill="${fill}"
|
|
129
|
+
text-anchor="middle"
|
|
130
|
+
dominant-baseline="middle">
|
|
131
|
+
${escapeXML(text)}
|
|
132
|
+
</text>
|
|
133
|
+
</svg>
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
return Buffer.from(svg);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const escapeXML = (str) => {
|
|
140
|
+
return str.replace(
|
|
141
|
+
/[<>&'"]/g,
|
|
142
|
+
(c) =>
|
|
143
|
+
({
|
|
144
|
+
'<': '<',
|
|
145
|
+
'>': '>',
|
|
146
|
+
'&': '&',
|
|
147
|
+
'"': '"',
|
|
148
|
+
"'": ''',
|
|
149
|
+
}[c])
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/*
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
*/
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pixeli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A lightweight command-line tool for merging multiple images into customizable grid layouts.",
|
|
5
|
+
"homepage": "https://github.com/pakdad-mousavi/pixeli#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/pakdad-mousavi/pixeli/issues"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/pakdad-mousavi/pixeli.git"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"pixeli": "bin/pixeli.js"
|
|
15
|
+
},
|
|
16
|
+
"license": "ISC",
|
|
17
|
+
"author": "Pakdad Mousavi",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "index.js",
|
|
20
|
+
"files": [
|
|
21
|
+
"/lib",
|
|
22
|
+
"/bin",
|
|
23
|
+
"/commands"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"chalk": "^5.6.2",
|
|
30
|
+
"cli-progress": "^3.12.0",
|
|
31
|
+
"commander": "^14.0.2",
|
|
32
|
+
"sharp": "^0.34.5"
|
|
33
|
+
}
|
|
34
|
+
}
|