logomarks 0.0.0-stage → 1.0.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.
Files changed (174) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +56 -2
  4. package/bin/logomarks.js +4 -0
  5. package/data/fonts/BricolageGrotesque-SemiBold.ttf +0 -0
  6. package/data/fonts/Cagliostro-Regular.ttf +0 -0
  7. package/data/fonts/Fraunces-SemiBold.ttf +0 -0
  8. package/data/fonts/IBMPlexMono-Regular.ttf +0 -0
  9. package/data/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  10. package/data/fonts/InstrumentSans-SemiBold.ttf +0 -0
  11. package/data/fonts/InstrumentSerif-Regular.ttf +0 -0
  12. package/data/fonts/Inter-SemiBold.ttf +0 -0
  13. package/data/fonts/Limelight-Regular.ttf +0 -0
  14. package/data/fonts/OFL.txt +103 -0
  15. package/data/fonts/Shrikhand-Regular.ttf +0 -0
  16. package/data/fonts/SpaceGrotesk-SemiBold.ttf +0 -0
  17. package/data/index.bin +0 -0
  18. package/docs/api.md +31 -0
  19. package/docs/commands.md +412 -0
  20. package/docs/editing.md +61 -0
  21. package/docs/mcp.md +45 -0
  22. package/package.json +94 -4
  23. package/src/brand/brand.d.ts +23 -0
  24. package/src/brand/brand.js +29 -0
  25. package/src/brand/fonts.d.ts +61 -0
  26. package/src/brand/fonts.js +93 -0
  27. package/src/brand/mark.d.ts +53 -0
  28. package/src/brand/mark.js +151 -0
  29. package/src/checks/finding.d.ts +56 -0
  30. package/src/checks/finding.js +57 -0
  31. package/src/checks/fit.d.ts +5 -0
  32. package/src/checks/fit.js +46 -0
  33. package/src/checks/index.d.ts +33 -0
  34. package/src/checks/index.js +57 -0
  35. package/src/checks/specks.d.ts +5 -0
  36. package/src/checks/specks.js +38 -0
  37. package/src/checks/widths.d.ts +7 -0
  38. package/src/checks/widths.js +48 -0
  39. package/src/cli/args.d.ts +20 -0
  40. package/src/cli/args.js +35 -0
  41. package/src/cli/commands/check.d.ts +10 -0
  42. package/src/cli/commands/check.js +66 -0
  43. package/src/cli/commands/combine.d.ts +10 -0
  44. package/src/cli/commands/combine.js +54 -0
  45. package/src/cli/commands/cover.d.ts +10 -0
  46. package/src/cli/commands/cover.js +53 -0
  47. package/src/cli/commands/edit.d.ts +10 -0
  48. package/src/cli/commands/edit.js +70 -0
  49. package/src/cli/commands/find.d.ts +10 -0
  50. package/src/cli/commands/find.js +61 -0
  51. package/src/cli/commands/get.d.ts +10 -0
  52. package/src/cli/commands/get.js +57 -0
  53. package/src/cli/commands/glitch.d.ts +10 -0
  54. package/src/cli/commands/glitch.js +57 -0
  55. package/src/cli/commands/index.d.ts +15 -0
  56. package/src/cli/commands/index.js +24 -0
  57. package/src/cli/commands/kit.d.ts +10 -0
  58. package/src/cli/commands/kit.js +66 -0
  59. package/src/cli/commands/mcp.d.ts +9 -0
  60. package/src/cli/commands/mcp.js +36 -0
  61. package/src/cli/commands/mockup.d.ts +10 -0
  62. package/src/cli/commands/mockup.js +61 -0
  63. package/src/cli/commands/parts.d.ts +10 -0
  64. package/src/cli/commands/parts.js +44 -0
  65. package/src/cli/commands/sheet.d.ts +10 -0
  66. package/src/cli/commands/sheet.js +66 -0
  67. package/src/cli/commands/similar.d.ts +10 -0
  68. package/src/cli/commands/similar.js +111 -0
  69. package/src/cli/commands/social.d.ts +10 -0
  70. package/src/cli/commands/social.js +65 -0
  71. package/src/cli/commands/specks.d.ts +20 -0
  72. package/src/cli/commands/specks.js +103 -0
  73. package/src/cli/edited.d.ts +6 -0
  74. package/src/cli/edited.js +32 -0
  75. package/src/cli/files.d.ts +4 -0
  76. package/src/cli/files.js +27 -0
  77. package/src/cli/help.d.ts +20 -0
  78. package/src/cli/help.js +55 -0
  79. package/src/cli/index.d.ts +10 -0
  80. package/src/cli/index.js +104 -0
  81. package/src/cli/options.d.ts +30 -0
  82. package/src/cli/options.js +155 -0
  83. package/src/cli/report.d.ts +4 -0
  84. package/src/cli/report.js +28 -0
  85. package/src/cli/style.d.ts +4 -0
  86. package/src/cli/style.js +34 -0
  87. package/src/cover/index.d.ts +49 -0
  88. package/src/cover/index.js +63 -0
  89. package/src/edit/combine.d.ts +31 -0
  90. package/src/edit/combine.js +103 -0
  91. package/src/edit/doc.d.ts +34 -0
  92. package/src/edit/doc.js +96 -0
  93. package/src/edit/glitch.d.ts +48 -0
  94. package/src/edit/glitch.js +43 -0
  95. package/src/edit/index.d.ts +8 -0
  96. package/src/edit/index.js +11 -0
  97. package/src/edit/ops.d.ts +51 -0
  98. package/src/edit/ops.js +90 -0
  99. package/src/edit/preview.d.ts +8 -0
  100. package/src/edit/preview.js +51 -0
  101. package/src/find/index.d.ts +54 -0
  102. package/src/find/index.js +88 -0
  103. package/src/index.d.ts +30 -0
  104. package/src/index.js +28 -0
  105. package/src/kit/index.d.ts +26 -0
  106. package/src/kit/index.js +31 -0
  107. package/src/mcp/content.d.ts +30 -0
  108. package/src/mcp/content.js +56 -0
  109. package/src/mcp/edited.d.ts +5 -0
  110. package/src/mcp/edited.js +25 -0
  111. package/src/mcp/index.d.ts +41 -0
  112. package/src/mcp/index.js +72 -0
  113. package/src/mcp/inputs.d.ts +46 -0
  114. package/src/mcp/inputs.js +122 -0
  115. package/src/mcp/preview.d.ts +4 -0
  116. package/src/mcp/preview.js +24 -0
  117. package/src/mcp/tools/check.d.ts +15 -0
  118. package/src/mcp/tools/check.js +47 -0
  119. package/src/mcp/tools/combine.d.ts +15 -0
  120. package/src/mcp/tools/combine.js +42 -0
  121. package/src/mcp/tools/cover.d.ts +20 -0
  122. package/src/mcp/tools/cover.js +46 -0
  123. package/src/mcp/tools/edit.d.ts +21 -0
  124. package/src/mcp/tools/edit.js +73 -0
  125. package/src/mcp/tools/find.d.ts +14 -0
  126. package/src/mcp/tools/find.js +36 -0
  127. package/src/mcp/tools/get.d.ts +15 -0
  128. package/src/mcp/tools/get.js +44 -0
  129. package/src/mcp/tools/glitch.d.ts +12 -0
  130. package/src/mcp/tools/glitch.js +39 -0
  131. package/src/mcp/tools/index.d.ts +4 -0
  132. package/src/mcp/tools/index.js +23 -0
  133. package/src/mcp/tools/kit.d.ts +28 -0
  134. package/src/mcp/tools/kit.js +51 -0
  135. package/src/mcp/tools/mockup.d.ts +22 -0
  136. package/src/mcp/tools/mockup.js +51 -0
  137. package/src/mcp/tools/sheet.d.ts +15 -0
  138. package/src/mcp/tools/sheet.js +43 -0
  139. package/src/mcp/tools/similar.d.ts +13 -0
  140. package/src/mcp/tools/similar.js +42 -0
  141. package/src/mcp/tools/social.d.ts +5 -0
  142. package/src/mcp/tools/social.js +68 -0
  143. package/src/mockup/index.d.ts +52 -0
  144. package/src/mockup/index.js +152 -0
  145. package/src/raster/render.d.ts +30 -0
  146. package/src/raster/render.js +100 -0
  147. package/src/rules.d.ts +73 -0
  148. package/src/rules.js +139 -0
  149. package/src/sheet/card.d.ts +20 -0
  150. package/src/sheet/card.js +190 -0
  151. package/src/sheet/draw.d.ts +26 -0
  152. package/src/sheet/draw.js +94 -0
  153. package/src/sheet/grid.d.ts +2 -0
  154. package/src/sheet/grid.js +32 -0
  155. package/src/sheet/index.d.ts +31 -0
  156. package/src/sheet/index.js +47 -0
  157. package/src/sheet/theme.d.ts +8 -0
  158. package/src/sheet/theme.js +45 -0
  159. package/src/similar/folder.d.ts +20 -0
  160. package/src/similar/folder.js +66 -0
  161. package/src/similar/nearest.d.ts +32 -0
  162. package/src/similar/nearest.js +60 -0
  163. package/src/similar/signature.d.ts +12 -0
  164. package/src/similar/signature.js +42 -0
  165. package/src/similar/store.d.ts +67 -0
  166. package/src/similar/store.js +88 -0
  167. package/src/util/color.d.ts +2 -0
  168. package/src/util/color.js +23 -0
  169. package/src/util/files.d.ts +8 -0
  170. package/src/util/files.js +60 -0
  171. package/src/util/round.d.ts +2 -0
  172. package/src/util/round.js +14 -0
  173. package/src/util/version.d.ts +2 -0
  174. package/src/util/version.js +10 -0
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @module logomarks/sheet/grid
3
+ *
4
+ * A plain contact sheet: marks in a grid, light on dark, each labelled with
5
+ * its name (what batchsheet.mjs and peek.mjs drew), for a quick look at a
6
+ * whole batch before the full review cards.
7
+ */
8
+
9
+ import { render } from '../raster/render.js';
10
+ import { text, panel, mark } from './draw.js';
11
+ import { COLORS } from './theme.js';
12
+
13
+ /** A cell's width and height, in pixels. */
14
+ const W = 190, H = 220;
15
+
16
+ /**
17
+ * The marks in a labelled grid, `cols` across.
18
+ *
19
+ * @param {readonly { name: string, svg: string }[]} marks The marks.
20
+ * @param {{ cols?: number }} [o] How many across (8).
21
+ * @returns {Promise<Uint8Array>} The sheet as a PNG.
22
+ */
23
+ export async function gridSheet(marks, { cols = 8 } = {}) {
24
+ const across = Math.max(1, Math.min(cols, marks.length)), rows = Math.ceil(marks.length / across);
25
+ const cells = marks.map((m, i) => {
26
+ const x = (i % across) * W, y = Math.floor(i / across) * H;
27
+ return panel(x + 6, y + 6, W - 12, H - 12, COLORS.card, { r: 12 }) + mark(m.svg, x + 30, y + 22, W - 60, COLORS.darkInk) + text(x + W / 2, y + H - 24, m.name, { size: 13, color: COLORS.text, anchor: 'middle' });
28
+ });
29
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${across * W}" height="${rows * H}"><rect width="100%" height="100%" fill="${COLORS.page}"/>${cells.join('')}</svg>`;
30
+ const { png } = await render(svg, { text: true, png: true });
31
+ return /** @type {Uint8Array} */ (png);
32
+ }
@@ -0,0 +1,31 @@
1
+ import type { Report } from '../checks/index.js';
2
+ import type { Match } from '../similar/nearest.js';
3
+ import type { MarkIndex } from '../similar/store.js';
4
+
5
+ /** A mark for the sheet. */
6
+ export interface SheetMark {
7
+ /** Its name, usually its file's slug. */
8
+ name: string;
9
+ svg: string;
10
+ }
11
+
12
+ /** How to check the marks. */
13
+ export interface SheetOptions {
14
+ /** The live marks (the bundled index). */
15
+ index?: MarkIndex;
16
+ /** Warnings fail a mark too (false). */
17
+ strict?: boolean;
18
+ /** Finding ids to leave out. */
19
+ ignore?: string[];
20
+ }
21
+
22
+ /** A rendered sheet. */
23
+ export interface Sheet {
24
+ png: Uint8Array;
25
+ svg: string;
26
+ /** Each mark's check and nearest live marks (itself left out). */
27
+ marks: { name: string; report: Report; matches: Match[] }[];
28
+ }
29
+
30
+ /** Review cards for a set of marks, rendered to one PNG. */
31
+ export function sheet(marks: readonly SheetMark[], o?: SheetOptions): Promise<Sheet>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @module logomarks/sheet
3
+ *
4
+ * `sheet`: review cards for a set of marks, one under another, rendered to a
5
+ * PNG for an AI (or a person) to look at.
6
+ */
7
+
8
+ import { check } from '../checks/index.js';
9
+ import { similar } from '../similar/nearest.js';
10
+ import { loadIndex } from '../similar/store.js';
11
+ import { render } from '../raster/render.js';
12
+ import { COLORS, CARD } from './theme.js';
13
+ import { card } from './card.js';
14
+ import { text } from './draw.js';
15
+
16
+ /** The page's margin and the space between cards. */
17
+ const MARGIN = 16;
18
+
19
+ /** The title strip's height. */
20
+ const TITLE = 44;
21
+
22
+ /**
23
+ * A review sheet.
24
+ *
25
+ * @param {readonly import('./index.js').SheetMark[]} marks The marks, each with its name.
26
+ * @param {import('./index.js').SheetOptions} [o] Checks and neighbours.
27
+ * @returns {Promise<import('./index.js').Sheet>} The sheet as PNG and SVG, and each mark's report and neighbours.
28
+ */
29
+ export async function sheet(marks, { index = loadIndex(), strict = false, ignore = [] } = {}) {
30
+ /** @type {import('./index.js').Sheet['marks']} */
31
+ const results = [];
32
+ let y = MARGIN + TITLE, cards = '';
33
+ for (const { name, svg } of marks) {
34
+ const report = check(svg, { name, strict, ignore });
35
+ const matches = report.size ? await similar(svg, { top: 3, index, exclude: name }) : [];
36
+ const c = await card({ name, svg, report, matches, index }, y);
37
+ cards += `<g transform="translate(${MARGIN} 0)">${c.svg}</g>`;
38
+ y += c.height + MARGIN;
39
+ results.push({ name, report, matches });
40
+ }
41
+ const width = CARD.width + 2 * MARGIN;
42
+ const failed = results.filter((r) => !r.report.ok).length;
43
+ const title = text(MARGIN + 4, MARGIN + 24, `Logomarks review · ${marks.length} mark${marks.length === 1 ? '' : 's'} · ${failed ? `${failed} failing` : 'all passing'} · live set of ${index.marks.length} (${index.built})`, { size: 14, color: COLORS.muted });
44
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${y}" viewBox="0 0 ${width} ${y}"><rect width="${width}" height="${y}" fill="${COLORS.page}"/>${title}${cards}</svg>`;
45
+ const png = /** @type {Uint8Array} */ ((await render(svg, { text: true, png: true })).png);
46
+ return { png, svg, marks: results };
47
+ }
@@ -0,0 +1,8 @@
1
+ /** The sheet's colours. */
2
+ export const COLORS: Readonly<Record<'page' | 'card' | 'line' | 'text' | 'muted' | 'dark' | 'darkInk' | 'light' | 'lightInk' | 'error' | 'warning' | 'info' | 'pass', string>>;
3
+ /** The sheet's typeface (bundled). */
4
+ export const FONT: string;
5
+ /** Card geometry in pixels. */
6
+ export const CARD: Readonly<{ width: number; pad: number; gap: number; head: number; large: number; chips: number; neighbour: number; badge: number }>;
7
+ /** How much each chip size is magnified beside its actual size. */
8
+ export const ZOOM: Readonly<Record<32 | 16, number>>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @module logomarks/sheet/theme
3
+ *
4
+ * The review sheet's colours, type and sizes, in one place.
5
+ */
6
+
7
+ /** Colours: a dark page, the two grounds a mark is judged on, and the verdict colours. */
8
+ export const COLORS = {
9
+ page: '#0c0c0c',
10
+ card: '#171717',
11
+ line: '#2a2a2a',
12
+ text: '#f2f2f2',
13
+ muted: '#8c8c8c',
14
+ dark: '#000000',
15
+ darkInk: '#f4f4f2',
16
+ light: '#f4f4f2',
17
+ lightInk: '#111111',
18
+ error: '#ff5c64',
19
+ warning: '#f5b941',
20
+ info: '#8c8c8c',
21
+ pass: '#5cffc0',
22
+ };
23
+
24
+ /** The typeface (bundled). */
25
+ export const FONT = 'IBM Plex Mono';
26
+
27
+ /** Card geometry in pixels. */
28
+ export const CARD = {
29
+ width: 1400,
30
+ pad: 24,
31
+ gap: 16,
32
+ /** The header row. */
33
+ head: 56,
34
+ /** The large views' side. */
35
+ large: 300,
36
+ /** The chips column's width. */
37
+ chips: 236,
38
+ /** A neighbour tile's side. */
39
+ neighbour: 140,
40
+ /** A badge's height. */
41
+ badge: 28,
42
+ };
43
+
44
+ /** How much each chip is magnified beside its actual size, so its pixels can be seen. */
45
+ export const ZOOM = { 32: 3, 16: 4 };
@@ -0,0 +1,20 @@
1
+ import type { MarkIndex } from './store.js';
2
+
3
+ /** What indexOf() takes. */
4
+ export interface FolderOptions {
5
+ /** Letter signatures too (false). */
6
+ letters?: boolean;
7
+ /** Names and tags by slug (from a marks.json). */
8
+ about?: Record<string, { name: string; tags: string[] }>;
9
+ /** The files are candidates: every one's holes are compared, and each match says so. */
10
+ candidate?: boolean;
11
+ }
12
+
13
+ /** A badge's slug (any family). */
14
+ export function isBadge(slug: string): boolean;
15
+ /** An index of SVG files. */
16
+ export function indexOf(files: readonly string[], o?: FolderOptions): Promise<MarkIndex>;
17
+ /** An index of every SVG in a folder, named from the marks.json beside it. */
18
+ export function folderIndex(dir: string, o?: { letters?: boolean }): Promise<MarkIndex>;
19
+ /** Two indexes as one. */
20
+ export function joinIndexes(a: MarkIndex, b: MarkIndex): MarkIndex;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @module logomarks/similar/folder
3
+ *
4
+ * An index built on the spot, for comparing against marks that aren't in the
5
+ * bundled one: a folder of SVGs (the site's svg folder while a batch is
6
+ * drawn), or the candidates themselves (each against the others). Names, tags
7
+ * come from a `marks.json` beside the folder when there is one; the letter
8
+ * signatures cover badges (their holes) and letter and number marks (their ink),
9
+ * as the bundled index does.
10
+ */
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { outlineSignature, letterSignatures } from './signature.js';
15
+ import { slugOf } from '../util/files.js';
16
+
17
+ /** A badge, whose letter is its holes (every family: Badge, Pixel Badge, Stencil Badge …). */
18
+ export const isBadge = (/** @type {string} */ slug) => /(^|-)badge-/.test(slug);
19
+
20
+ /**
21
+ * An index of SVG files.
22
+ *
23
+ * @param {readonly string[]} files The marks' files.
24
+ * @param {import('./folder.js').FolderOptions} [o] Letter signatures too, names and tags by slug, and a tag for each.
25
+ * @returns {Promise<import('./store.js').MarkIndex>} The index (its marks carry only slug, name, svg and outline).
26
+ */
27
+ export async function indexOf(files, { letters = false, about = {}, candidate = false } = {}) {
28
+ /** @type {import('./store.js').MarkIndex} */
29
+ const index = { built: new Date().toISOString().slice(0, 10), marks: [], letters: [] };
30
+ for (const file of files) {
31
+ const svg = fs.readFileSync(file, 'utf8');
32
+ const slug = slugOf(file), a = about[slug];
33
+ const i = index.marks.push(/** @type {import('./store.js').IndexedMark} */ ({ slug, name: a?.name ?? slug, svg, outline: await outlineSignature(svg), ...(candidate && { candidate: true }), ...(a && { tags: a.tags }) })) - 1;
34
+ if (!letters) continue;
35
+ const lettery = candidate || a?.tags.includes('letter') || a?.tags.includes('number');
36
+ if (!isBadge(slug) && !lettery) continue;
37
+ const { holes, ink } = await letterSignatures(svg);
38
+ if ((isBadge(slug) || candidate) && holes) index.letters.push({ mark: i, kind: 'hole', sig: holes });
39
+ else if (lettery && !candidate) index.letters.push({ mark: i, kind: 'ink', sig: ink });
40
+ }
41
+ return index;
42
+ }
43
+
44
+ /**
45
+ * An index of every SVG in a folder, named from the `marks.json` beside it when there is one.
46
+ *
47
+ * @param {string} dir The folder.
48
+ * @param {{ letters?: boolean }} [o] Letter signatures too.
49
+ * @returns {Promise<import('./store.js').MarkIndex>} The index.
50
+ */
51
+ export async function folderIndex(dir, { letters = false } = {}) {
52
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.svg')).sort().map((f) => path.join(dir, f));
53
+ const list = path.join(dir, '..', 'marks.json');
54
+ /** @type {Record<string, { name: string, tags: string[] }>} */
55
+ const about = fs.existsSync(list) ? Object.fromEntries(JSON.parse(fs.readFileSync(list, 'utf8')).map((/** @type {{ slug: string, name: string, tags: string[] }} */ m) => [m.slug, m])) : {};
56
+ return indexOf(files, { letters, about });
57
+ }
58
+
59
+ /**
60
+ * Two indexes as one (the second's letters renumbered).
61
+ *
62
+ * @param {import('./store.js').MarkIndex} a The first.
63
+ * @param {import('./store.js').MarkIndex} b The second.
64
+ * @returns {import('./store.js').MarkIndex} Both.
65
+ */
66
+ export const joinIndexes = (a, b) => ({ built: a.built, marks: [...a.marks, ...b.marks], letters: [...a.letters, ...b.letters.map((l) => ({ ...l, mark: l.mark + a.marks.length }))] });
@@ -0,0 +1,32 @@
1
+ import type { MarkIndex } from './store.js';
2
+
3
+ /** How close a live mark is: `near-copy` (0.9+ by outline), `too-close` (0.72+), or `ok`. */
4
+ export type Verdict = 'near-copy' | 'too-close' | 'ok';
5
+
6
+ /** A live mark near the candidate. */
7
+ export interface Match {
8
+ slug: string;
9
+ name: string;
10
+ /** Overlap, 0–1, the best of 8 rotations and flips. */
11
+ score: number;
12
+ verdict: Verdict;
13
+ /** What was compared: the outline, or a badge's knocked-out letter against a badge's hole or a letter mark's ink. */
14
+ by: 'outline' | 'letter (hole)' | 'letter (ink)';
15
+ /** Another candidate, not a live mark (`similar --each`). */
16
+ candidate?: true;
17
+ }
18
+
19
+ /** What to compare. */
20
+ export interface SimilarOptions {
21
+ /** How many to return (5). */
22
+ top?: number;
23
+ /** Compare the candidate's knocked-out letter (its holes) instead of its outline (false). */
24
+ letters?: boolean;
25
+ /** The live marks (the bundled index). */
26
+ index?: MarkIndex;
27
+ /** A slug to leave out (the candidate itself). */
28
+ exclude?: string;
29
+ }
30
+
31
+ /** The live marks nearest a candidate, closest first. With `letters`, an empty list when it has no holes. */
32
+ export function similar(svg: string, o?: SimilarOptions): Promise<Match[]>;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @module logomarks/similar/nearest
3
+ *
4
+ * The live marks nearest a candidate, by outline (dupcheck.mjs) or, for a
5
+ * letter badge, by its knocked-out letter (holecheck.mjs).
6
+ */
7
+
8
+ import { SIMILAR, LETTERS } from '../rules.js';
9
+ import { outlineSignature, letterSignatures, variants, iou } from './signature.js';
10
+ import { loadIndex } from './store.js';
11
+
12
+ /**
13
+ * How close a score is.
14
+ *
15
+ * @param {number} score The score.
16
+ * @param {{ tooClose: number, nearCopy: number }} t The thresholds.
17
+ * @returns {import('./nearest.js').Verdict} The verdict.
18
+ */
19
+ const verdict = (score, t) => (score >= t.nearCopy ? 'near-copy' : score >= t.tooClose ? 'too-close' : 'ok');
20
+
21
+ /**
22
+ * The best score of a candidate's 8 variants against one stored signature.
23
+ *
24
+ * @param {Float32Array[]} vs The candidate's variants.
25
+ * @param {Float32Array} sig The stored signature.
26
+ * @returns {number} The score.
27
+ */
28
+ const best = (vs, sig) => vs.reduce((m, v) => Math.max(m, iou(v, sig)), 0);
29
+
30
+ /**
31
+ * The live marks nearest a candidate.
32
+ *
33
+ * @param {string} svg The candidate.
34
+ * @param {import('./nearest.js').SimilarOptions} [o] How many, and what to compare.
35
+ * @returns {Promise<import('./nearest.js').Match[]>} The nearest, closest first.
36
+ */
37
+ export async function similar(svg, { top = 5, letters = false, index = loadIndex(), exclude } = {}) {
38
+ /** @type {import('./nearest.js').Match[]} */
39
+ let out;
40
+ if (letters) {
41
+ const { holes } = await letterSignatures(svg);
42
+ if (!holes) return [];
43
+ const vs = variants(holes);
44
+ out = index.letters.map((l) => {
45
+ const m = index.marks[l.mark], score = best(vs, l.sig);
46
+ return { slug: m.slug, name: m.name, score, verdict: verdict(score, LETTERS), by: l.kind === 'hole' ? 'letter (hole)' : 'letter (ink)', ...(m.candidate && { candidate: true }) };
47
+ });
48
+ } else {
49
+ const vs = variants(await outlineSignature(svg));
50
+ out = index.marks.map((m) => {
51
+ const score = best(vs, m.outline);
52
+ return { slug: m.slug, name: m.name, score, verdict: verdict(score, SIMILAR), by: 'outline', ...(m.candidate && { candidate: true }) };
53
+ });
54
+ }
55
+ return out
56
+ .filter((m) => m.slug !== exclude)
57
+ .sort((a, b) => b.score - a.score)
58
+ .slice(0, top)
59
+ .map((m) => ({ ...m, score: Math.round(m.score * 1000) / 1000 }));
60
+ }
@@ -0,0 +1,12 @@
1
+ /** A 3×3 box blur of a square image of side `N`, averaging only pixels inside it. */
2
+ export function blur(a: Float32Array, N: number): Float32Array;
3
+ /** A mark's 48 px coverage (0–1), before the blur: what the index stores. */
4
+ export function outlineCoverage(svg: string): Promise<Float32Array>;
5
+ /** A mark's outline signature: 48 px coverage, blurred 3×3. */
6
+ export function outlineSignature(svg: string): Promise<Float32Array>;
7
+ /** A mark's holes (null when it has none) and its ink, each cropped to its box at 40 px. */
8
+ export function letterSignatures(svg: string): Promise<{ holes: Float32Array | null; ink: Float32Array }>;
9
+ /** A square image in all 8 rotations and flips. */
10
+ export function variants(a: Float32Array): Float32Array[];
11
+ /** Overlap (IoU) of two images: the sum of minima over the sum of maxima. */
12
+ export function iou(a: ArrayLike<number>, b: ArrayLike<number>): number;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @module logomarks/similar/signature
3
+ *
4
+ * What two marks are compared by (dupcheck.mjs and holecheck.mjs), rendered with
5
+ * the CLI's renderer and measured by @logomarks/core: the outline (48 px,
6
+ * blurred 3×3) and the letter (a badge's holes or a letter mark's ink, cropped
7
+ * to 40 px), both compared by overlap (IoU) in all 8 rotations and flips.
8
+ */
9
+
10
+ import { blur, variants, iou, outlineSignature as signatureOf, letterSignatures as lettersOf } from '@logomarks/core/similar';
11
+ import { SIMILAR, LETTERS } from '../rules.js';
12
+ import { coverage } from '../raster/render.js';
13
+
14
+ export { blur, variants, iou };
15
+
16
+ /**
17
+ * A mark's coverage at the signature's size (48 px), before the blur: what the index stores.
18
+ *
19
+ * @param {string} svg The mark.
20
+ * @returns {Promise<Float32Array>} Coverage 0–1.
21
+ */
22
+ export const outlineCoverage = (svg) => coverage(svg, SIMILAR.size);
23
+
24
+ /**
25
+ * A mark's outline signature: 48 px, blurred.
26
+ *
27
+ * @param {string} svg The mark.
28
+ * @returns {Promise<Float32Array>} The signature.
29
+ */
30
+ export async function outlineSignature(svg) {
31
+ return signatureOf(await outlineCoverage(svg), SIMILAR.size);
32
+ }
33
+
34
+ /**
35
+ * A mark's letter signatures: its holes (null when it has none) and its ink, cropped to 40 px.
36
+ *
37
+ * @param {string} svg The mark.
38
+ * @returns {Promise<import('./signature.js').LetterSignatures>} The signatures.
39
+ */
40
+ export async function letterSignatures(svg) {
41
+ return lettersOf(await coverage(svg, LETTERS.render), LETTERS.render, LETTERS.size);
42
+ }
@@ -0,0 +1,67 @@
1
+ import type { Packed } from '@arraypress/search';
2
+
3
+ /** What a live mark is: what `findMarks` searches. */
4
+ export interface MarkAbout {
5
+ /** Shape tags: round, square, letter, number, symmetric, radial, solid, light, wide, tall, new. */
6
+ tags: string[];
7
+ /** The board it is in (bold, archive, geometric, grid). */
8
+ collection: string;
9
+ /** Its family's style (letter, badge, iso, line, grid, glitch, animal …). */
10
+ style: string;
11
+ /** Its mood words (minimal, technical, calm, premium …). */
12
+ mood: string[];
13
+ /** The letter or number it shows, for letter marks. */
14
+ letter?: string;
15
+ /** What it looks like, in a sentence. */
16
+ description: string;
17
+ /** The kinds of business it suits, best fit first (may be empty). */
18
+ industries: string[];
19
+ }
20
+
21
+ /** A live mark in the index. */
22
+ export interface IndexedMark extends MarkAbout {
23
+ slug: string;
24
+ name: string;
25
+ /** The mark's SVG. */
26
+ svg: string;
27
+ /** Its outline signature: 48 px coverage, blurred. */
28
+ outline: Float32Array;
29
+ /** A candidate compared alongside (`similar --each`), not a live mark. */
30
+ candidate?: true;
31
+ }
32
+
33
+ /** A letter signature: a badge's holes or a letter mark's ink. */
34
+ export interface IndexedLetter {
35
+ /** The mark's position in `marks`. */
36
+ mark: number;
37
+ kind: 'hole' | 'ink';
38
+ /** The signature, 40 px, values 0–1. */
39
+ sig: Float32Array;
40
+ }
41
+
42
+ /** The live marks and their signatures. */
43
+ export interface MarkIndex {
44
+ /** When it was built (YYYY-MM-DD). */
45
+ built: string;
46
+ marks: IndexedMark[];
47
+ letters: IndexedLetter[];
48
+ /** The board's search over `marks`, in the same order (a packed @arraypress/search index); only the bundled index has it. */
49
+ search?: Packed;
50
+ }
51
+
52
+ /** What an index is written from. */
53
+ export interface IndexInput {
54
+ marks: ({ slug: string; name: string; svg: string } & MarkAbout)[];
55
+ /** One 48 px coverage per mark (values 0–1), before the blur. */
56
+ coverages: ArrayLike<number>[];
57
+ letters: { mark: number; kind: 'hole' | 'ink'; sig: ArrayLike<number> }[];
58
+ /** The board's search over `marks`, packed. */
59
+ search: Packed;
60
+ }
61
+
62
+ /** Where the bundled index lives. */
63
+ export const INDEX_FILE: URL;
64
+ /** Writes an index (brotli) and returns its size in bytes. */
65
+ export function writeIndex(input: IndexInput, file?: string | URL): number;
66
+ /** Reads an index, once per file. Throws when it is missing or from another version. */
67
+ export function loadIndex(file?: string | URL): MarkIndex;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @module logomarks/similar/store
3
+ *
4
+ * The bundled index of live marks: each mark's slug, name and SVG, what it is
5
+ * (tags, collection, style, mood, letter, description, industries: what
6
+ * `findMarks` searches), its 48 px coverage (the outline signature before its
7
+ * blur), and for badges and letter marks a letter signature, and the board's search over them (a packed
8
+ * @arraypress/search index: `findMarks`). One brotli-compressed file, built by
9
+ * `npm run index` from the Logomarks data, so `similar` and `sheet` work
10
+ * offline. Layout: a 4-byte JSON length, the JSON (marks and letters), then
11
+ * the signature bytes.
12
+ */
13
+
14
+ import fs from 'node:fs';
15
+ import zlib from 'node:zlib';
16
+ import { SIMILAR, LETTERS } from '../rules.js';
17
+ import { blur } from './signature.js';
18
+
19
+ /** Where the bundled index lives. */
20
+ export const INDEX_FILE = new URL('../../data/index.bin', import.meta.url);
21
+
22
+ /** The file format's version. */
23
+ const VERSION = 3;
24
+
25
+ /** Letter signatures are quarters (2×2 supersampling): stored ×4. */
26
+ const QUARTERS = 4;
27
+
28
+ /** @type {Map<string, import('./store.js').MarkIndex>} */
29
+ const cache = new Map();
30
+
31
+ /**
32
+ * Writes an index.
33
+ *
34
+ * @param {import('./store.js').IndexInput} input The marks and their signatures.
35
+ * @param {string | URL} [file] Where (the bundled index).
36
+ * @returns {number} The file's size in bytes.
37
+ */
38
+ export function writeIndex({ marks, coverages, letters, search }, file = INDEX_FILE) {
39
+ const head = Buffer.from(JSON.stringify({
40
+ version: VERSION,
41
+ built: new Date().toISOString().slice(0, 10),
42
+ size: SIMILAR.size,
43
+ letterSize: LETTERS.size,
44
+ marks: marks.map(({ slug, name, svg, ...about }) => [slug, name, svg, about]),
45
+ letters: letters.map((l) => [l.mark, l.kind]),
46
+ search,
47
+ }));
48
+ const length = Buffer.alloc(4);
49
+ length.writeUInt32LE(head.length);
50
+ const bytes = (/** @type {ArrayLike<number>} */ a, /** @type {number} */ k) => Uint8Array.from(a, (v) => Math.round(v * k));
51
+ const body = Buffer.concat([length, head, ...coverages.map((c) => bytes(c, 255)), ...letters.map((l) => bytes(l.sig, QUARTERS))]);
52
+ const out = zlib.brotliCompressSync(body, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } });
53
+ fs.writeFileSync(file, out);
54
+ return out.length;
55
+ }
56
+
57
+ /**
58
+ * Reads an index (once per file) and blurs its coverages into outline signatures.
59
+ *
60
+ * @param {string | URL} [file] Which (the bundled index).
61
+ * @returns {import('./store.js').MarkIndex} The index.
62
+ * @throws {Error} When the file is missing or from another version.
63
+ */
64
+ export function loadIndex(file = INDEX_FILE) {
65
+ const key = String(file);
66
+ const hit = cache.get(key);
67
+ if (hit) return hit;
68
+ const body = zlib.brotliDecompressSync(fs.readFileSync(file));
69
+ const n = body.readUInt32LE(0);
70
+ const raw = JSON.parse(body.toString('utf8', 4, 4 + n));
71
+ if (raw.version !== VERSION) throw new Error(`index version ${raw.version}, expected ${VERSION}: run npm run index`);
72
+ const S = raw.size * raw.size, L = raw.letterSize * raw.letterSize;
73
+ let at = 4 + n;
74
+ const take = (/** @type {number} */ len, /** @type {number} */ k) => {
75
+ const a = Float32Array.from(body.subarray(at, at + len), (v) => v / k);
76
+ at += len;
77
+ return a;
78
+ };
79
+ /** @type {import('./store.js').MarkIndex} */
80
+ const index = {
81
+ built: raw.built,
82
+ marks: raw.marks.map((/** @type {[string, string, string, import('./store.js').MarkAbout]} */ [slug, name, svg, about]) => ({ slug, name, svg, ...about, outline: blur(take(S, 255), raw.size) })),
83
+ letters: raw.letters.map((/** @type {[number, 'hole' | 'ink']} */ [mark, kind]) => ({ mark, kind, sig: take(L, QUARTERS) })),
84
+ search: raw.search,
85
+ };
86
+ cache.set(key, index);
87
+ return index;
88
+ }
@@ -0,0 +1,2 @@
1
+ /** A typed colour (`#rgb`, `#rrggbb`, `black`, `white`) as `#RRGGBB`; null when it isn't one. */
2
+ export function parseColor(input: string): string | null;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @module logomarks/util/color
3
+ *
4
+ * Reading a colour someone typed: `#rgb`, `#rrggbb` (the `#` optional), or
5
+ * `black` and `white`. Everything else about colour is
6
+ * `@arraypress/color-utils` and `@logomarks/brand-kit`'s palette.
7
+ */
8
+
9
+ /**
10
+ * A colour as `#RRGGBB`.
11
+ *
12
+ * @param {string} input The colour.
13
+ * @returns {string | null} It, upper case; null when it isn't one.
14
+ */
15
+ export function parseColor(input) {
16
+ const s = input.trim().toLowerCase();
17
+ if (s === 'black') return '#000000';
18
+ if (s === 'white') return '#FFFFFF';
19
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/.exec(s);
20
+ if (!m) return null;
21
+ const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1];
22
+ return `#${h.toUpperCase()}`;
23
+ }
@@ -0,0 +1,8 @@
1
+ /** Every SVG named, folders expanded (sorted, not recursive); throws when a path is missing. */
2
+ export function expandSvgs(paths: readonly string[]): string[];
3
+ /** A mark's name from its file: the slug. */
4
+ export function slugOf(file: string): string;
5
+ /** Writes a file, making its folder. */
6
+ export function writeFile(file: string, data: string | Uint8Array): void;
7
+ /** Writes files by path under a folder; returns how many. */
8
+ export function writeFiles(dir: string, files: Iterable<[string, string | Uint8Array]>): number;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @module logomarks/util/files
3
+ *
4
+ * Files in and out: the SVGs a list of paths names (folders expanded), a
5
+ * mark's name from its file, and writing a file (or a map of them) with
6
+ * their folders made.
7
+ */
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ /**
13
+ * Every SVG named, folders expanded (sorted, not recursive).
14
+ *
15
+ * @param {readonly string[]} paths Files and folders.
16
+ * @returns {string[]} The files, as given (or joined to their folder).
17
+ * @throws {Error} When a path is missing.
18
+ */
19
+ export function expandSvgs(paths) {
20
+ return paths.flatMap((p) => {
21
+ if (!fs.existsSync(p)) throw new Error(`no such file: ${p}`);
22
+ if (!fs.statSync(p).isDirectory()) return [p];
23
+ return fs.readdirSync(p).filter((f) => f.endsWith('.svg')).sort().map((f) => path.join(p, f));
24
+ });
25
+ }
26
+
27
+ /**
28
+ * A mark's name from its file: the slug.
29
+ *
30
+ * @param {string} file The file.
31
+ * @returns {string} Its name without the folder and `.svg`.
32
+ */
33
+ export const slugOf = (file) => path.basename(file).replace(/\.svg$/i, '');
34
+
35
+ /**
36
+ * Writes a file, making its folder.
37
+ *
38
+ * @param {string} file Where.
39
+ * @param {string | Uint8Array} data What.
40
+ */
41
+ export function writeFile(file, data) {
42
+ fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
43
+ fs.writeFileSync(file, data);
44
+ }
45
+
46
+ /**
47
+ * Writes files by path under a folder.
48
+ *
49
+ * @param {string} dir The folder.
50
+ * @param {Iterable<[string, string | Uint8Array]>} files Each file's path in it and its data.
51
+ * @returns {number} How many.
52
+ */
53
+ export function writeFiles(dir, files) {
54
+ let n = 0;
55
+ for (const [p, data] of files) {
56
+ writeFile(path.join(dir, p), data);
57
+ n++;
58
+ }
59
+ return n;
60
+ }
@@ -0,0 +1,2 @@
1
+ /** A number rounded to `places` decimal places (2), for SVG. */
2
+ export function round(v: number, places?: number): number;