logo-soup 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/LICENSE +21 -0
- package/README.md +129 -0
- package/bin/logo-soup.mjs +2 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +96 -0
- package/dist/index.d.mts +73 -0
- package/dist/index.mjs +3 -0
- package/dist/normalize-BN3StFfP.mjs +219 -0
- package/package.json +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-PRESENT Johann Schopplich
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# 🍜 logo-soup
|
|
2
|
+
|
|
3
|
+
> [!NOTE]
|
|
4
|
+
> Inspired by [React Logo Soup](https://github.com/sanity-labs/react-logo-soup) by [Sanity](https://github.com/sanity-labs). logo-soup is a framework-agnostic Node.js port using `sharp` for server-side analysis.
|
|
5
|
+
|
|
6
|
+
Logos come in all shapes – wide wordmarks, dense icons, tall monograms – and displaying them at the same CSS size makes some look huge while others nearly vanish.
|
|
7
|
+
|
|
8
|
+
logo-soup analyzes SVG/PNG images with `sharp`, detects their content bounding box, measures pixel density and visual center, then normalizes dimensions so every logo feels perceptually balanced.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pnpm add logo-soup
|
|
14
|
+
|
|
15
|
+
# Or run directly
|
|
16
|
+
npx logo-soup ./logos
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## CLI
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
logo-soup ./public/logos -o logo-metrics.json
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Output JSON keys are filenames only (e.g. `"logo.svg"`), so consumers can prepend their own base path.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
logo-soup <dir> [options]
|
|
29
|
+
|
|
30
|
+
Options:
|
|
31
|
+
--output, -o Output JSON file path (default: "logo-metrics.json")
|
|
32
|
+
--base-size Base size for normalization in px (default: 48)
|
|
33
|
+
--scale-factor Aspect ratio normalization 0-1 (default: 0.5)
|
|
34
|
+
--density-factor Density compensation 0-1 (default: 0.5)
|
|
35
|
+
--extensions, -e Comma-separated file extensions (default: "svg,png")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Programmatic API
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { analyze, analyzeDirectory, normalize } from 'logo-soup'
|
|
42
|
+
|
|
43
|
+
// Single file
|
|
44
|
+
const metrics = await analyze('./logo.svg')
|
|
45
|
+
if (metrics) {
|
|
46
|
+
const dimensions = normalize(metrics, { baseSize: 48 })
|
|
47
|
+
console.log(dimensions) // { width, height, offsetX, offsetY }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Batch
|
|
51
|
+
const results = await analyzeDirectory('./logos', { extensions: ['svg', 'png'] })
|
|
52
|
+
for (const [file, metrics] of results) {
|
|
53
|
+
const dimensions = normalize(metrics)
|
|
54
|
+
console.log(file, dimensions)
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### `analyze`
|
|
59
|
+
|
|
60
|
+
Analyzes a single logo image and returns its metrics, or `undefined` if no content is detected. Throws on I/O or decode errors.
|
|
61
|
+
|
|
62
|
+
**Type Declaration:**
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
function analyze(filePath: string, options?: AnalyzeOptions): Promise<Metrics | undefined>
|
|
66
|
+
|
|
67
|
+
interface AnalyzeOptions {
|
|
68
|
+
/** Maximum dimension for the resampled image used during analysis (default: 200) */
|
|
69
|
+
sampleMaxSize?: number
|
|
70
|
+
/** Minimum contrast threshold to consider a pixel as content (default: 10) */
|
|
71
|
+
contrastThreshold?: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface Metrics {
|
|
75
|
+
contentRatio: number
|
|
76
|
+
pixelDensity: number
|
|
77
|
+
visualCenterX: number
|
|
78
|
+
visualCenterY: number
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### `analyzeDirectory`
|
|
83
|
+
|
|
84
|
+
Analyzes all matching images in a directory. Returns a `Map<string, Metrics>` where keys are filenames.
|
|
85
|
+
|
|
86
|
+
**Type Declaration:**
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
function analyzeDirectory(dirPath: string, options?: AnalyzeDirectoryOptions): Promise<Map<string, Metrics>>
|
|
90
|
+
|
|
91
|
+
interface AnalyzeDirectoryOptions extends AnalyzeOptions {
|
|
92
|
+
/** File extensions to include, without dots (default: ["svg", "png"]) */
|
|
93
|
+
extensions?: string[]
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `normalize`
|
|
98
|
+
|
|
99
|
+
Converts raw metrics into display dimensions using aspect ratio normalization with density compensation.
|
|
100
|
+
|
|
101
|
+
**Type Declaration:**
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
function normalize(metrics: Metrics, options?: NormalizeOptions): NormalizedDimensions
|
|
105
|
+
|
|
106
|
+
interface NormalizeOptions {
|
|
107
|
+
/** Base size in pixels (default: 48) */
|
|
108
|
+
baseSize?: number
|
|
109
|
+
/** Aspect ratio normalization factor, 0–1 (default: 0.5) */
|
|
110
|
+
scaleFactor?: number
|
|
111
|
+
/** Density compensation factor, 0–1 (default: 0.5) */
|
|
112
|
+
densityFactor?: number
|
|
113
|
+
/** Dampening exponent for density compensation (default: 0.5) */
|
|
114
|
+
densityDampening?: number
|
|
115
|
+
/** Reference density value for compensation scaling (default: 0.35) */
|
|
116
|
+
referenceDensity?: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface NormalizedDimensions {
|
|
120
|
+
width: number
|
|
121
|
+
height: number
|
|
122
|
+
offsetX: number
|
|
123
|
+
offsetY: number
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
[MIT](./LICENSE) License © 2025-PRESENT [Johann Schopplich](https://github.com/johannschopplich)
|
package/dist/cli.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { c as DENSITY_FACTOR, d as SCALE_FACTOR, i as BASE_SIZE, o as DEFAULT_EXTENSIONS, r as analyzeDirectory, t as normalize } from "./normalize-BN3StFfP.mjs";
|
|
2
|
+
import * as fsp from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { defineCommand, runMain } from "citty";
|
|
6
|
+
import { consola } from "consola";
|
|
7
|
+
|
|
8
|
+
//#region package.json
|
|
9
|
+
var name = "logo-soup";
|
|
10
|
+
var version = "0.1.0";
|
|
11
|
+
var description = "Normalize logo dimensions for visual balance";
|
|
12
|
+
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/cli.ts
|
|
15
|
+
const command = defineCommand({
|
|
16
|
+
meta: {
|
|
17
|
+
name,
|
|
18
|
+
version,
|
|
19
|
+
description
|
|
20
|
+
},
|
|
21
|
+
args: {
|
|
22
|
+
"dir": {
|
|
23
|
+
type: "positional",
|
|
24
|
+
description: "Directory containing logo images",
|
|
25
|
+
required: true
|
|
26
|
+
},
|
|
27
|
+
"output": {
|
|
28
|
+
type: "string",
|
|
29
|
+
alias: "o",
|
|
30
|
+
description: "Output JSON file path",
|
|
31
|
+
default: "logo-metrics.json"
|
|
32
|
+
},
|
|
33
|
+
"base-size": {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: `Base size for normalization in px (default: ${BASE_SIZE})`
|
|
36
|
+
},
|
|
37
|
+
"scale-factor": {
|
|
38
|
+
type: "string",
|
|
39
|
+
description: `Aspect ratio normalization 0-1 (default: ${SCALE_FACTOR})`
|
|
40
|
+
},
|
|
41
|
+
"density-factor": {
|
|
42
|
+
type: "string",
|
|
43
|
+
description: `Density compensation 0-1 (default: ${DENSITY_FACTOR})`
|
|
44
|
+
},
|
|
45
|
+
"extensions": {
|
|
46
|
+
type: "string",
|
|
47
|
+
alias: "e",
|
|
48
|
+
description: `Comma-separated file extensions (default: "${DEFAULT_EXTENSIONS.join(",")}")`
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
async run({ args }) {
|
|
52
|
+
const dirPath = path.resolve(args.dir);
|
|
53
|
+
try {
|
|
54
|
+
if (!(await fsp.stat(dirPath)).isDirectory()) {
|
|
55
|
+
consola.error(`Not a directory: ${dirPath}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
consola.error(`Directory not found: ${dirPath}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
const baseSize = parseNumericArg(args["base-size"], "base-size", BASE_SIZE);
|
|
63
|
+
const scaleFactor = parseNumericArg(args["scale-factor"], "scale-factor", SCALE_FACTOR);
|
|
64
|
+
const densityFactor = parseNumericArg(args["density-factor"], "density-factor", DENSITY_FACTOR);
|
|
65
|
+
const extensions = args.extensions ? args.extensions.split(",").map((ext) => ext.trim().toLowerCase()) : DEFAULT_EXTENSIONS;
|
|
66
|
+
consola.start(`Analyzing logos in ${dirPath}`);
|
|
67
|
+
const metricsMap = await analyzeDirectory(dirPath, { extensions });
|
|
68
|
+
const results = {};
|
|
69
|
+
for (const [file, metrics] of metricsMap) {
|
|
70
|
+
const dimensions = normalize(metrics, {
|
|
71
|
+
baseSize,
|
|
72
|
+
scaleFactor,
|
|
73
|
+
densityFactor
|
|
74
|
+
});
|
|
75
|
+
results[file] = dimensions;
|
|
76
|
+
consola.log(` ${file} → ${dimensions.width}×${dimensions.height}px`);
|
|
77
|
+
}
|
|
78
|
+
const outputPath = path.resolve(args.output);
|
|
79
|
+
await fsp.mkdir(path.dirname(outputPath), { recursive: true });
|
|
80
|
+
await fsp.writeFile(outputPath, `${JSON.stringify(results, null, 2)}\n`);
|
|
81
|
+
consola.success(`Wrote ${Object.keys(results).length} entries to ${outputPath}`);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
function parseNumericArg(value, name, fallback) {
|
|
85
|
+
if (value === void 0) return fallback;
|
|
86
|
+
const parsed = Number(value);
|
|
87
|
+
if (Number.isNaN(parsed)) {
|
|
88
|
+
consola.error(`Invalid value for --${name}: "${value}" (expected a number)`);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
return parsed;
|
|
92
|
+
}
|
|
93
|
+
runMain(command);
|
|
94
|
+
|
|
95
|
+
//#endregion
|
|
96
|
+
export { };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
interface Metrics {
|
|
3
|
+
contentRatio: number;
|
|
4
|
+
pixelDensity: number;
|
|
5
|
+
visualCenterX: number;
|
|
6
|
+
visualCenterY: number;
|
|
7
|
+
}
|
|
8
|
+
interface NormalizedDimensions {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
offsetX: number;
|
|
12
|
+
offsetY: number;
|
|
13
|
+
}
|
|
14
|
+
interface AnalyzeOptions {
|
|
15
|
+
/** Maximum dimension for the resampled image used during analysis. */
|
|
16
|
+
sampleMaxSize?: number;
|
|
17
|
+
/** Minimum contrast threshold to consider a pixel as content. */
|
|
18
|
+
contrastThreshold?: number;
|
|
19
|
+
}
|
|
20
|
+
interface NormalizeOptions {
|
|
21
|
+
/** Base size in pixels for the normalized output. */
|
|
22
|
+
baseSize?: number;
|
|
23
|
+
/** Aspect ratio normalization factor (0–1). */
|
|
24
|
+
scaleFactor?: number;
|
|
25
|
+
/** Density compensation factor (0–1). */
|
|
26
|
+
densityFactor?: number;
|
|
27
|
+
/** Dampening exponent for density compensation. */
|
|
28
|
+
densityDampening?: number;
|
|
29
|
+
/** Reference density value for compensation scaling. */
|
|
30
|
+
referenceDensity?: number;
|
|
31
|
+
}
|
|
32
|
+
interface AnalyzeDirectoryOptions extends AnalyzeOptions {
|
|
33
|
+
/** File extensions to include (without dots). */
|
|
34
|
+
extensions?: string[];
|
|
35
|
+
}
|
|
36
|
+
interface ContentBox {
|
|
37
|
+
x: number;
|
|
38
|
+
y: number;
|
|
39
|
+
width: number;
|
|
40
|
+
height: number;
|
|
41
|
+
}
|
|
42
|
+
interface RGB {
|
|
43
|
+
r: number;
|
|
44
|
+
g: number;
|
|
45
|
+
b: number;
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/analyze.d.ts
|
|
49
|
+
declare function analyze(filePath: string, options?: AnalyzeOptions): Promise<Metrics | undefined>;
|
|
50
|
+
declare function analyzeDirectory(dirPath: string, options?: AnalyzeDirectoryOptions): Promise<Map<string, Metrics>>;
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/defaults.d.ts
|
|
53
|
+
/** Maximum dimension for the resampled image used during analysis. */
|
|
54
|
+
declare const SAMPLE_MAX_SIZE: number;
|
|
55
|
+
/** Minimum contrast threshold to consider a pixel as content. */
|
|
56
|
+
declare const CONTRAST_THRESHOLD: number;
|
|
57
|
+
/** Base size in pixels for the normalized output. */
|
|
58
|
+
declare const BASE_SIZE: number;
|
|
59
|
+
/** Aspect ratio normalization factor (0–1). */
|
|
60
|
+
declare const SCALE_FACTOR: number;
|
|
61
|
+
/** Density compensation factor (0–1). */
|
|
62
|
+
declare const DENSITY_FACTOR: number;
|
|
63
|
+
/** Dampening exponent for density compensation. */
|
|
64
|
+
declare const DENSITY_DAMPENING: number;
|
|
65
|
+
/** Reference density value for compensation scaling. */
|
|
66
|
+
declare const REFERENCE_DENSITY: number;
|
|
67
|
+
/** Default file extensions to scan. */
|
|
68
|
+
declare const DEFAULT_EXTENSIONS: string[];
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/normalize.d.ts
|
|
71
|
+
declare function normalize(metrics: Metrics, options?: NormalizeOptions): NormalizedDimensions;
|
|
72
|
+
//#endregion
|
|
73
|
+
export { type AnalyzeDirectoryOptions, type AnalyzeOptions, BASE_SIZE, CONTRAST_THRESHOLD, type ContentBox, DEFAULT_EXTENSIONS, DENSITY_DAMPENING, DENSITY_FACTOR, type Metrics, type NormalizeOptions, type NormalizedDimensions, REFERENCE_DENSITY, type RGB, SAMPLE_MAX_SIZE, SCALE_FACTOR, analyze, analyzeDirectory, normalize };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { a as CONTRAST_THRESHOLD, c as DENSITY_FACTOR, d as SCALE_FACTOR, i as BASE_SIZE, l as REFERENCE_DENSITY, n as analyze, o as DEFAULT_EXTENSIONS, r as analyzeDirectory, s as DENSITY_DAMPENING, t as normalize, u as SAMPLE_MAX_SIZE } from "./normalize-BN3StFfP.mjs";
|
|
2
|
+
|
|
3
|
+
export { BASE_SIZE, CONTRAST_THRESHOLD, DEFAULT_EXTENSIONS, DENSITY_DAMPENING, DENSITY_FACTOR, REFERENCE_DENSITY, SAMPLE_MAX_SIZE, SCALE_FACTOR, analyze, analyzeDirectory, normalize };
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import * as fsp from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
|
|
5
|
+
//#region src/defaults.ts
|
|
6
|
+
/** Maximum dimension for the resampled image used during analysis. */
|
|
7
|
+
const SAMPLE_MAX_SIZE = 200;
|
|
8
|
+
/** Minimum contrast threshold to consider a pixel as content. */
|
|
9
|
+
const CONTRAST_THRESHOLD = 10;
|
|
10
|
+
/** Base size in pixels for the normalized output. */
|
|
11
|
+
const BASE_SIZE = 48;
|
|
12
|
+
/** Aspect ratio normalization factor (0–1). */
|
|
13
|
+
const SCALE_FACTOR = .5;
|
|
14
|
+
/** Density compensation factor (0–1). */
|
|
15
|
+
const DENSITY_FACTOR = .5;
|
|
16
|
+
/** Dampening exponent for density compensation. */
|
|
17
|
+
const DENSITY_DAMPENING = .5;
|
|
18
|
+
/** Reference density value for compensation scaling. */
|
|
19
|
+
const REFERENCE_DENSITY = .35;
|
|
20
|
+
/** Default file extensions to scan. */
|
|
21
|
+
const DEFAULT_EXTENSIONS = ["svg", "png"];
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/analyze.ts
|
|
25
|
+
async function analyze(filePath, options = {}) {
|
|
26
|
+
const { sampleMaxSize = SAMPLE_MAX_SIZE, contrastThreshold = CONTRAST_THRESHOLD } = options;
|
|
27
|
+
const { pixels, width, height, alphaOnly, bg } = await extractPixels(filePath, sampleMaxSize);
|
|
28
|
+
const contentBox = detectContentBoundingBox(pixels, width, height, contrastThreshold, alphaOnly, bg);
|
|
29
|
+
if (contentBox.width === 0 || contentBox.height === 0) return;
|
|
30
|
+
const visualCenter = calculateVisualCenter(pixels, width, contentBox, contrastThreshold, alphaOnly, bg);
|
|
31
|
+
const density = measurePixelDensity(pixels, width, contentBox, contrastThreshold, alphaOnly);
|
|
32
|
+
return {
|
|
33
|
+
contentRatio: contentBox.width / contentBox.height,
|
|
34
|
+
pixelDensity: density,
|
|
35
|
+
visualCenterX: visualCenter.offsetX / contentBox.width,
|
|
36
|
+
visualCenterY: visualCenter.offsetY / contentBox.height
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async function analyzeDirectory(dirPath, options = {}) {
|
|
40
|
+
const { extensions = DEFAULT_EXTENSIONS, ...analyzeOptions } = options;
|
|
41
|
+
const files = (await fsp.readdir(dirPath)).filter((fileName) => {
|
|
42
|
+
const ext = path.extname(fileName).slice(1).toLowerCase();
|
|
43
|
+
return extensions.includes(ext);
|
|
44
|
+
});
|
|
45
|
+
const results = /* @__PURE__ */ new Map();
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
const absolutePath = path.resolve(dirPath, file);
|
|
48
|
+
try {
|
|
49
|
+
const metrics = await analyze(absolutePath, analyzeOptions);
|
|
50
|
+
if (metrics) results.set(file, metrics);
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
return results;
|
|
54
|
+
}
|
|
55
|
+
async function extractPixels(filePath, sampleMaxSize) {
|
|
56
|
+
let image = sharp(await fsp.readFile(filePath), { ...filePath.endsWith(".svg") && { density: 96 } }).ensureAlpha();
|
|
57
|
+
image = image.resize(sampleMaxSize, sampleMaxSize, {
|
|
58
|
+
fit: "inside",
|
|
59
|
+
withoutEnlargement: false
|
|
60
|
+
});
|
|
61
|
+
const { data: pixels, info } = await image.raw().toBuffer({ resolveWithObject: true });
|
|
62
|
+
const { width, height } = info;
|
|
63
|
+
let hasTransparency = false;
|
|
64
|
+
const totalPixels = width * height;
|
|
65
|
+
for (let i = 0; i < totalPixels; i++) if (pixels[i * 4 + 3] < 250) {
|
|
66
|
+
hasTransparency = true;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
let bg = {
|
|
70
|
+
r: 255,
|
|
71
|
+
g: 255,
|
|
72
|
+
b: 255
|
|
73
|
+
};
|
|
74
|
+
if (!hasTransparency) {
|
|
75
|
+
const corners = [
|
|
76
|
+
0,
|
|
77
|
+
(width - 1) * 4,
|
|
78
|
+
(height - 1) * width * 4,
|
|
79
|
+
((height - 1) * width + width - 1) * 4
|
|
80
|
+
];
|
|
81
|
+
let sumR = 0;
|
|
82
|
+
let sumG = 0;
|
|
83
|
+
let sumB = 0;
|
|
84
|
+
for (const offset of corners) {
|
|
85
|
+
sumR += pixels[offset];
|
|
86
|
+
sumG += pixels[offset + 1];
|
|
87
|
+
sumB += pixels[offset + 2];
|
|
88
|
+
}
|
|
89
|
+
bg = {
|
|
90
|
+
r: Math.round(sumR / 4),
|
|
91
|
+
g: Math.round(sumG / 4),
|
|
92
|
+
b: Math.round(sumB / 4)
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
pixels,
|
|
97
|
+
width,
|
|
98
|
+
height,
|
|
99
|
+
alphaOnly: hasTransparency,
|
|
100
|
+
bg
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function detectContentBoundingBox(pixels, width, height, threshold, alphaOnly, bg) {
|
|
104
|
+
let minX = width;
|
|
105
|
+
let minY = height;
|
|
106
|
+
let maxX = 0;
|
|
107
|
+
let maxY = 0;
|
|
108
|
+
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
|
109
|
+
const i = (y * width + x) * 4;
|
|
110
|
+
const r = pixels[i];
|
|
111
|
+
const g = pixels[i + 1];
|
|
112
|
+
const b = pixels[i + 2];
|
|
113
|
+
const a = pixels[i + 3];
|
|
114
|
+
if (isContentPixel(r, g, b, a, threshold, alphaOnly, bg)) {
|
|
115
|
+
if (x < minX) minX = x;
|
|
116
|
+
if (y < minY) minY = y;
|
|
117
|
+
if (x > maxX) maxX = x;
|
|
118
|
+
if (y > maxY) maxY = y;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (minX > maxX || minY > maxY) return {
|
|
122
|
+
x: 0,
|
|
123
|
+
y: 0,
|
|
124
|
+
width,
|
|
125
|
+
height
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
x: minX,
|
|
129
|
+
y: minY,
|
|
130
|
+
width: maxX - minX + 1,
|
|
131
|
+
height: maxY - minY + 1
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function calculateVisualCenter(pixels, width, contentBox, threshold, alphaOnly, bg) {
|
|
135
|
+
let totalWeight = 0;
|
|
136
|
+
let weightedX = 0;
|
|
137
|
+
let weightedY = 0;
|
|
138
|
+
const { x: bx, y: by, width: bw, height: bh } = contentBox;
|
|
139
|
+
for (let y = 0; y < bh; y++) for (let x = 0; x < bw; x++) {
|
|
140
|
+
const i = ((by + y) * width + (bx + x)) * 4;
|
|
141
|
+
const r = pixels[i];
|
|
142
|
+
const g = pixels[i + 1];
|
|
143
|
+
const b = pixels[i + 2];
|
|
144
|
+
const a = pixels[i + 3];
|
|
145
|
+
if (!isContentPixel(r, g, b, a, threshold, alphaOnly, bg)) continue;
|
|
146
|
+
let weight;
|
|
147
|
+
if (alphaOnly) weight = a / 255;
|
|
148
|
+
else {
|
|
149
|
+
const dr = r - bg.r;
|
|
150
|
+
const dg = g - bg.g;
|
|
151
|
+
const db = b - bg.b;
|
|
152
|
+
const colorDistance = Math.sqrt(dr * dr + dg * dg + db * db);
|
|
153
|
+
weight = Math.sqrt(colorDistance) * (a / 255);
|
|
154
|
+
}
|
|
155
|
+
totalWeight += weight;
|
|
156
|
+
weightedX += (x + .5) * weight;
|
|
157
|
+
weightedY += (y + .5) * weight;
|
|
158
|
+
}
|
|
159
|
+
if (totalWeight === 0) return {
|
|
160
|
+
offsetX: 0,
|
|
161
|
+
offsetY: 0
|
|
162
|
+
};
|
|
163
|
+
return {
|
|
164
|
+
offsetX: weightedX / totalWeight - bw / 2,
|
|
165
|
+
offsetY: weightedY / totalWeight - bh / 2
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function measurePixelDensity(pixels, width, contentBox, threshold, alphaOnly) {
|
|
169
|
+
const { x: bx, y: by, width: bw, height: bh } = contentBox;
|
|
170
|
+
const totalPixels = bw * bh;
|
|
171
|
+
if (totalPixels === 0) return .5;
|
|
172
|
+
let filledPixels = 0;
|
|
173
|
+
let totalOpacity = 0;
|
|
174
|
+
for (let y = 0; y < bh; y++) for (let x = 0; x < bw; x++) {
|
|
175
|
+
const i = ((by + y) * width + (bx + x)) * 4;
|
|
176
|
+
const r = pixels[i];
|
|
177
|
+
const g = pixels[i + 1];
|
|
178
|
+
const b = pixels[i + 2];
|
|
179
|
+
const a = pixels[i + 3];
|
|
180
|
+
if (isContentPixel(r, g, b, a, threshold, alphaOnly, {
|
|
181
|
+
r: 255,
|
|
182
|
+
g: 255,
|
|
183
|
+
b: 255
|
|
184
|
+
})) {
|
|
185
|
+
filledPixels++;
|
|
186
|
+
totalOpacity += a / 255;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (filledPixels === 0) return 0;
|
|
190
|
+
return filledPixels / totalPixels * (totalOpacity / filledPixels);
|
|
191
|
+
}
|
|
192
|
+
function isContentPixel(r, g, b, a, threshold, alphaOnly, bg) {
|
|
193
|
+
if (alphaOnly) return a > threshold;
|
|
194
|
+
return a > threshold && (Math.abs(r - bg.r) > threshold || Math.abs(g - bg.g) > threshold || Math.abs(b - bg.b) > threshold);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
//#endregion
|
|
198
|
+
//#region src/normalize.ts
|
|
199
|
+
function normalize(metrics, options = {}) {
|
|
200
|
+
const { baseSize = BASE_SIZE, scaleFactor = SCALE_FACTOR, densityFactor = DENSITY_FACTOR, densityDampening = DENSITY_DAMPENING, referenceDensity = REFERENCE_DENSITY } = options;
|
|
201
|
+
const ratio = metrics.contentRatio;
|
|
202
|
+
let width = ratio ** scaleFactor * baseSize;
|
|
203
|
+
let height = width / ratio;
|
|
204
|
+
if (densityFactor > 0 && metrics.pixelDensity > 0) {
|
|
205
|
+
const densityScale = (1 / (metrics.pixelDensity / referenceDensity)) ** (densityFactor * densityDampening);
|
|
206
|
+
const clampedScale = Math.max(.5, Math.min(2, densityScale));
|
|
207
|
+
width *= clampedScale;
|
|
208
|
+
height *= clampedScale;
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
width: Math.round(width),
|
|
212
|
+
height: Math.round(height),
|
|
213
|
+
offsetX: Math.round(-(metrics.visualCenterX ?? 0) * width * 10) / 10,
|
|
214
|
+
offsetY: Math.round(-(metrics.visualCenterY ?? 0) * height * 10) / 10
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
//#endregion
|
|
219
|
+
export { CONTRAST_THRESHOLD as a, DENSITY_FACTOR as c, SCALE_FACTOR as d, BASE_SIZE as i, REFERENCE_DENSITY as l, analyze as n, DEFAULT_EXTENSIONS as o, analyzeDirectory as r, DENSITY_DAMPENING as s, normalize as t, SAMPLE_MAX_SIZE as u };
|
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "logo-soup",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"packageManager": "pnpm@10.30.0",
|
|
6
|
+
"description": "Normalize logo dimensions for visual balance",
|
|
7
|
+
"author": "Johann Schopplich <hello@johannschopplich.com>",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"homepage": "https://github.com/johannschopplich/logo-soup#readme",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/johannschopplich/logo-soup.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/johannschopplich/logo-soup/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"logo",
|
|
19
|
+
"image",
|
|
20
|
+
"analysis",
|
|
21
|
+
"normalization",
|
|
22
|
+
"visual-balance",
|
|
23
|
+
"sharp",
|
|
24
|
+
"svg",
|
|
25
|
+
"png"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.mts",
|
|
31
|
+
"default": "./dist/index.mjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"types": "./dist/index.d.mts",
|
|
35
|
+
"bin": {
|
|
36
|
+
"logo-soup": "bin/logo-soup.mjs"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"bin",
|
|
40
|
+
"dist"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsdown",
|
|
44
|
+
"lint": "eslint .",
|
|
45
|
+
"lint:fix": "eslint . --fix",
|
|
46
|
+
"test": "vitest",
|
|
47
|
+
"test:types": "tsc --noEmit",
|
|
48
|
+
"release": "bumpp"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"citty": "^0.2.1",
|
|
52
|
+
"consola": "^3.4.2",
|
|
53
|
+
"sharp": "^0.34.5"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@antfu/eslint-config": "^7.4.3",
|
|
57
|
+
"@types/node": "^24.10.13",
|
|
58
|
+
"bumpp": "^10.4.1",
|
|
59
|
+
"eslint": "^10.0.0",
|
|
60
|
+
"tsdown": "^0.20.3",
|
|
61
|
+
"typescript": "^5.9.3",
|
|
62
|
+
"vitest": "^4.0.18"
|
|
63
|
+
},
|
|
64
|
+
"pnpm": {
|
|
65
|
+
"onlyBuiltDependencies": [
|
|
66
|
+
"@parcel/watcher",
|
|
67
|
+
"esbuild",
|
|
68
|
+
"sharp"
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
}
|