brand-intent 0.2.0 → 0.3.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,283 @@
1
+ /**
2
+ * Parser for .format canvas format definition files.
3
+ *
4
+ * Strict, line-based format. Every line must match a known pattern or be blank.
5
+ * Throws on parse errors with file name and line number.
6
+ *
7
+ * Units:
8
+ * - screen: size in px, zone values in px
9
+ * - print/signage: size in mm (px computed via dpi), zone values in mm (converted to px)
10
+ */
11
+
12
+ import type { ZoneInsets } from './types';
13
+
14
+ // ── Parsed types ───────────────────────────────────────────────────────────────
15
+
16
+ export interface ParsedFormat {
17
+ id: string;
18
+ /** Optional display name (from `name:` key). Falls back to label if not set. */
19
+ name?: string;
20
+ label: string;
21
+ sublabel: string;
22
+ category: 'instagram' | 'linkedin' | 'print' | 'signage';
23
+ // Pixel dimensions (always present — computed from mm+dpi for print/signage)
24
+ widthPx: number;
25
+ heightPx: number;
26
+ // Physical dimensions (print/signage only)
27
+ widthMm?: number;
28
+ heightMm?: number;
29
+ dpi?: number;
30
+ bleedMm?: number;
31
+ // Optional grid override — absent means auto portrait/landscape selection
32
+ grid?: { bands: number; cols: number };
33
+ // Zones — always stored in px (mm values converted during parse)
34
+ danger: ZoneInsets;
35
+ gridCrop: ZoneInsets | null;
36
+ comfort: number; // px (0 if comfortMm is used)
37
+ comfortMm?: number; // mm (print/signage)
38
+ /** Capability restrictions from the `no:` field (e.g. ['motion', 'video', 'photo']). */
39
+ restrictions?: string[];
40
+ /** Allowed purpose IDs from the `purposes:` field. Absent = all purposes allowed. */
41
+ purposes?: string[];
42
+ }
43
+
44
+ // ── Helpers ────────────────────────────────────────────────────────────────────
45
+
46
+ const ZONE_NONE: ZoneInsets = { top: 0, bottom: 0, left: 0, right: 0 };
47
+
48
+ function parsePositiveInt(s: string, fileName: string, lineNum: number, field: string): number {
49
+ const n = parseInt(s, 10);
50
+ if (isNaN(n) || n < 0) throw new Error(`${fileName}:${lineNum}: ${field} must be a non-negative integer, got "${s}"`);
51
+ return n;
52
+ }
53
+
54
+ /** Parse inset shorthand into ZoneInsets (CSS-style):
55
+ * 1 value: all sides equal
56
+ * 2 values: top/bottom = v[0], left/right = v[1]
57
+ * 4 values: top right bottom left */
58
+ function parseInsets(parts: string[], fileName: string, lineNum: number, field: string): ZoneInsets {
59
+ if (parts.length === 1) {
60
+ const v = parsePositiveInt(parts[0], fileName, lineNum, field);
61
+ return { top: v, right: v, bottom: v, left: v };
62
+ }
63
+ if (parts.length === 2) {
64
+ const v = parsePositiveInt(parts[0], fileName, lineNum, `${field} top/bottom`);
65
+ const h = parsePositiveInt(parts[1], fileName, lineNum, `${field} left/right`);
66
+ return { top: v, right: h, bottom: v, left: h };
67
+ }
68
+ if (parts.length === 4) {
69
+ return {
70
+ top: parsePositiveInt(parts[0], fileName, lineNum, `${field} top`),
71
+ right: parsePositiveInt(parts[1], fileName, lineNum, `${field} right`),
72
+ bottom: parsePositiveInt(parts[2], fileName, lineNum, `${field} bottom`),
73
+ left: parsePositiveInt(parts[3], fileName, lineNum, `${field} left`),
74
+ };
75
+ }
76
+ throw new Error(`${fileName}:${lineNum}: ${field} requires 1, 2, or 4 values (got ${parts.length})`);
77
+ }
78
+
79
+ function mmToPx(mm: number, dpi: number): number {
80
+ return Math.round(mm * dpi / 25.4);
81
+ }
82
+
83
+ function scaleInsets(insets: ZoneInsets, factor: number): ZoneInsets {
84
+ return {
85
+ top: Math.round(insets.top * factor),
86
+ right: Math.round(insets.right * factor),
87
+ bottom: Math.round(insets.bottom * factor),
88
+ left: Math.round(insets.left * factor),
89
+ };
90
+ }
91
+
92
+ // ── Main parser ────────────────────────────────────────────────────────────────
93
+
94
+ export function parseFormatFile(content: string, fileName: string): ParsedFormat {
95
+ const lines = content.split('\n');
96
+
97
+ let id: string | undefined;
98
+ let name: string | undefined;
99
+ let label: string | undefined;
100
+ let sublabel: string | undefined;
101
+ let category: ParsedFormat['category'] | undefined;
102
+ let widthPx: number | undefined;
103
+ let heightPx: number | undefined;
104
+ let widthMm: number | undefined;
105
+ let heightMm: number | undefined;
106
+ let dpi: number | undefined;
107
+ let bleedMm: number | undefined;
108
+ let grid: ParsedFormat['grid'] | undefined;
109
+ let danger: ZoneInsets = ZONE_NONE;
110
+ let gridCrop: ZoneInsets | null = null;
111
+ let comfortRaw: number | undefined; // px for screen, mm for print/signage
112
+ let comfortIsMm = false;
113
+ let restrictions: string[] | undefined;
114
+ let purposes: string[] | undefined;
115
+
116
+ for (let i = 0; i < lines.length; i++) {
117
+ const lineNum = i + 1;
118
+ const line = lines[i].trim();
119
+
120
+ // Blank lines and comments
121
+ if (!line || line.startsWith('#')) continue;
122
+
123
+ const colonIdx = line.indexOf(':');
124
+ if (colonIdx === -1) throw new Error(`${fileName}:${lineNum}: expected "key: value", got "${line}"`);
125
+
126
+ const key = line.slice(0, colonIdx).trim();
127
+ const value = line.slice(colonIdx + 1).trim();
128
+
129
+ switch (key) {
130
+ case 'id':
131
+ id = value;
132
+ break;
133
+
134
+ case 'name':
135
+ name = value;
136
+ break;
137
+
138
+ case 'label':
139
+ label = value;
140
+ break;
141
+
142
+ case 'sublabel':
143
+ sublabel = value;
144
+ break;
145
+
146
+ case 'category':
147
+ if (value !== 'instagram' && value !== 'linkedin' && value !== 'print' && value !== 'signage')
148
+ throw new Error(`${fileName}:${lineNum}: category must be instagram, linkedin, print, or signage, got "${value}"`);
149
+ category = value;
150
+ break;
151
+
152
+ case 'size': {
153
+ // "1080 1350" (px) or "594mm 297mm" (mm)
154
+ const parts = value.split(/\s+/);
155
+ if (parts.length !== 2) throw new Error(`${fileName}:${lineNum}: size requires 2 values, got "${value}"`);
156
+ const isMm = parts[0].endsWith('mm');
157
+ if (isMm) {
158
+ widthMm = parseFloat(parts[0]);
159
+ heightMm = parseFloat(parts[1]);
160
+ if (isNaN(widthMm) || isNaN(heightMm) || widthMm <= 0 || heightMm <= 0)
161
+ throw new Error(`${fileName}:${lineNum}: invalid mm dimensions "${value}"`);
162
+ // px computed after dpi is parsed; set flag for now
163
+ } else {
164
+ widthPx = parsePositiveInt(parts[0], fileName, lineNum, 'width');
165
+ heightPx = parsePositiveInt(parts[1], fileName, lineNum, 'height');
166
+ if (widthPx <= 0 || heightPx <= 0)
167
+ throw new Error(`${fileName}:${lineNum}: size values must be positive`);
168
+ }
169
+ break;
170
+ }
171
+
172
+ case 'dpi':
173
+ dpi = parseInt(value, 10);
174
+ if (isNaN(dpi) || dpi <= 0) throw new Error(`${fileName}:${lineNum}: dpi must be a positive integer, got "${value}"`);
175
+ break;
176
+
177
+ case 'bleed': {
178
+ const raw = value.endsWith('mm') ? parseFloat(value) : parseFloat(value);
179
+ if (isNaN(raw) || raw < 0) throw new Error(`${fileName}:${lineNum}: bleed must be a non-negative number, got "${value}"`);
180
+ bleedMm = raw;
181
+ break;
182
+ }
183
+
184
+ case 'grid': {
185
+ const parts = value.split(/\s+/);
186
+ if (parts.length !== 2) throw new Error(`${fileName}:${lineNum}: grid requires "bands cols", got "${value}"`);
187
+ const bands = parseInt(parts[0], 10);
188
+ const cols = parseInt(parts[1], 10);
189
+ if (isNaN(bands) || bands < 1 || isNaN(cols) || cols < 1)
190
+ throw new Error(`${fileName}:${lineNum}: grid bands and cols must be positive integers`);
191
+ grid = { bands, cols };
192
+ break;
193
+ }
194
+
195
+ case 'danger': {
196
+ const parts = value.split(/\s+/);
197
+ danger = parseInsets(parts, fileName, lineNum, 'danger');
198
+ break;
199
+ }
200
+
201
+ case 'crop': {
202
+ if (value === 'none') {
203
+ gridCrop = null;
204
+ } else {
205
+ const parts = value.split(/\s+/);
206
+ gridCrop = parseInsets(parts, fileName, lineNum, 'crop');
207
+ }
208
+ break;
209
+ }
210
+
211
+ case 'comfort': {
212
+ const isMm = value.endsWith('mm');
213
+ comfortIsMm = isMm;
214
+ comfortRaw = isMm ? parseFloat(value) : parseFloat(value);
215
+ if (isNaN(comfortRaw) || comfortRaw < 0)
216
+ throw new Error(`${fileName}:${lineNum}: comfort must be a non-negative number, got "${value}"`);
217
+ break;
218
+ }
219
+
220
+ case 'no': {
221
+ restrictions = value.split(/\s+/).filter(Boolean);
222
+ break;
223
+ }
224
+
225
+ case 'purposes': {
226
+ purposes = value.split(/[\s,]+/).filter(Boolean);
227
+ break;
228
+ }
229
+
230
+ default:
231
+ throw new Error(`${fileName}:${lineNum}: unknown key "${key}"`);
232
+ }
233
+ }
234
+
235
+ // ── Validation ───────────────────────────────────────────────────────────────
236
+ if (!id) throw new Error(`${fileName}: missing required field "id"`);
237
+ if (!label) throw new Error(`${fileName}: missing required field "label"`);
238
+ if (!sublabel) throw new Error(`${fileName}: missing required field "sublabel"`);
239
+ if (!category) throw new Error(`${fileName}: missing required field "category"`);
240
+
241
+ // ── Resolve px dimensions for mm-based formats ───────────────────────────────
242
+ const effectiveDpi = dpi ?? 300;
243
+ if (widthMm !== undefined && heightMm !== undefined) {
244
+ const pxPerMm = effectiveDpi / 25.4;
245
+ const bleed = bleedMm ?? 0;
246
+ widthPx = Math.round((widthMm + 2 * bleed) * pxPerMm);
247
+ heightPx = Math.round((heightMm + 2 * bleed) * pxPerMm);
248
+ }
249
+
250
+ if (widthPx === undefined || heightPx === undefined)
251
+ throw new Error(`${fileName}: missing required field "size"`);
252
+
253
+ // ── Convert zone insets from mm to px for physical formats ──────────────────────
254
+ if (category === 'print' || category === 'signage') {
255
+ const pxPerMm = effectiveDpi / 25.4;
256
+ danger = scaleInsets(danger, pxPerMm);
257
+ if (gridCrop) gridCrop = scaleInsets(gridCrop, pxPerMm);
258
+ }
259
+
260
+ // ── Resolve comfort ──────────────────────────────────────────────────────────
261
+ let comfort = 0;
262
+ let comfortMm: number | undefined;
263
+ if (comfortRaw !== undefined) {
264
+ if (comfortIsMm || category === 'print' || category === 'signage') {
265
+ comfortMm = comfortRaw;
266
+ } else {
267
+ comfort = Math.round(comfortRaw);
268
+ }
269
+ }
270
+
271
+ return {
272
+ id, name, label, sublabel, category,
273
+ widthPx, heightPx,
274
+ widthMm, heightMm,
275
+ dpi: dpi ?? (category === 'print' || category === 'signage' ? effectiveDpi : undefined),
276
+ bleedMm,
277
+ grid,
278
+ danger, gridCrop,
279
+ comfort, comfortMm,
280
+ restrictions,
281
+ purposes,
282
+ };
283
+ }