- public/images/print/skatespots-sticker.svg (55 x 86 mm) and skatespots-poster.svg (A3) — the free artwork to print and stick up. Physical size is baked into the SVG (mm units), so 'Actual size' printing is exact. - Self-contained: Outfit/Barlow fonts + the logo embedded as data URIs, and a real vector QR. VERIFIED both QRs decode to https://skate-spots.org (decoded with jsQR from a rendered PNG; logo centre, error-correction H). - New 'Free to print' section in the App Shop: previews, FREE badge, sizes, cutting tip, 'Open & print' + 'Save file' buttons, and a 100%/Actual-size note. - scripts/make-print-art.mjs regenerates the artwork (committed output). - AGENTS.md documents the assets + the re-verify-the-QR rule.
165 lines
7.9 KiB
JavaScript
165 lines
7.9 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Generates the print-ready VECTOR artwork served by the App Shop:
|
||
* run with `node scripts/make-print-art.mjs` (output is committed, so this is
|
||
* only needed when the artwork changes).
|
||
* public/images/print/skatespots-sticker.svg (55 × 86 mm)
|
||
* public/images/print/skatespots-poster.svg (A3, 297 × 420 mm)
|
||
*
|
||
* Self-contained: brand fonts (Outfit variable + Barlow Condensed) and the logo
|
||
* are embedded as data URIs, and the QR is real vector from `qrcode`. That means
|
||
* it prints crisp at any size and needs no network.
|
||
*/
|
||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||
import { join, dirname } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { createRequire } from 'node:module';
|
||
|
||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||
const require = createRequire(join(ROOT, 'package.json'));
|
||
const QRCode = require('qrcode');
|
||
|
||
const OUT = join(ROOT, 'public/images/print');
|
||
mkdirSync(OUT, { recursive: true });
|
||
|
||
const ORANGE = '#f08c2a';
|
||
const WHITE = '#ffffff';
|
||
const BG = '#0a0a0a';
|
||
const QR_URL = 'https://skate-spots.org';
|
||
|
||
// ── assets ───────────────────────────────────────────────────────────────────
|
||
const b64 = (p) => readFileSync(p).toString('base64');
|
||
const outfit = b64(join(ROOT, 'node_modules/@fontsource-variable/outfit/files/outfit-latin-wght-normal.woff2'));
|
||
const barlow = b64(join(ROOT, 'node_modules/@fontsource/barlow-condensed/files/barlow-condensed-latin-600-normal.woff2'));
|
||
const logoPng = b64(join(ROOT, 'public/images/icon-1024.png'));
|
||
|
||
const FONTS = `
|
||
<style>
|
||
@font-face { font-family:'Outfit'; font-weight:100 900; font-style:normal; font-display:block;
|
||
src:url(data:font/woff2;base64,${outfit}) format('woff2'); }
|
||
@font-face { font-family:'Barlow Condensed'; font-weight:600; font-style:normal; font-display:block;
|
||
src:url(data:font/woff2;base64,${barlow}) format('woff2'); }
|
||
.h { font-family:'Outfit', system-ui, sans-serif; font-weight:900; }
|
||
.c { font-family:'Barlow Condensed', system-ui, sans-serif; font-weight:600; letter-spacing:0.18em; }
|
||
</style>`;
|
||
|
||
/** Real QR module path (no background) + its viewBox size. */
|
||
async function qrPath() {
|
||
const svg = await QRCode.toString(QR_URL, {
|
||
type: 'svg',
|
||
margin: 0,
|
||
errorCorrectionLevel: 'H',
|
||
color: { dark: '#000000', light: '#ffffff' },
|
||
});
|
||
const m = svg.match(/<path[^>]*d="([^"]+)"[^>]*\/?>/g) || [];
|
||
// The first path is the solid background; the rest are the modules.
|
||
const modulePath = (svg.match(/<path stroke="#000000" d="([^"]+)"/) || [])[1];
|
||
const vb = (svg.match(/viewBox="0 0 ([0-9.]+) ([0-9.]+)"/) || [])[1] || '31';
|
||
return { d: modulePath, size: Number(vb), all: m.length };
|
||
}
|
||
|
||
/** White QR card: panel + modules + centred logo. Units are the caller's. */
|
||
function qrCard({ x, y, size, qr, radius, logoFrac = 0.26 }) {
|
||
const pad = size * 0.055; // quiet-ish inset
|
||
const inner = size - pad * 2;
|
||
const s = inner / qr.size;
|
||
const logoS = size * logoFrac;
|
||
const logoX = x + size / 2 - logoS / 2;
|
||
const logoY = y + size / 2 - logoS / 2;
|
||
const discR = logoS * 0.62;
|
||
return `
|
||
<rect x="${x}" y="${y}" width="${size}" height="${size}" rx="${radius}" fill="${WHITE}"/>
|
||
<g transform="translate(${x + pad} ${y + pad}) scale(${s})">
|
||
<path fill="none" stroke="#000000" stroke-width="1" d="${qr.d}"/>
|
||
</g>
|
||
<circle cx="${x + size / 2}" cy="${y + size / 2}" r="${discR}" fill="${WHITE}"/>
|
||
<image x="${logoX}" y="${logoY}" width="${logoS}" height="${logoS}"
|
||
href="data:image/png;base64,${logoPng}" preserveAspectRatio="xMidYMid meet"/>`;
|
||
}
|
||
|
||
/** Block-shadowed headline: orange offset copy behind a white one. */
|
||
function headline({ text, cx, y, size, width, offset, stroke = 0, letterSpacing = '0.01em' }) {
|
||
const common = `text-anchor="middle" x="${cx}" y="${y}" font-size="${size}" letter-spacing="${letterSpacing}"` +
|
||
(width ? ` textLength="${width}" lengthAdjust="spacingAndGlyphs"` : '');
|
||
return `
|
||
<text class="h" ${common} fill="${ORANGE}" transform="translate(${offset} ${offset})">${text}</text>
|
||
<text class="h" ${common} fill="${WHITE}" ${stroke ? `stroke="${ORANGE}" stroke-width="${stroke}" paint-order="stroke"` : ''}>${text}</text>`;
|
||
}
|
||
|
||
/** Sawtooth ribbon of alternating triangles. dir: 'down' means apex points down. */
|
||
function ribbon({ y, width, height, dir, count = 13 }) {
|
||
const w = width / count;
|
||
let out = '';
|
||
for (let i = 0; i < count; i++) {
|
||
const x = i * w;
|
||
const fill = i % 2 === 0 ? ORANGE : WHITE;
|
||
const pts =
|
||
dir === 'down'
|
||
? `${x},${y} ${x + w / 2},${y + height} ${x + w},${y}`
|
||
: `${x},${y + height} ${x + w / 2},${y} ${x + w},${y + height}`;
|
||
out += `<polygon points="${pts}" fill="${fill}"/>`;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const dashed = ({ y, x1, x2, color = '#3a3a3a', w = 4 }) =>
|
||
`<line x1="${x1}" y1="${y}" x2="${x2}" y2="${y}" stroke="${color}" stroke-width="${w}" stroke-dasharray="18 14"/>`;
|
||
|
||
// ── sticker (portrait, 55 × 86 mm at 0.1 mm units) ───────────────────────────
|
||
async function sticker() {
|
||
const qr = await qrPath();
|
||
const W = 550, H = 860;
|
||
const qrSize = 400;
|
||
return `<svg xmlns="http://www.w3.org/2000/svg" width="55mm" height="86mm" viewBox="0 0 ${W} ${H}">
|
||
${FONTS}
|
||
<rect width="${W}" height="${H}" rx="34" fill="#000000"/>
|
||
${headline({ text: 'DO YOU EVEN SKATE', cx: W / 2, y: 150, size: 92, width: 470, offset: 5, stroke: 2 })}
|
||
${headline({ text: 'BRO?', cx: W / 2, y: 330, size: 190, width: 330, offset: 8, stroke: 3 })}
|
||
${qrCard({ x: (W - qrSize) / 2, y: 420, size: qrSize, qr, radius: 44 })}
|
||
</svg>\n`;
|
||
}
|
||
|
||
// ── poster (A3 portrait, 0.1 mm units → 2970 × 4200) ─────────────────────────
|
||
async function poster() {
|
||
const qr = await qrPath();
|
||
const W = 2970, H = 4200;
|
||
const qrSize = 1500;
|
||
const cx = W / 2;
|
||
return `<svg xmlns="http://www.w3.org/2000/svg" width="297mm" height="420mm" viewBox="0 0 ${W} ${H}">
|
||
${FONTS}
|
||
<rect width="${W}" height="${H}" fill="${BG}"/>
|
||
|
||
<!-- sawtooth ribbons -->
|
||
${ribbon({ y: 0, width: W, height: 74, dir: 'down', count: 13 })}
|
||
${ribbon({ y: H - 74, width: W, height: 74, dir: 'up', count: 13 })}
|
||
|
||
<!-- header -->
|
||
<rect x="120" y="150" width="30" height="30" fill="${ORANGE}"/>
|
||
<rect x="${W - 150}" y="150" width="30" height="30" fill="${ORANGE}"/>
|
||
<text class="c" x="${cx}" y="188" fill="${WHITE}" text-anchor="middle" font-size="72" textLength="860" lengthAdjust="spacingAndGlyphs">SKATESPOTS.APP</text>
|
||
${dashed({ y: 250, x1: 130, x2: W - 130 })}
|
||
|
||
<!-- headline -->
|
||
${headline({ text: 'DO YOU EVEN SKATE', cx, y: 620, size: 300, width: 2350, offset: 12, stroke: 4 })}
|
||
${headline({ text: 'BRO?', cx, y: 1000, size: 520, width: 1150, offset: 18, stroke: 6 })}
|
||
<text class="c" x="${cx}" y="1140" fill="${ORANGE}" text-anchor="middle" font-size="104" textLength="1750" lengthAdjust="spacingAndGlyphs">FIND SPOTS. SESH TOGETHER. NO CAP.</text>
|
||
${dashed({ y: 1240, x1: 130, x2: W - 130 })}
|
||
|
||
<!-- qr -->
|
||
${qrCard({ x: cx - qrSize / 2, y: 1370, size: qrSize, qr, radius: 70 })}
|
||
${dashed({ y: 3260, x1: 130, x2: W - 130 })}
|
||
|
||
<!-- footer -->
|
||
<text class="c" x="${cx}" y="3480" fill="${WHITE}" text-anchor="middle" font-size="130" textLength="1950" lengthAdjust="spacingAndGlyphs">SCAN. DOWNLOAD. GET OUT THERE.</text>
|
||
<line x1="130" y1="3600" x2="${W - 130}" y2="3600" stroke="${ORANGE}" stroke-width="5"/>
|
||
<text class="c" x="${cx}" y="3700" fill="#8a8a8a" text-anchor="middle" font-size="80" letter-spacing="0.04em" textLength="1250" lengthAdjust="spacingAndGlyphs">zapstore.dev/apps/org.skatespots.app</text>
|
||
</svg>\n`;
|
||
}
|
||
|
||
const s = await sticker();
|
||
const p = await poster();
|
||
writeFileSync(join(OUT, 'skatespots-sticker.svg'), s);
|
||
writeFileSync(join(OUT, 'skatespots-poster.svg'), p);
|
||
console.log('sticker:', s.length, 'bytes');
|
||
console.log('poster :', p.length, 'bytes');
|
||
console.log('→', OUT);
|