SkateSpots/scripts/make-print-art.mjs
SkateSpots 4341653abe feat(app-shop): add free printable sticker + poster (vector, real QR)
- 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.
2026-09-27 22:16:43 +07:00

165 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);