@mseep/dembrandt 0.19.5

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 (139) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +408 -0
  3. package/dist/index.d.ts +8 -0
  4. package/dist/index.js +532 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/lib/browser.d.ts +16 -0
  7. package/dist/lib/browser.js +27 -0
  8. package/dist/lib/browser.js.map +1 -0
  9. package/dist/lib/colors.d.ts +101 -0
  10. package/dist/lib/colors.js +405 -0
  11. package/dist/lib/colors.js.map +1 -0
  12. package/dist/lib/compare.d.ts +31 -0
  13. package/dist/lib/compare.js +46 -0
  14. package/dist/lib/compare.js.map +1 -0
  15. package/dist/lib/discovery.d.ts +31 -0
  16. package/dist/lib/discovery.js +243 -0
  17. package/dist/lib/discovery.js.map +1 -0
  18. package/dist/lib/drift.d.ts +64 -0
  19. package/dist/lib/drift.js +383 -0
  20. package/dist/lib/drift.js.map +1 -0
  21. package/dist/lib/dtcg/validate.d.ts +51 -0
  22. package/dist/lib/dtcg/validate.js +1403 -0
  23. package/dist/lib/dtcg/validate.js.map +1 -0
  24. package/dist/lib/exit-codes.d.ts +29 -0
  25. package/dist/lib/exit-codes.js +26 -0
  26. package/dist/lib/exit-codes.js.map +1 -0
  27. package/dist/lib/extractors/breakpoints.d.ts +5 -0
  28. package/dist/lib/extractors/breakpoints.js +450 -0
  29. package/dist/lib/extractors/breakpoints.js.map +1 -0
  30. package/dist/lib/extractors/colors.d.ts +2 -0
  31. package/dist/lib/extractors/colors.js +657 -0
  32. package/dist/lib/extractors/colors.js.map +1 -0
  33. package/dist/lib/extractors/components.d.ts +4 -0
  34. package/dist/lib/extractors/components.js +370 -0
  35. package/dist/lib/extractors/components.js.map +1 -0
  36. package/dist/lib/extractors/index.d.ts +9 -0
  37. package/dist/lib/extractors/index.js +1257 -0
  38. package/dist/lib/extractors/index.js.map +1 -0
  39. package/dist/lib/extractors/logo.d.ts +2 -0
  40. package/dist/lib/extractors/logo.js +626 -0
  41. package/dist/lib/extractors/logo.js.map +1 -0
  42. package/dist/lib/extractors/spacing.d.ts +4 -0
  43. package/dist/lib/extractors/spacing.js +163 -0
  44. package/dist/lib/extractors/spacing.js.map +1 -0
  45. package/dist/lib/extractors/teach.d.ts +1 -0
  46. package/dist/lib/extractors/teach.js +66 -0
  47. package/dist/lib/extractors/teach.js.map +1 -0
  48. package/dist/lib/extractors/typography.d.ts +1 -0
  49. package/dist/lib/extractors/typography.js +163 -0
  50. package/dist/lib/extractors/typography.js.map +1 -0
  51. package/dist/lib/findings.d.ts +34 -0
  52. package/dist/lib/findings.js +166 -0
  53. package/dist/lib/findings.js.map +1 -0
  54. package/dist/lib/formatters/dtcg.d.ts +10 -0
  55. package/dist/lib/formatters/dtcg.js +416 -0
  56. package/dist/lib/formatters/dtcg.js.map +1 -0
  57. package/dist/lib/formatters/html.d.ts +25 -0
  58. package/dist/lib/formatters/html.js +479 -0
  59. package/dist/lib/formatters/html.js.map +1 -0
  60. package/dist/lib/formatters/markdown.d.ts +5 -0
  61. package/dist/lib/formatters/markdown.js +568 -0
  62. package/dist/lib/formatters/markdown.js.map +1 -0
  63. package/dist/lib/formatters/pdf.d.ts +12 -0
  64. package/dist/lib/formatters/pdf.js +1121 -0
  65. package/dist/lib/formatters/pdf.js.map +1 -0
  66. package/dist/lib/formatters/terminal.d.ts +6 -0
  67. package/dist/lib/formatters/terminal.js +954 -0
  68. package/dist/lib/formatters/terminal.js.map +1 -0
  69. package/dist/lib/formatters/theme.d.ts +35 -0
  70. package/dist/lib/formatters/theme.js +37 -0
  71. package/dist/lib/formatters/theme.js.map +1 -0
  72. package/dist/lib/merger.d.ts +14 -0
  73. package/dist/lib/merger.js +362 -0
  74. package/dist/lib/merger.js.map +1 -0
  75. package/dist/lib/normalize.d.ts +29 -0
  76. package/dist/lib/normalize.js +59 -0
  77. package/dist/lib/normalize.js.map +1 -0
  78. package/dist/lib/robots.d.ts +12 -0
  79. package/dist/lib/robots.js +110 -0
  80. package/dist/lib/robots.js.map +1 -0
  81. package/dist/lib/run-summary.d.ts +40 -0
  82. package/dist/lib/run-summary.js +64 -0
  83. package/dist/lib/run-summary.js.map +1 -0
  84. package/dist/lib/types.d.ts +329 -0
  85. package/dist/lib/types.js +7 -0
  86. package/dist/lib/types.js.map +1 -0
  87. package/dist/lib/version.d.ts +134 -0
  88. package/dist/lib/version.js +153 -0
  89. package/dist/lib/version.js.map +1 -0
  90. package/dist/mcp-server.d.ts +11 -0
  91. package/dist/mcp-server.js +311 -0
  92. package/dist/mcp-server.js.map +1 -0
  93. package/dist/package.json +106 -0
  94. package/dist/test/_vitest-shim.d.ts +13 -0
  95. package/dist/test/_vitest-shim.js +23 -0
  96. package/dist/test/_vitest-shim.js.map +1 -0
  97. package/dist/test/cli.test.d.ts +1 -0
  98. package/dist/test/cli.test.js +24 -0
  99. package/dist/test/cli.test.js.map +1 -0
  100. package/dist/test/colors.test.d.ts +1 -0
  101. package/dist/test/colors.test.js +64 -0
  102. package/dist/test/colors.test.js.map +1 -0
  103. package/dist/test/compare.test.d.ts +1 -0
  104. package/dist/test/compare.test.js +57 -0
  105. package/dist/test/compare.test.js.map +1 -0
  106. package/dist/test/drift.test.d.ts +1 -0
  107. package/dist/test/drift.test.js +53 -0
  108. package/dist/test/drift.test.js.map +1 -0
  109. package/dist/test/dtcg-formatter.test.d.ts +1 -0
  110. package/dist/test/dtcg-formatter.test.js +48 -0
  111. package/dist/test/dtcg-formatter.test.js.map +1 -0
  112. package/dist/test/dtcg-validate.test.d.ts +1 -0
  113. package/dist/test/dtcg-validate.test.js +2129 -0
  114. package/dist/test/dtcg-validate.test.js.map +1 -0
  115. package/dist/test/exit-codes.test.d.ts +1 -0
  116. package/dist/test/exit-codes.test.js +53 -0
  117. package/dist/test/exit-codes.test.js.map +1 -0
  118. package/dist/test/findings.test.d.ts +1 -0
  119. package/dist/test/findings.test.js +77 -0
  120. package/dist/test/findings.test.js.map +1 -0
  121. package/dist/test/html.test.d.ts +1 -0
  122. package/dist/test/html.test.js +95 -0
  123. package/dist/test/html.test.js.map +1 -0
  124. package/dist/test/markdown.test.d.ts +1 -0
  125. package/dist/test/markdown.test.js +145 -0
  126. package/dist/test/markdown.test.js.map +1 -0
  127. package/dist/test/merger.test.d.ts +1 -0
  128. package/dist/test/merger.test.js +98 -0
  129. package/dist/test/merger.test.js.map +1 -0
  130. package/dist/test/normalize.test.d.ts +1 -0
  131. package/dist/test/normalize.test.js +47 -0
  132. package/dist/test/normalize.test.js.map +1 -0
  133. package/dist/test/run-summary.test.d.ts +1 -0
  134. package/dist/test/run-summary.test.js +45 -0
  135. package/dist/test/run-summary.test.js.map +1 -0
  136. package/dist/test/version.test.d.ts +1 -0
  137. package/dist/test/version.test.js +73 -0
  138. package/dist/test/version.test.js.map +1 -0
  139. package/package.json +106 -0
@@ -0,0 +1,1121 @@
1
+ /**
2
+ * PDF Brand Guide Generator
3
+ *
4
+ * Renders extraction results as a minimal, professional brand guide PDF
5
+ * using Playwright's page.pdf() — no extra dependencies.
6
+ */
7
+ import { loadBrowserEngines } from '../browser.js';
8
+ import { convertColor, hexToRgb } from '../colors.js';
9
+ /**
10
+ * Generate a brand guide PDF from extraction data
11
+ * @param {Object} data - Extraction results from extractBranding()
12
+ * @param {string} outputPath - Path to write the PDF
13
+ */
14
+ export async function generatePDF(data, outputPath, existingBrowser) {
15
+ const html = buildHTML(data);
16
+ const ownBrowser = !existingBrowser;
17
+ let browser = existingBrowser;
18
+ if (!browser) {
19
+ const { chromium } = await loadBrowserEngines();
20
+ browser = await chromium.launch({ headless: true });
21
+ }
22
+ const page = await browser.newPage();
23
+ await page.setContent(html, { waitUntil: 'networkidle' });
24
+ await page.pdf({
25
+ path: outputPath,
26
+ format: 'A4',
27
+ margin: { top: '0', right: '0', bottom: '0', left: '0' },
28
+ printBackground: true,
29
+ });
30
+ await page.close();
31
+ if (ownBrowser)
32
+ await browser.close();
33
+ }
34
+ function hex(colorString) {
35
+ if (!colorString)
36
+ return null;
37
+ try {
38
+ const c = convertColor(colorString);
39
+ return c ? c.hex : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ function textColor(bgHex) {
46
+ if (!bgHex)
47
+ return '#000';
48
+ const r = parseInt(bgHex.slice(1, 3), 16);
49
+ const g = parseInt(bgHex.slice(3, 5), 16);
50
+ const b = parseInt(bgHex.slice(5, 7), 16);
51
+ const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
52
+ return lum > 0.55 ? '#1a1a1a' : '#fff';
53
+ }
54
+ /** Sort colors by hue for visual grouping */
55
+ function sortByHue(colors) {
56
+ return [...colors].sort((a, b) => {
57
+ const aRgb = hexToRgb(a.hex);
58
+ const bRgb = hexToRgb(b.hex);
59
+ if (!aRgb || !bRgb)
60
+ return 0;
61
+ const hue = (r, g, b) => {
62
+ r /= 255;
63
+ g /= 255;
64
+ b /= 255;
65
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
66
+ const sat = max === 0 ? 0 : (max - min) / max;
67
+ const lum = (max + min) / 2;
68
+ // Grays/whites/blacks: sort by lightness at the end
69
+ if (sat < 0.08)
70
+ return 720 + lum * 360;
71
+ let h;
72
+ const d = max - min;
73
+ if (max === r)
74
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
75
+ else if (max === g)
76
+ h = ((b - r) / d + 2) / 6;
77
+ else
78
+ h = ((r - g) / d + 4) / 6;
79
+ return h * 360;
80
+ };
81
+ return hue(aRgb.r, aRgb.g, aRgb.b) - hue(bRgb.r, bRgb.g, bRgb.b);
82
+ });
83
+ }
84
+ function pickCoverBg(colors) {
85
+ const gathered = gatherColors(colors);
86
+ if (!gathered.length)
87
+ return '#0a0a0a';
88
+ // Check if brand colors are mostly light — if so, use white bg so they pop
89
+ let lightCount = 0;
90
+ for (const c of gathered.slice(0, 6)) {
91
+ const rgb = hexToRgb(c.hex);
92
+ if (!rgb)
93
+ continue;
94
+ const lum = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
95
+ if (lum > 0.55)
96
+ lightCount++;
97
+ }
98
+ return lightCount > gathered.slice(0, 6).length / 2 ? '#0a0a0a' : '#ffffff';
99
+ }
100
+ function formatRgb(hexStr) {
101
+ const rgb = hexToRgb(hexStr);
102
+ if (!rgb)
103
+ return '';
104
+ return `${rgb.r}, ${rgb.g}, ${rgb.b}`;
105
+ }
106
+ function formatCmyk(hexStr) {
107
+ const rgb = hexToRgb(hexStr);
108
+ if (!rgb)
109
+ return '';
110
+ const r = rgb.r / 255, g = rgb.g / 255, b = rgb.b / 255;
111
+ const k = 1 - Math.max(r, g, b);
112
+ if (k === 1)
113
+ return '0, 0, 0, 100';
114
+ const c = Math.round(((1 - r - k) / (1 - k)) * 100);
115
+ const m = Math.round(((1 - g - k) / (1 - k)) * 100);
116
+ const y = Math.round(((1 - b - k) / (1 - k)) * 100);
117
+ return `${c}, ${m}, ${y}, ${Math.round(k * 100)}`;
118
+ }
119
+ /** Chroma proxy: max-min of RGB channels (0–255). Higher = more saturated. */
120
+ function chroma(hexStr) {
121
+ const rgb = hexToRgb(hexStr);
122
+ if (!rgb)
123
+ return 0;
124
+ return Math.max(rgb.r, rgb.g, rgb.b) - Math.min(rgb.r, rgb.g, rgb.b);
125
+ }
126
+ function titleCase(s) {
127
+ return String(s).replace(/[-_]+/g, ' ').trim().replace(/\b\w/g, c => c.toUpperCase());
128
+ }
129
+ /** Descriptive name from hue/lightness for unlabeled palette colors. */
130
+ function hueName(hexStr) {
131
+ const rgb = hexToRgb(hexStr);
132
+ if (!rgb)
133
+ return 'Color';
134
+ const r = rgb.r / 255, g = rgb.g / 255, b = rgb.b / 255;
135
+ const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
136
+ const l = (max + min) / 2;
137
+ const s = max === 0 ? 0 : d / max;
138
+ if (s < 0.10) {
139
+ if (l > 0.92)
140
+ return 'White';
141
+ if (l > 0.6)
142
+ return 'Light Gray';
143
+ if (l > 0.32)
144
+ return 'Gray';
145
+ if (l > 0.1)
146
+ return 'Dark Gray';
147
+ return 'Black';
148
+ }
149
+ let h;
150
+ if (max === r)
151
+ h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
152
+ else if (max === g)
153
+ h = ((b - r) / d + 2) / 6;
154
+ else
155
+ h = ((r - g) / d + 4) / 6;
156
+ h *= 360;
157
+ if (h < 15 || h >= 345)
158
+ return 'Red';
159
+ if (h < 45)
160
+ return 'Orange';
161
+ if (h < 70)
162
+ return 'Yellow';
163
+ if (h < 160)
164
+ return 'Green';
165
+ if (h < 200)
166
+ return 'Teal';
167
+ if (h < 255)
168
+ return 'Blue';
169
+ if (h < 290)
170
+ return 'Purple';
171
+ return 'Pink';
172
+ }
173
+ /** Display name: semantic role if labelled, else a descriptive hue name. */
174
+ function colorName(c) {
175
+ return c.label ? titleCase(c.label) : hueName(c.hex);
176
+ }
177
+ function weightName(w) {
178
+ const map = { 100: 'Thin', 200: 'Extra Light', 300: 'Light', 400: 'Regular', 500: 'Medium', 600: 'Semi Bold', 700: 'Bold', 800: 'Extra Bold', 900: 'Black' };
179
+ return map[w] || `Weight ${w}`;
180
+ }
181
+ function buildHTML(data) {
182
+ let domain;
183
+ try {
184
+ domain = new URL(data.url).hostname.replace('www.', '');
185
+ }
186
+ catch {
187
+ domain = 'unknown';
188
+ }
189
+ const date = new Date(data.extractedAt).toLocaleDateString('en-US', {
190
+ year: 'numeric', month: 'long', day: 'numeric'
191
+ });
192
+ // Derive company name: extracted siteName, or capitalize domain
193
+ const companyName = data.siteName || domain.split('.')[0].replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
194
+ const allColors = gatherColors(data.colors);
195
+ const semanticColors = allColors.filter(c => c.label && c.source === 'semantic');
196
+ const paletteColors = allColors.filter(c => c.source !== 'semantic');
197
+ const colors = sortByHue([...semanticColors, ...paletteColors]);
198
+ const fonts = gatherFonts(data.typography);
199
+ const googleFonts = data.typography?.sources?.googleFonts || [];
200
+ // Build Google Fonts import URL for available fonts
201
+ const googleFontImport = (() => {
202
+ if (!googleFonts.length || !fonts.length)
203
+ return '';
204
+ const available = fonts.filter(f => googleFonts.some(gf => gf.toLowerCase() === f.family.toLowerCase()));
205
+ if (!available.length)
206
+ return '';
207
+ const params = available.map(f => {
208
+ const weights = f.weights.length ? f.weights.join(';') : '400';
209
+ return `family=${encodeURIComponent(f.family)}:wght@${weights}`;
210
+ }).join('&');
211
+ return `@import url('https://fonts.googleapis.com/css2?${params}&display=swap');`;
212
+ })();
213
+ // Detect if the logo is light (white on transparent) — needs dark background
214
+ const logoUrl = getLogoImageUrl(data);
215
+ const logoIsLight = (() => {
216
+ if (!data.logo)
217
+ return false;
218
+ const url = (data.logo.url || '').toLowerCase();
219
+ // Filename hints: "white", "valkoinen", "light", "neg", "inverse"
220
+ if (/white|valkoinen|light|negat|inver|_w\.|_w-|[-_]neg/i.test(url))
221
+ return true;
222
+ // If the logo's actual background is dark, the logo itself is likely light
223
+ if (data.logo.background) {
224
+ const bgH = hex(data.logo.background);
225
+ if (bgH) {
226
+ const rgb = hexToRgb(bgH);
227
+ if (rgb) {
228
+ const lum = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
229
+ if (lum < 0.3)
230
+ return true;
231
+ }
232
+ }
233
+ }
234
+ return false;
235
+ })();
236
+ // Use logo's background, or force dark if logo is light, else pick from colors
237
+ const logoBgHex = data.logo?.background ? hex(data.logo.background) : null;
238
+ const primaryColor = logoIsLight ? (logoBgHex || '#0a0a0a') : (logoBgHex || pickCoverBg(data.colors));
239
+ const coverTextColor = textColor(primaryColor);
240
+ const coverSubtitleColor = coverTextColor === '#fff' ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.4)';
241
+ const coverDateColor = coverTextColor === '#fff' ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.25)';
242
+ let pageNum = 1;
243
+ const footer = (num) => `
244
+ <div class="page-footer">
245
+ <span class="footer-domain">${escapeHtml(domain)}</span>
246
+ <span>Brand Guide</span>
247
+ <span>${num}</span>
248
+ </div>`;
249
+ return `<!DOCTYPE html>
250
+ <html>
251
+ <head>
252
+ <meta charset="utf-8">
253
+ <title>${escapeHtml(domain)} - Brand Guide - Made with Dembrandt</title>
254
+ <meta name="author" content="Dembrandt">
255
+ <meta name="keywords" content="dembrandt, brand guide, ${escapeHtml(domain)}">
256
+ <meta name="description" content="Brand guide for ${escapeHtml(domain)}, generated by Dembrandt">
257
+ <style>
258
+ ${googleFontImport}
259
+ @page { size: A4; margin: 0; }
260
+ * { margin: 0; padding: 0; box-sizing: border-box; }
261
+
262
+ body {
263
+ font-family: -apple-system, 'Helvetica Neue', Arial, sans-serif;
264
+ color: #1a1a1a;
265
+ background: #fff;
266
+ font-size: 14px;
267
+ line-height: 1.5;
268
+ -webkit-print-color-adjust: exact;
269
+ print-color-adjust: exact;
270
+ }
271
+
272
+ .page {
273
+ width: 210mm;
274
+ height: 297mm;
275
+ padding: 56px 64px 64px;
276
+ page-break-after: always;
277
+ position: relative;
278
+ overflow: hidden;
279
+ }
280
+
281
+ .page:last-child { page-break-after: auto; }
282
+
283
+ /* Cover */
284
+ .cover {
285
+ display: flex;
286
+ flex-direction: column;
287
+ justify-content: center;
288
+ align-items: center;
289
+ text-align: center;
290
+ padding: 96px 80px;
291
+ background: ${primaryColor};
292
+ color: ${coverTextColor};
293
+ }
294
+
295
+ .cover .logo-img {
296
+ max-width: 340px;
297
+ max-height: 180px;
298
+ margin-bottom: 64px;
299
+ object-fit: contain;
300
+ }
301
+
302
+ .cover .domain {
303
+ font-size: 56px;
304
+ font-weight: 700;
305
+ line-height: 1.04;
306
+ margin-bottom: 28px;
307
+ }
308
+
309
+ .cover .rule {
310
+ width: 48px;
311
+ height: 2px;
312
+ background: ${coverTextColor};
313
+ opacity: 0.5;
314
+ margin: 0 auto 28px;
315
+ }
316
+
317
+ .cover .subtitle {
318
+ font-size: 15px;
319
+ font-weight: 600;
320
+ color: ${coverSubtitleColor};
321
+ }
322
+
323
+ .cover .cover-meta {
324
+ position: absolute;
325
+ bottom: 64px;
326
+ left: 0;
327
+ right: 0;
328
+ text-align: center;
329
+ font-size: 13px;
330
+ color: ${coverDateColor};
331
+ }
332
+
333
+ /* Back cover */
334
+ .back-cover {
335
+ display: flex;
336
+ flex-direction: column;
337
+ justify-content: center;
338
+ align-items: center;
339
+ text-align: center;
340
+ background: ${primaryColor};
341
+ color: ${coverTextColor};
342
+ }
343
+
344
+ .back-cover .logo-img {
345
+ max-width: 220px;
346
+ max-height: 110px;
347
+ object-fit: contain;
348
+ margin-bottom: 28px;
349
+ }
350
+
351
+ .back-cover .rule {
352
+ width: 40px;
353
+ height: 2px;
354
+ background: ${coverTextColor};
355
+ opacity: 0.45;
356
+ margin: 0 auto 24px;
357
+ }
358
+
359
+ .back-cover .back-doc {
360
+ font-size: 14px;
361
+ font-weight: 600;
362
+ color: ${coverSubtitleColor};
363
+ margin-bottom: 14px;
364
+ }
365
+
366
+ .back-cover .back-copyright {
367
+ font-size: 12px;
368
+ line-height: 1.7;
369
+ color: ${coverDateColor};
370
+ }
371
+
372
+ .back-cover .back-attrib {
373
+ position: absolute;
374
+ bottom: 56px;
375
+ left: 0;
376
+ right: 0;
377
+ text-align: center;
378
+ font-size: 11px;
379
+ color: ${coverDateColor};
380
+ }
381
+
382
+ .back-cover .back-attrib strong {
383
+ font-weight: 700;
384
+ }
385
+
386
+
387
+ /* Section header */
388
+ .section-title {
389
+ font-size: 24px;
390
+ font-weight: 600;
391
+ color: #1a1a1a;
392
+ margin-bottom: 40px;
393
+ padding-bottom: 12px;
394
+ border-bottom: 2px solid #1a1a1a;
395
+ }
396
+
397
+ /* Colors — block layout */
398
+ .color-hero {
399
+ border-radius: 10px;
400
+ padding: 24px 28px;
401
+ display: flex;
402
+ flex-direction: column;
403
+ justify-content: flex-end;
404
+ min-height: 150px;
405
+ margin-bottom: 24px;
406
+ }
407
+
408
+ .color-hero .ch-name {
409
+ font-size: 22px;
410
+ font-weight: 600;
411
+ margin-bottom: 10px;
412
+ }
413
+
414
+ .color-hero .ch-values {
415
+ font-size: 12px;
416
+ font-family: 'SF Mono', 'Menlo', monospace;
417
+ line-height: 1.7;
418
+ opacity: 0.85;
419
+ }
420
+
421
+ .color-grid {
422
+ display: grid;
423
+ grid-template-columns: repeat(3, 1fr);
424
+ gap: 20px;
425
+ }
426
+
427
+ .color-card .chip {
428
+ border-radius: 8px;
429
+ min-height: 96px;
430
+ display: flex;
431
+ align-items: flex-end;
432
+ padding: 12px 14px;
433
+ }
434
+
435
+ .color-card .chip-name {
436
+ font-size: 13px;
437
+ font-weight: 600;
438
+ }
439
+
440
+ .color-card .card-meta {
441
+ padding: 10px 2px 0;
442
+ font-size: 10px;
443
+ font-family: 'SF Mono', 'Menlo', monospace;
444
+ color: #555;
445
+ line-height: 1.7;
446
+ }
447
+
448
+ .color-card .card-meta .cm-hex {
449
+ color: #1a1a1a;
450
+ font-weight: 600;
451
+ }
452
+
453
+ /* Typography — full page specimens */
454
+ .type-specimen {
455
+ margin-bottom: 48px;
456
+ break-inside: avoid;
457
+ }
458
+
459
+ .type-family-name {
460
+ font-size: 14px;
461
+ font-weight: 600;
462
+ color: #4a4a4a;
463
+ margin-bottom: 20px;
464
+ }
465
+
466
+ .type-alphabet {
467
+ font-weight: 400;
468
+ color: #1a1a1a;
469
+ line-height: 1.2;
470
+ margin-bottom: 12px;
471
+ letter-spacing: -0.5px;
472
+ }
473
+
474
+ .type-paragraph {
475
+ font-size: 18px;
476
+ line-height: 1.6;
477
+ color: #333;
478
+ margin-top: 32px;
479
+ max-width: 520px;
480
+ }
481
+
482
+ .type-meta {
483
+ font-size: 13px;
484
+ font-family: 'SF Mono', 'Menlo', monospace;
485
+ color: #555;
486
+ margin-bottom: 24px;
487
+ }
488
+
489
+ .type-weights {
490
+ display: flex;
491
+ flex-direction: column;
492
+ gap: 16px;
493
+ margin-top: 28px;
494
+ }
495
+
496
+ .type-weight-item {
497
+ display: flex;
498
+ align-items: baseline;
499
+ gap: 24px;
500
+ }
501
+
502
+ .type-weight-label {
503
+ font-size: 12px;
504
+ font-family: 'SF Mono', 'Menlo', monospace;
505
+ color: #767676;
506
+ min-width: 100px;
507
+ text-align: right;
508
+ flex-shrink: 0;
509
+ }
510
+
511
+ .type-weight-preview {
512
+ font-size: 28px;
513
+ color: #1a1a1a;
514
+ white-space: nowrap;
515
+ overflow: hidden;
516
+ text-overflow: ellipsis;
517
+ }
518
+
519
+ /* Footer */
520
+ .page-footer {
521
+ position: absolute;
522
+ bottom: 36px;
523
+ left: 64px;
524
+ right: 64px;
525
+ display: flex;
526
+ justify-content: space-between;
527
+ font-size: 10px;
528
+ color: #767676;
529
+ }
530
+
531
+ .page-footer .footer-domain { font-weight: 600; }
532
+
533
+ /* Table of Contents */
534
+ .toc {
535
+ margin-top: 80px;
536
+ }
537
+
538
+ .toc-list {
539
+ list-style: none;
540
+ }
541
+
542
+ .toc-list li {
543
+ display: flex;
544
+ align-items: baseline;
545
+ padding: 18px 0;
546
+ font-size: 18px;
547
+ font-weight: 500;
548
+ color: #1a1a1a;
549
+ }
550
+
551
+ .toc-list li .toc-title {
552
+ flex-shrink: 0;
553
+ }
554
+
555
+ .toc-list li .toc-dots {
556
+ flex: 1;
557
+ border-bottom: 1px dotted #aaa;
558
+ margin: 0 12px;
559
+ position: relative;
560
+ top: -4px;
561
+ }
562
+
563
+ .toc-list li .toc-page {
564
+ font-size: 18px;
565
+ font-family: 'SF Mono', 'Menlo', monospace;
566
+ font-weight: 400;
567
+ color: #555;
568
+ flex-shrink: 0;
569
+ }
570
+
571
+ .toc-section-group {
572
+ margin-top: 8px;
573
+ padding-top: 8px;
574
+ }
575
+
576
+ .toc-section-group:first-child {
577
+ margin-top: 0;
578
+ padding-top: 0;
579
+ }
580
+
581
+
582
+ /* Logo usage page */
583
+ .logo-grid {
584
+ display: grid;
585
+ grid-template-columns: 1fr 1fr;
586
+ gap: 20px;
587
+ margin-top: 16px;
588
+ }
589
+
590
+ .logo-box {
591
+ border-radius: 8px;
592
+ display: flex;
593
+ align-items: center;
594
+ justify-content: center;
595
+ padding: 32px 24px;
596
+ min-height: 140px;
597
+ position: relative;
598
+ }
599
+
600
+ .logo-box img {
601
+ max-width: 200px;
602
+ max-height: 80px;
603
+ object-fit: contain;
604
+ }
605
+
606
+ .logo-box-label {
607
+ position: absolute;
608
+ bottom: 10px;
609
+ left: 14px;
610
+ font-size: 10px;
611
+ font-family: 'SF Mono', 'Menlo', monospace;
612
+ opacity: 0.5;
613
+ }
614
+
615
+ .logo-box-full {
616
+ grid-column: 1 / -1;
617
+ min-height: 120px;
618
+ }
619
+
620
+ .logo-box-full img {
621
+ max-width: 280px;
622
+ max-height: 100px;
623
+ }
624
+
625
+ /* Logo misuse — "don't" grid */
626
+ .misuse-grid {
627
+ display: grid;
628
+ grid-template-columns: repeat(3, 1fr);
629
+ gap: 20px;
630
+ margin-top: 16px;
631
+ }
632
+
633
+ .misuse-box {
634
+ border: 1px solid #e0e0e0;
635
+ border-radius: 8px;
636
+ padding: 24px 16px 38px;
637
+ min-height: 124px;
638
+ display: flex;
639
+ align-items: center;
640
+ justify-content: center;
641
+ position: relative;
642
+ overflow: hidden;
643
+ }
644
+
645
+ .misuse-box img {
646
+ max-width: 150px;
647
+ max-height: 56px;
648
+ object-fit: contain;
649
+ }
650
+
651
+ .misuse-mark {
652
+ position: absolute;
653
+ top: 10px;
654
+ right: 10px;
655
+ width: 20px;
656
+ height: 20px;
657
+ border-radius: 50%;
658
+ background: #d92d20;
659
+ color: #fff;
660
+ font-size: 12px;
661
+ font-weight: 700;
662
+ display: flex;
663
+ align-items: center;
664
+ justify-content: center;
665
+ line-height: 1;
666
+ }
667
+
668
+ .misuse-label {
669
+ position: absolute;
670
+ bottom: 12px;
671
+ left: 0;
672
+ right: 0;
673
+ text-align: center;
674
+ font-size: 11px;
675
+ color: #555;
676
+ }
677
+ </style>
678
+ </head>
679
+ <body>
680
+
681
+ <!-- COVER -->
682
+ <div class="page cover">
683
+ ${logoUrl ? `<img class="logo-img" src="${escapeAttr(logoUrl)}" />` : ''}
684
+ <div class="domain">${escapeHtml(companyName)}</div>
685
+ <div class="rule"></div>
686
+ <div class="subtitle">Brand Guidelines</div>
687
+ <div class="cover-meta">${escapeHtml(domain)}&nbsp;&nbsp;&middot;&nbsp;&nbsp;${date}${data.meta?.dembrandtVersion ? `&nbsp;&nbsp;&middot;&nbsp;&nbsp;v${escapeHtml(data.meta.dembrandtVersion)}` : ''}</div>
688
+ </div>
689
+
690
+ <!-- TABLE OF CONTENTS -->
691
+ ${(() => {
692
+ pageNum++;
693
+ const tocEntries = [];
694
+ let tocPage = pageNum + 1;
695
+ if (logoUrl) {
696
+ tocEntries.push({ title: 'Logo', page: tocPage });
697
+ tocPage++;
698
+ tocEntries.push({ title: 'Logo Misuse', page: tocPage });
699
+ tocPage++;
700
+ }
701
+ if (colors.length > 0) {
702
+ tocEntries.push({ title: 'Color Palette', page: tocPage });
703
+ tocPage++;
704
+ }
705
+ if (fonts.length > 0) {
706
+ tocEntries.push({ title: 'Typography - Primary', page: tocPage });
707
+ tocPage++;
708
+ if (fonts.length > 1) {
709
+ tocEntries.push({ title: 'Typography - Secondary', page: tocPage });
710
+ tocPage++;
711
+ }
712
+ }
713
+ // Group entries: Logo | Colors | Typography...
714
+ const groups = [];
715
+ let currentGroup = [];
716
+ for (const e of tocEntries) {
717
+ const isTypo = e.title.startsWith('Typography');
718
+ const prevIsTypo = currentGroup.length > 0 && currentGroup[0].title.startsWith('Typography');
719
+ if (currentGroup.length > 0 && isTypo !== prevIsTypo) {
720
+ groups.push(currentGroup);
721
+ currentGroup = [];
722
+ }
723
+ currentGroup.push(e);
724
+ }
725
+ if (currentGroup.length)
726
+ groups.push(currentGroup);
727
+ return `
728
+ <div class="page">
729
+ <div class="section-title">Contents</div>
730
+ <div class="toc">
731
+ <ul class="toc-list">
732
+ ${groups.map(group => group.map((e, i) => `${i === 0 && groups.indexOf(group) > 0 ? '<li class="toc-section-group"></li>' : ''}<li><span class="toc-title">${escapeHtml(e.title)}</span><span class="toc-dots"></span><span class="toc-page">${e.page}</span></li>`).join('')).join('')}
733
+ </ul>
734
+ </div>
735
+ ${footer(pageNum)}
736
+ </div>`;
737
+ })()}
738
+
739
+ <!-- LOGO USAGE -->
740
+ ${logoUrl ? (() => {
741
+ pageNum++;
742
+ const brandColorHex = allColors.find(c => {
743
+ const rgb = hexToRgb(c.hex);
744
+ if (!rgb)
745
+ return false;
746
+ const lum = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255;
747
+ const max = Math.max(rgb.r, rgb.g, rgb.b), min = Math.min(rgb.r, rgb.g, rgb.b);
748
+ return (max - min) > 30 && lum > 0.15 && lum < 0.85;
749
+ })?.hex || '#336699';
750
+ const logoW = data.logo?.width || 200;
751
+ const logoH = data.logo?.height || 60;
752
+ // A = logo height. Safe zone = A on all sides.
753
+ // Scale for the diagram: fit logo to ~160px wide
754
+ // Scale diagram to fit: max 160px wide, max 80px tall
755
+ const scale = Math.min(160 / logoW, 80 / logoH);
756
+ const dW = Math.round(logoW * scale);
757
+ const dH = Math.round(logoH * scale);
758
+ const dA = dH; // A in diagram pixels = scaled logo height
759
+ // Logo contrast boxes: pick label color that works on each bg
760
+ const boxes = [
761
+ { bg: '#ffffff', border: true, label: 'White' },
762
+ { bg: '#0a0a0a', border: false, label: 'Dark' },
763
+ { bg: brandColorHex, border: false, label: brandColorHex.toUpperCase() },
764
+ { bg: '#f5f5f5', border: true, label: 'Light gray' },
765
+ ];
766
+ return `
767
+ <div class="page">
768
+ <div class="section-title">Logo</div>
769
+ <div class="logo-grid">
770
+ ${boxes.map(b => {
771
+ const labelColor = textColor(b.bg);
772
+ // Ensure label has enough contrast: if label would be invisible, override
773
+ const labelStyle = `color:${labelColor === '#fff' ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.45)'}`;
774
+ return `<div class="logo-box${boxes.indexOf(b) < 2 ? ' logo-box-full' : ''}" style="background:${b.bg}${b.border ? ';border:1px solid #d0d0d0' : ''}">
775
+ <img src="${escapeAttr(logoUrl)}" />
776
+ <span class="logo-box-label" style="${labelStyle}">${b.label}</span>
777
+ </div>`;
778
+ }).join('')}
779
+ </div>
780
+
781
+ <div style="margin-top:32px;display:flex;align-items:flex-start;gap:48px">
782
+ <div style="flex-shrink:0">
783
+ <div style="position:relative;width:${dW + dA * 2}px;height:${dH + dA * 2}px;background:#f5f5f5;border-radius:4px">
784
+ <div style="position:absolute;inset:0;border:1px dashed #aaa;border-radius:4px"></div>
785
+ <div style="position:absolute;top:${dA}px;left:${dA}px;width:${dW}px;height:${dH}px;background:#fff;border:1px solid #ccc;border-radius:2px;display:flex;align-items:center;justify-content:center">
786
+ <img src="${escapeAttr(logoUrl)}" style="max-width:${dW - 8}px;max-height:${dH - 8}px;object-fit:contain" />
787
+ </div>
788
+ <div style="position:absolute;top:${Math.round(dA / 2 - 7)}px;left:50%;transform:translateX(-50%);font-size:12px;font-weight:700;font-style:italic;font-family:Georgia,serif;color:#555">A</div>
789
+ <div style="position:absolute;bottom:${Math.round(dA / 2 - 7)}px;left:50%;transform:translateX(-50%);font-size:12px;font-weight:700;font-style:italic;font-family:Georgia,serif;color:#555">A</div>
790
+ <div style="position:absolute;left:${Math.round(dA / 2 - 5)}px;top:50%;transform:translateY(-50%);font-size:12px;font-weight:700;font-style:italic;font-family:Georgia,serif;color:#555">A</div>
791
+ <div style="position:absolute;right:${Math.round(dA / 2 - 5)}px;top:50%;transform:translateY(-50%);font-size:12px;font-weight:700;font-style:italic;font-family:Georgia,serif;color:#555">A</div>
792
+ </div>
793
+ <div style="margin-top:10px;font-size:10px;font-family:'SF Mono','Menlo',monospace;color:#767676;text-align:center">A = ${logoH}px (logo height)</div>
794
+ </div>
795
+ <div style="padding-top:4px">
796
+ <div style="font-size:13px;font-weight:600;color:#1a1a1a;margin-bottom:10px">Clear Space</div>
797
+ <p style="font-size:12px;color:#555;line-height:1.7;max-width:260px">
798
+ <strong style="font-style:italic;font-family:Georgia,serif">A</strong> = logo height.
799
+ Minimum clear space on all sides equals
800
+ <strong style="font-style:italic;font-family:Georgia,serif">A</strong>.
801
+ No text, graphics, or other elements should intrude this zone.
802
+ </p>
803
+ <div style="margin-top:16px;font-size:11px;font-family:'SF Mono','Menlo',monospace;color:#555">
804
+ ${logoW} \u00d7 ${logoH}px
805
+ </div>
806
+ </div>
807
+ </div>
808
+ ${footer(pageNum)}
809
+ </div>`;
810
+ })() : ''}
811
+
812
+ <!-- LOGO MISUSE -->
813
+ ${logoUrl ? (() => {
814
+ pageNum++;
815
+ const boxBg = logoIsLight ? '#0a0a0a' : '#ffffff';
816
+ const violations = [
817
+ { label: "Don't stretch", style: 'transform:scaleX(1.55)' },
818
+ { label: "Don't condense", style: 'transform:scaleY(0.55)' },
819
+ { label: "Don't rotate", style: 'transform:rotate(-12deg)' },
820
+ { label: "Don't recolor", style: 'filter:hue-rotate(150deg) saturate(2.2)' },
821
+ { label: "Don't add effects", style: 'filter:drop-shadow(3px 4px 2px rgba(0,0,0,0.45))' },
822
+ { label: "Don't reduce contrast", style: 'opacity:0.32', boxBg: 'linear-gradient(135deg,#9aa0a6,#cdd1d6)' },
823
+ ];
824
+ return `
825
+ <div class="page">
826
+ <div class="section-title">Logo Misuse</div>
827
+ <p style="font-size:12px;color:#555;line-height:1.7;max-width:520px;margin-bottom:8px">
828
+ Keep the logo consistent. Do not alter its proportions, colors, orientation, or apply effects.
829
+ The examples below are incorrect.
830
+ </p>
831
+ <div class="misuse-grid">
832
+ ${violations.map(v => `
833
+ <div class="misuse-box" style="background:${escapeAttr(v.boxBg || boxBg)}">
834
+ <img src="${escapeAttr(logoUrl)}" style="${v.style}" />
835
+ <span class="misuse-mark">&#10005;</span>
836
+ <span class="misuse-label">${escapeHtml(v.label)}</span>
837
+ </div>`).join('')}
838
+ </div>
839
+ ${footer(pageNum)}
840
+ </div>`;
841
+ })() : ''}
842
+
843
+ <!-- COLORS -->
844
+ ${colors.length > 0 ? (() => {
845
+ const ordered = [...sortByHue(semanticColors), ...sortByHue(paletteColors)];
846
+ if (!ordered.length)
847
+ return '';
848
+ // Primary = the explicit 'primary' role, else first semantic, else most chromatic
849
+ const primary = semanticColors.find(c => /primary/i.test(c.label || ''))
850
+ || semanticColors[0]
851
+ || ordered.reduce((best, c) => chroma(c.hex) > chroma(best.hex) ? c : best, ordered[0]);
852
+ const rest = ordered.filter(c => c.hex !== primary.hex).slice(0, 9);
853
+ const heroFg = textColor(primary.hex);
854
+ const heroValues = heroFg === '#fff' ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.6)';
855
+ const card = (c) => {
856
+ const fg = textColor(c.hex);
857
+ return `
858
+ <div class="color-card">
859
+ <div class="chip" style="background:${c.hex};color:${fg}">
860
+ <span class="chip-name">${escapeHtml(colorName(c))}</span>
861
+ </div>
862
+ <div class="card-meta">
863
+ <span class="cm-hex">${c.hex.toUpperCase()}</span><br>
864
+ RGB ${formatRgb(c.hex)}<br>
865
+ CMYK ${formatCmyk(c.hex)}
866
+ </div>
867
+ </div>`;
868
+ };
869
+ pageNum++;
870
+ return `
871
+ <div class="page">
872
+ <div class="section-title">Color Palette</div>
873
+ <div class="color-hero" style="background:${primary.hex};color:${heroFg}">
874
+ <div class="ch-name">${escapeHtml(colorName(primary))}</div>
875
+ <div class="ch-values" style="color:${heroValues}">
876
+ ${primary.hex.toUpperCase()}&nbsp;&nbsp;&middot;&nbsp;&nbsp;RGB ${formatRgb(primary.hex)}&nbsp;&nbsp;&middot;&nbsp;&nbsp;CMYK ${formatCmyk(primary.hex)}
877
+ </div>
878
+ </div>
879
+ <div class="color-grid">
880
+ ${rest.map(card).join('')}
881
+ </div>
882
+ ${footer(pageNum)}
883
+ </div>`;
884
+ })() : ''}
885
+
886
+ <!-- TYPOGRAPHY -->
887
+ ${fonts.length > 0 ? (() => {
888
+ const pages = [];
889
+ const primaryFont = fonts[0];
890
+ const primaryAvailable = googleFonts.some(gf => gf.toLowerCase() === primaryFont.family.toLowerCase());
891
+ pageNum++;
892
+ pages.push(`
893
+ <div class="page">
894
+ <div class="section-title">Typography - Primary</div>
895
+ <div class="type-specimen"${primaryAvailable ? ` style="font-family:'${escapeAttr(primaryFont.family)}',${escapeAttr(primaryFont.fallbacks || 'sans-serif')}"` : ''}>
896
+ <div class="type-family-name">${escapeHtml(primaryFont.family)}${!primaryAvailable ? ' <span style="background:#f5a623;color:#1a1a1a;font-size:9px;font-weight:700;padding:3px 10px;border-radius:3px;margin-left:10px;letter-spacing:0.3px">INSTALL FONT TO COMPLETE</span>' : ''}</div>
897
+ <div class="type-alphabet" style="font-size:72px;font-weight:${primaryFont.weights[0] || 400}">
898
+ ABCDEFGHIJKLM<br>NOPQRSTUVWXYZ
899
+ </div>
900
+ <div class="type-alphabet" style="font-size:48px;font-weight:${primaryFont.weights[0] || 400};margin-top:8px">
901
+ abcdefghijklmnopqrstuvwxyz
902
+ </div>
903
+ <div class="type-alphabet" style="font-size:36px;font-weight:${primaryFont.weights[0] || 400};margin-top:8px;color:#4a4a4a">
904
+ 0123456789 !@#$%&amp;*()
905
+ </div>
906
+ <div class="type-meta" style="margin-top:16px">
907
+ ${primaryFont.weights.map(w => weightName(w)).join(', ')}${primaryFont.fallbacks ? ` / ${escapeHtml(primaryFont.fallbacks)}` : ''}
908
+ </div>
909
+ ${primaryFont.weights.length > 1 ? `
910
+ <div class="type-weights">
911
+ ${primaryFont.weights.slice(0, 5).map(w => `
912
+ <div class="type-weight-item">
913
+ <span class="type-weight-label">${weightName(w)}</span>
914
+ <span class="type-weight-preview" style="font-weight:${w}">
915
+ Hamburgevons
916
+ </span>
917
+ </div>`).join('')}
918
+ </div>` : ''}
919
+ <div class="type-paragraph" style="font-weight:${primaryFont.weights.includes(400) ? 400 : primaryFont.weights[0] || 400}">
920
+ Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed.
921
+ </div>
922
+ ${!primaryAvailable ? `<p style="font-size:11px;color:#555;margin-top:24px;border-top:1px solid #d0d0d0;padding-top:12px">Specimen shown in system fallback font. Actual rendering requires installing <strong>${escapeHtml(primaryFont.family)}</strong>.</p>` : ''}
923
+ </div>
924
+ ${footer(pageNum)}
925
+ </div>`);
926
+ if (fonts.length > 1) {
927
+ const secondaryFont = fonts[1];
928
+ const secondaryAvailable = googleFonts.some(gf => gf.toLowerCase() === secondaryFont.family.toLowerCase());
929
+ pageNum++;
930
+ pages.push(`
931
+ <div class="page">
932
+ <div class="section-title">Typography - Secondary</div>
933
+ <div class="type-specimen"${secondaryAvailable ? ` style="font-family:'${escapeAttr(secondaryFont.family)}',${escapeAttr(secondaryFont.fallbacks || 'sans-serif')}"` : ''}>
934
+ <div class="type-family-name">${escapeHtml(secondaryFont.family)}${!secondaryAvailable ? ' <span style="background:#f5a623;color:#1a1a1a;font-size:9px;font-weight:700;padding:3px 10px;border-radius:3px;margin-left:10px;letter-spacing:0.3px">INSTALL FONT TO COMPLETE</span>' : ''}</div>
935
+ <div class="type-alphabet" style="font-size:56px;font-weight:${secondaryFont.weights[0] || 400}">
936
+ ABCDEFGHIJKLM<br>NOPQRSTUVWXYZ
937
+ </div>
938
+ <div class="type-alphabet" style="font-size:40px;font-weight:${secondaryFont.weights[0] || 400};margin-top:8px">
939
+ abcdefghijklmnopqrstuvwxyz
940
+ </div>
941
+ <div class="type-alphabet" style="font-size:28px;font-weight:${secondaryFont.weights[0] || 400};margin-top:8px;color:#4a4a4a">
942
+ 0123456789 !@#$%&amp;*()
943
+ </div>
944
+ <div class="type-meta" style="margin-top:16px">
945
+ ${secondaryFont.weights.map(w => weightName(w)).join(', ')}${secondaryFont.fallbacks ? ` / ${escapeHtml(secondaryFont.fallbacks)}` : ''}
946
+ </div>
947
+ ${secondaryFont.weights.length > 1 ? `
948
+ <div class="type-weights">
949
+ ${secondaryFont.weights.slice(0, 5).map(w => `
950
+ <div class="type-weight-item">
951
+ <span class="type-weight-label">${weightName(w)}</span>
952
+ <span class="type-weight-preview" style="font-weight:${w}">
953
+ Hamburgevons
954
+ </span>
955
+ </div>`).join('')}
956
+ </div>` : ''}
957
+ <div class="type-paragraph" style="font-weight:${secondaryFont.weights.includes(400) ? 400 : secondaryFont.weights[0] || 400}">
958
+ Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed.
959
+ </div>
960
+ ${!secondaryAvailable ? `<p style="font-size:11px;color:#555;margin-top:24px;border-top:1px solid #d0d0d0;padding-top:12px">Specimen shown in system fallback font. Actual rendering requires installing <strong>${escapeHtml(secondaryFont.family)}</strong>.</p>` : ''}
961
+ </div>
962
+ ${footer(pageNum)}
963
+ </div>`);
964
+ }
965
+ return pages.join('\n');
966
+ })() : ''}
967
+
968
+ <!-- BACK COVER -->
969
+ ${(() => {
970
+ const year = (() => { const y = new Date(data.extractedAt).getFullYear(); return Number.isFinite(y) ? y : new Date().getFullYear(); })();
971
+ const version = data.meta?.dembrandtVersion;
972
+ return `
973
+ <div class="page back-cover">
974
+ ${logoUrl ? `<img class="logo-img" src="${escapeAttr(logoUrl)}" />` : ''}
975
+ <div class="rule"></div>
976
+ <div class="back-doc">Brand Guidelines</div>
977
+ <div class="back-copyright">
978
+ &copy; ${year} ${escapeHtml(companyName)}. All rights reserved.<br>
979
+ These guidelines and the assets within remain the property of ${escapeHtml(companyName)}.
980
+ </div>
981
+ <div class="back-attrib">
982
+ Created with <strong>DEMBRANDT</strong>&nbsp;&nbsp;&middot;&nbsp;&nbsp;dembrandt.com${version ? `&nbsp;&nbsp;&middot;&nbsp;&nbsp;v${escapeHtml(version)}` : ''}
983
+ </div>
984
+ </div>`;
985
+ })()}
986
+
987
+ </body>
988
+ </html>`;
989
+ }
990
+ function getLogoImageUrl(data) {
991
+ const isImageUrl = (url) => {
992
+ if (/\.(svg|png|jpg|jpeg|webp|gif|avif)(\?.*)?$/i.test(url))
993
+ return true;
994
+ // Image optimizers (Next.js /_next/image, Cloudflare /cdn-cgi/image, etc.)
995
+ // embed the real file in a query param and serve an image. The extension
996
+ // lands mid-URL, so check the decoded form too.
997
+ try {
998
+ const decoded = decodeURIComponent(url);
999
+ if (/\.(svg|png|jpg|jpeg|webp|gif|avif)(?=[?&]|$)/i.test(decoded))
1000
+ return true;
1001
+ }
1002
+ catch { /* malformed encoding — fall through */ }
1003
+ return false;
1004
+ };
1005
+ // Inline SVG logos carry their own self-contained data URI.
1006
+ if (data.logo?.inline && data.logo.dataUri) {
1007
+ return data.logo.dataUri;
1008
+ }
1009
+ if (data.logo?.url && isImageUrl(data.logo.url)) {
1010
+ return data.logo.url;
1011
+ }
1012
+ if (!data.favicons?.length)
1013
+ return null;
1014
+ const appleTouch = data.favicons.find(f => f.type === 'apple-touch-icon');
1015
+ if (appleTouch)
1016
+ return appleTouch.url;
1017
+ const svg = data.favicons.find(f => f.url?.endsWith('.svg'));
1018
+ if (svg)
1019
+ return svg.url;
1020
+ const sized = data.favicons
1021
+ .filter(f => f.sizes && f.type !== 'og:image' && f.type !== 'twitter:image')
1022
+ .sort((a, b) => {
1023
+ const aSize = parseInt(a.sizes) || 0;
1024
+ const bSize = parseInt(b.sizes) || 0;
1025
+ return bSize - aSize;
1026
+ });
1027
+ if (sized.length)
1028
+ return sized[0].url;
1029
+ const icon = data.favicons.find(f => f.type !== 'og:image' && f.type !== 'twitter:image');
1030
+ return icon?.url || null;
1031
+ }
1032
+ function gatherColors(colors) {
1033
+ if (!colors)
1034
+ return [];
1035
+ const result = [];
1036
+ const seen = new Set();
1037
+ const add = (colorStr, label, confidence, source) => {
1038
+ const h = hex(colorStr);
1039
+ if (!h || seen.has(h.toLowerCase()))
1040
+ return false;
1041
+ seen.add(h.toLowerCase());
1042
+ const c = convertColor(colorStr);
1043
+ result.push({
1044
+ hex: h,
1045
+ rgb: c ? c.rgb : colorStr,
1046
+ label,
1047
+ confidence,
1048
+ source
1049
+ });
1050
+ return true;
1051
+ };
1052
+ // 1. Semantic colors are the most reliable brand signals
1053
+ if (colors.semantic) {
1054
+ for (const [role, color] of Object.entries(colors.semantic)) {
1055
+ if (color)
1056
+ add(color, role, 'high', 'semantic');
1057
+ }
1058
+ }
1059
+ // 2. All palette colors
1060
+ if (colors.palette) {
1061
+ for (const c of colors.palette) {
1062
+ add(c.color, '', c.confidence, 'palette');
1063
+ }
1064
+ }
1065
+ // 3. CSS variable colors
1066
+ if (colors.cssVariables) {
1067
+ for (const [_varName, varData] of Object.entries(colors.cssVariables)) {
1068
+ const val = typeof varData === 'string' ? varData : varData.value;
1069
+ if (val)
1070
+ add(val, '', 'high', 'css-var');
1071
+ }
1072
+ }
1073
+ return result;
1074
+ }
1075
+ function gatherFonts(typography) {
1076
+ if (!typography?.styles?.length)
1077
+ return [];
1078
+ const families = new Map();
1079
+ for (const style of typography.styles) {
1080
+ if (!style.family)
1081
+ continue;
1082
+ if (!families.has(style.family)) {
1083
+ families.set(style.family, {
1084
+ family: style.family,
1085
+ fallbacks: style.fallbacks || '',
1086
+ weights: new Set(),
1087
+ sizes: []
1088
+ });
1089
+ }
1090
+ const f = families.get(style.family);
1091
+ if (style.weight)
1092
+ f.weights.add(Number(style.weight));
1093
+ if (style.size) {
1094
+ f.sizes.push({ size: style.size, weight: style.weight, context: style.context });
1095
+ }
1096
+ }
1097
+ return Array.from(families.values()).map(f => ({
1098
+ ...f,
1099
+ weights: Array.from(f.weights).sort((a, b) => a - b)
1100
+ }));
1101
+ }
1102
+ function escapeHtml(str) {
1103
+ if (!str)
1104
+ return '';
1105
+ return String(str)
1106
+ .replace(/&/g, '&amp;')
1107
+ .replace(/</g, '&lt;')
1108
+ .replace(/>/g, '&gt;')
1109
+ .replace(/"/g, '&quot;');
1110
+ }
1111
+ function escapeAttr(str) {
1112
+ if (!str)
1113
+ return '';
1114
+ return String(str)
1115
+ .replace(/&/g, '&amp;')
1116
+ .replace(/"/g, '&quot;')
1117
+ .replace(/'/g, '&#39;')
1118
+ .replace(/</g, '&lt;')
1119
+ .replace(/>/g, '&gt;');
1120
+ }
1121
+ //# sourceMappingURL=pdf.js.map