// Run with Electron, like export.cjs. Canvas lays out the supplied raster marks and real font. const { app, BrowserWindow } = require('electron'); const fs = require('node:fs/promises'); const path = require('node:path'); const assert = require('node:assert/strict'); app.disableHardwareAcceleration(); const timeout = setTimeout(() => app.exit(1), 60000); app.whenReady().then(async () => { const win = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true, sandbox: true } }); await win.loadFile(path.join(__dirname, 'index.html')); const results = await win.webContents.executeJavaScript(`(async () => { await document.fonts.load('600 144px Oswald'); if (![...document.fonts].some(face => face.family === 'Oswald' && face.status === 'loaded')) throw new Error('Bundled Oswald must load'); const load = async src => { const image = new Image(); image.src = src; await image.decode(); return image; }; const color = await load('assets/logos/logo.png'); const mono = await load('assets/logos/mono-source.png'); const white = await load('assets/logos/white-source.png'); const files = []; const d = 600, pad = 150, gap = 60, lineGap = 48, wordWidth = 800; const measure = (ctx, text, size) => { ctx.font = '600 ' + size + 'px Oswald'; const m = ctx.measureText(text); return { text, size, left: m.actualBoundingBoxLeft, width: m.actualBoundingBoxLeft + m.actualBoundingBoxRight, ascent: m.actualBoundingBoxAscent, height: m.actualBoundingBoxAscent + m.actualBoundingBoxDescent }; }; for (const layout of ['mark', 'horizontal', 'vertical']) { for (const variant of ['color-light', 'color-dark', 'black', 'white']) { if (layout === 'mark' && variant !== 'white') continue; for (const background of ['transparent', 'background']) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); let size = 144; for (let i = 0; i < 3; i++) size *= wordWidth / measure(ctx, 'ДЕТЕКТИВ', size).width; const lower = measure(ctx, 'ДЕТЕКТИВ', size); const upper = measure(ctx, 'ВЕЧЕРНИЙ', size * 0.45); if (Math.abs(lower.width - wordWidth) > 1) throw new Error('Wordmark width must be 4/3 of emblem diameter'); const textHeight = upper.height + lineGap + lower.height; const contentWidth = layout === 'horizontal' ? d + gap + Math.ceil(lower.width) : layout === 'mark' ? d : Math.ceil(lower.width); const contentHeight = layout === 'vertical' ? Math.ceil(d + gap + textHeight) : d; canvas.width = contentWidth + pad * 2; canvas.height = contentHeight + pad * 2; const dark = variant === 'color-dark' || variant === 'white'; if (background === 'background') { ctx.fillStyle = dark ? '#111111' : '#f4ede0'; ctx.fillRect(0,0,canvas.width,canvas.height); } ctx.filter = variant === 'black' ? 'brightness(0)' : 'none'; const imageX = layout === 'vertical' ? (canvas.width - d) / 2 : pad; ctx.save(); if (variant === 'white') { ctx.beginPath(); ctx.arc(imageX + d / 2, pad + d / 2, d * 0.48, 0, Math.PI * 2); ctx.clip(); } ctx.drawImage(variant === 'white' ? white : variant.startsWith('color') ? color : mono, imageX, pad, d, d); ctx.restore(); ctx.filter = 'none'; if (layout !== 'mark') { ctx.fillStyle = variant === 'white' ? '#ffffff' : variant === 'black' ? '#000000' : dark ? '#ffffff' : '#111111'; ctx.textBaseline = 'alphabetic'; ctx.textAlign = 'left'; const top = layout === 'vertical' ? pad + d + gap : pad + (d - textHeight) / 2; for (const [line, y] of [[upper, top], [lower, top + upper.height + lineGap]]) { const x = layout === 'vertical' ? (canvas.width - line.width) / 2 : pad + d + gap; if (x < pad - 1 || x + line.width > canvas.width - pad + 1 || y < pad || y + line.height > canvas.height - pad + 1) throw new Error('Text exceeds safety margins'); ctx.font = '600 ' + line.size + 'px Oswald'; ctx.fillText(line.text, x + line.left, y + line.ascent); } // Match the ring accent; symmetric rules keep the stacked lockup balanced. ctx.fillStyle = variant.startsWith('color') ? '#fcbf02' : variant === 'white' ? '#ffffff' : '#000000'; const blockX = layout === 'vertical' ? (canvas.width - lower.width) / 2 : pad + d + gap; const upperX = layout === 'vertical' ? (canvas.width - upper.width) / 2 : blockX; const ruleY = Math.round(top + upper.height / 2 - 4); const ruleStart = upperX + upper.width + 36; const ruleEnd = blockX + lower.width; if (ruleEnd <= ruleStart) throw new Error('Accent line needs space after upper word'); ctx.fillRect(ruleStart, ruleY, ruleEnd - ruleStart, 8); if (layout === 'vertical') ctx.fillRect(blockX, ruleY, upperX - 36 - blockX, 8); // The horizontal wordmark is the magnifier handle; its glint sits below the gold edge. if (layout === 'horizontal') { ctx.strokeStyle = dark ? '#ffffff' : '#000000'; ctx.lineWidth = 8; ctx.lineCap = 'round'; const start = ruleStart + 36, end = ruleEnd - 60; const y = ruleY + 33; if (y + 8 >= top + upper.height + lineGap) throw new Error('Handle glint overlaps lower word'); ctx.beginPath(); ctx.moveTo(start, y); ctx.lineTo(start + 18, y); ctx.moveTo(start + 40, y); ctx.lineTo(end, y); ctx.stroke(); } } const pixels = ctx.getImageData(0,0,canvas.width,canvas.height).data; if (background === 'transparent' && pixels[3] !== 0) throw new Error('Transparent padding required'); if (background === 'background' && pixels[3] !== 255) throw new Error('Opaque background required'); files.push({ name: layout + '-' + variant + '-' + background + '.png', width: canvas.width, height: canvas.height, data: canvas.toDataURL('image/png').split(',')[1] }); } } } return files; })()`); assert.equal(results.length, 18); for (const result of results) await fs.writeFile(path.join(__dirname, 'assets/logos', result.name), Buffer.from(result.data, 'base64')); console.log('Exported 16 wordmark files and two white-rim standalone marks, with transparent padding or opaque backgrounds.'); win.destroy(); clearTimeout(timeout); app.quit(); }).catch(error => { console.error(error); app.exit(1); });