feat(linkedin-studio): N2 — build-figur renderer (SVG/HTML->PNG, 3 mål, token-seam) [skip-docs]
TDD (33 nye tester, render-suite 20->53/0 = ny floor, 4 ekte e2e-renders): - render/build-figur.mjs: SVG/HTML -> PNG via headless Chrome; tre mål (article 1200xauto, carousel 1080x1350, single 1200x1200), overstyrbare via --width/--height; CLI + importerbar modul. - Token-seam: design-tokens leses fra LINKEDIN_STUDIO_DATA/profile/brand-tokens.json (brukerdata), nøytrale defaults i modulen — merkevare aldri hardkodet i repoet. Injiseres som CSS-variabler (--figur-*). - Warn-validering (aldri hard fail): min 24pt tekst, fargebudsjett (maks 2 ikke-nøytrale farger). - Robust Chrome-oppdagelse: app-bundle-stier -> PATH -> install-hint (prober injiserbare for test). - macOS-quirk (verifisert lokalt, Chrome 150): headless skriver PNG-en men avslutter aldri -> stderr-watchdog («written to file») + kill + verifisering av output. Søk-først utført (kjent headless-ustabilitet på macOS). - Fixture: nøytral demo-SVG (placeholder-søylediagram) under render/__tests__/fixtures/. Alle seks suiter grønne: test-runner 138/0, trends 245/0, brain 134/0, hooks 140/0, tests 35/0, render 53/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: 26f26c3d-be91-47c4-bbdf-4694ba063b67
This commit is contained in:
parent
776d728d7d
commit
c4434ed789
3 changed files with 669 additions and 0 deletions
284
render/__tests__/build-figur.test.mjs
Normal file
284
render/__tests__/build-figur.test.mjs
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
TARGETS,
|
||||
DEFAULT_TOKENS,
|
||||
deriveDimensions,
|
||||
loadBrandTokens,
|
||||
tokensToCss,
|
||||
wrapSvg,
|
||||
injectTokens,
|
||||
parseSvgMeta,
|
||||
validateFigure,
|
||||
resolveChrome,
|
||||
parseArgs,
|
||||
renderFigure,
|
||||
} from '../build-figur.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE_SVG = path.join(__dirname, 'fixtures', 'demo-figur.svg');
|
||||
|
||||
function tmpdir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'build-figur-test-'));
|
||||
}
|
||||
|
||||
// Leser bredde/høyde fra PNG-IHDR (byte 16–23, big-endian).
|
||||
function pngDimensions(file) {
|
||||
const buf = fs.readFileSync(file);
|
||||
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||||
}
|
||||
|
||||
describe('TARGETS + deriveDimensions', () => {
|
||||
test('carousel er 1080×1350 (fast)', () => {
|
||||
assert.equal(TARGETS.carousel.width, 1080);
|
||||
assert.equal(TARGETS.carousel.height, 1350);
|
||||
});
|
||||
|
||||
test('article er 1200 bred med innholdsstyrt høyde', () => {
|
||||
assert.equal(TARGETS.article.width, 1200);
|
||||
assert.equal(TARGETS.article.height, null);
|
||||
});
|
||||
|
||||
test('single er kvadratisk 1200×1200', () => {
|
||||
assert.equal(TARGETS.single.width, 1200);
|
||||
assert.equal(TARGETS.single.height, 1200);
|
||||
});
|
||||
|
||||
test('article + aspekt 16:9 gir avledet høyde 675', () => {
|
||||
const d = deriveDimensions('article', 1200 / 675, {});
|
||||
assert.equal(d.width, 1200);
|
||||
assert.equal(d.height, 675);
|
||||
assert.equal(d.warnings.length, 0);
|
||||
});
|
||||
|
||||
test('article uten aspekt gir fallback-høyde med warning', () => {
|
||||
const d = deriveDimensions('article', null, {});
|
||||
assert.equal(d.width, 1200);
|
||||
assert.ok(d.height > 0);
|
||||
assert.ok(d.warnings.some((w) => /høyde/i.test(w)));
|
||||
});
|
||||
|
||||
test('--width/--height-overstyringer vinner over målet', () => {
|
||||
const d = deriveDimensions('carousel', null, { width: 800, height: 600 });
|
||||
assert.equal(d.width, 800);
|
||||
assert.equal(d.height, 600);
|
||||
});
|
||||
|
||||
test('ukjent mål kaster med gyldige mål i meldingen', () => {
|
||||
assert.throws(() => deriveDimensions('poster', null, {}), /article/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadBrandTokens — token-seam (brukerdata, aldri hardkodet)', () => {
|
||||
test('uten brand-tokens.json: nøytrale defaults, source=default', () => {
|
||||
const dir = tmpdir();
|
||||
const r = loadBrandTokens(dir);
|
||||
assert.equal(r.source, 'default');
|
||||
assert.deepEqual(r.tokens, DEFAULT_TOKENS);
|
||||
assert.equal(r.warnings.length, 0);
|
||||
});
|
||||
|
||||
test('gyldig fil merges over defaults, source=user', () => {
|
||||
const dir = tmpdir();
|
||||
fs.mkdirSync(path.join(dir, 'profile'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'profile', 'brand-tokens.json'),
|
||||
JSON.stringify({ accent: '#123456' }),
|
||||
'utf8'
|
||||
);
|
||||
const r = loadBrandTokens(dir);
|
||||
assert.equal(r.source, 'user');
|
||||
assert.equal(r.tokens.accent, '#123456');
|
||||
// umerkede felter beholder defaults
|
||||
assert.equal(r.tokens.background, DEFAULT_TOKENS.background);
|
||||
});
|
||||
|
||||
test('ugyldig JSON: defaults + warning, kaster aldri', () => {
|
||||
const dir = tmpdir();
|
||||
fs.mkdirSync(path.join(dir, 'profile'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'profile', 'brand-tokens.json'), '{not json', 'utf8');
|
||||
let r;
|
||||
assert.doesNotThrow(() => { r = loadBrandTokens(dir); });
|
||||
assert.equal(r.source, 'default');
|
||||
assert.ok(r.warnings.some((w) => /brand-tokens\.json/.test(w)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokensToCss + wrapSvg + injectTokens — CSS-injeksjon', () => {
|
||||
test('tokensToCss produserer --figur-variabler for alle tokens', () => {
|
||||
const css = tokensToCss(DEFAULT_TOKENS);
|
||||
assert.match(css, /:root\{/);
|
||||
for (const key of Object.keys(DEFAULT_TOKENS)) {
|
||||
assert.ok(css.includes(`--figur-${key}`), `mangler --figur-${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('egendefinerte tokenverdier reflekteres i CSS-en', () => {
|
||||
const css = tokensToCss({ ...DEFAULT_TOKENS, accent: '#ABCDEF' });
|
||||
assert.match(css, /--figur-accent:\s*#ABCDEF/);
|
||||
});
|
||||
|
||||
test('wrapSvg bygger komplett HTML med svg + tokens + mål-dimensjoner', () => {
|
||||
const html = wrapSvg('<svg viewBox="0 0 10 10"></svg>', { width: 1080, height: 1350 }, DEFAULT_TOKENS);
|
||||
assert.match(html, /<!DOCTYPE html>/i);
|
||||
assert.match(html, /<svg viewBox="0 0 10 10">/);
|
||||
assert.match(html, /--figur-accent/);
|
||||
assert.match(html, /width:\s*1080px/);
|
||||
assert.match(html, /height:\s*1350px/);
|
||||
});
|
||||
|
||||
test('injectTokens legger style rett etter <head> når den finnes', () => {
|
||||
const out = injectTokens('<html><head><title>x</title></head><body></body></html>', DEFAULT_TOKENS);
|
||||
assert.match(out, /<head>\s*<style>[^<]*--figur-ink/);
|
||||
});
|
||||
|
||||
test('injectTokens prepender style når <head> mangler', () => {
|
||||
const out = injectTokens('<p>bare et fragment</p>', DEFAULT_TOKENS);
|
||||
assert.ok(out.trimStart().startsWith('<style>'));
|
||||
assert.match(out, /<p>bare et fragment<\/p>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSvgMeta — aspekt fra SVG-kilden', () => {
|
||||
test('viewBox gir aspekt', () => {
|
||||
const aspect = parseSvgMeta('<svg viewBox="0 0 1200 675"></svg>');
|
||||
assert.ok(Math.abs(aspect - 1200 / 675) < 1e-9);
|
||||
});
|
||||
|
||||
test('width/height-attributter gir aspekt', () => {
|
||||
const aspect = parseSvgMeta('<svg width="800px" height="400"></svg>');
|
||||
assert.equal(aspect, 2);
|
||||
});
|
||||
|
||||
test('uten dimensjonsinfo: null', () => {
|
||||
assert.equal(parseSvgMeta('<svg><rect/></svg>'), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFigure — designregler som warn, aldri hard fail', () => {
|
||||
test('font-size under 24 gir warning', () => {
|
||||
const w = validateFigure('<svg><text font-size="14">liten</text></svg>');
|
||||
assert.ok(w.some((x) => /24/.test(x)));
|
||||
});
|
||||
|
||||
test('font-size ≥ 24 gir ingen tekst-warning', () => {
|
||||
const w = validateFigure('<svg><text font-size="28">ok</text><text style="font-size: 32px">ok</text></svg>');
|
||||
assert.ok(!w.some((x) => /24/.test(x)));
|
||||
});
|
||||
|
||||
test('mer enn 2 ikke-nøytrale farger gir fargebudsjett-warning', () => {
|
||||
const w = validateFigure('<svg><rect fill="#9A3324"/><rect fill="#2F6F9F"/><rect fill="#22AA55"/></svg>');
|
||||
assert.ok(w.some((x) => /farge/i.test(x)));
|
||||
});
|
||||
|
||||
test('nøytraler (gråtoner/hvit/sort) telles ikke mot fargebudsjettet', () => {
|
||||
const w = validateFigure(
|
||||
'<svg><rect fill="#FFFFFF"/><rect fill="#1A1A1A"/><rect fill="#888888"/><rect fill="#9A3324" font-size="30"/></svg>'
|
||||
);
|
||||
assert.ok(!w.some((x) => /farge/i.test(x)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveChrome — robust binær-oppdagelse (mockbar)', () => {
|
||||
test('finner app-bundle-sti via exists-probe', () => {
|
||||
const r = resolveChrome({
|
||||
exists: (p) => p.includes('Google Chrome.app'),
|
||||
which: () => null,
|
||||
});
|
||||
assert.equal(r.available, true);
|
||||
assert.match(r.path, /Google Chrome/);
|
||||
});
|
||||
|
||||
test('faller tilbake til PATH via which-probe', () => {
|
||||
const r = resolveChrome({
|
||||
exists: () => false,
|
||||
which: (cmd) => (cmd === 'chromium' ? '/usr/local/bin/chromium' : null),
|
||||
});
|
||||
assert.equal(r.available, true);
|
||||
assert.equal(r.path, '/usr/local/bin/chromium');
|
||||
});
|
||||
|
||||
test('ingen kandidater: available=false med install-hint', () => {
|
||||
const r = resolveChrome({ exists: () => false, which: () => null });
|
||||
assert.equal(r.available, false);
|
||||
assert.match(r.hint, /Chrome/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseArgs — CLI-kontrakt', () => {
|
||||
test('defaults: target=article, out avledes senere (null)', () => {
|
||||
const r = parseArgs(['fig.svg']);
|
||||
assert.equal(r.error, undefined);
|
||||
assert.equal(r.input, 'fig.svg');
|
||||
assert.equal(r.target, 'article');
|
||||
assert.equal(r.out, null);
|
||||
});
|
||||
|
||||
test('--target/--out/--width/--height parses (tall som tall)', () => {
|
||||
const r = parseArgs(['fig.svg', '--target', 'carousel', '--out', '/tmp/x.png', '--width', '900', '--height', '1200']);
|
||||
assert.equal(r.target, 'carousel');
|
||||
assert.equal(r.out, '/tmp/x.png');
|
||||
assert.equal(r.width, 900);
|
||||
assert.equal(r.height, 1200);
|
||||
});
|
||||
|
||||
test('ugyldig target gir error med gyldige mål', () => {
|
||||
const r = parseArgs(['fig.svg', '--target', 'poster']);
|
||||
assert.match(r.error, /article/);
|
||||
});
|
||||
|
||||
test('manglende input gir usage-error', () => {
|
||||
const r = parseArgs([]);
|
||||
assert.match(r.error, /[Bb]ruk/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ende-til-ende (krever ekte Chrome — skippes CI-vennlig når binær mangler)
|
||||
// ---------------------------------------------------------------------------
|
||||
const chrome = resolveChrome();
|
||||
|
||||
describe('renderFigure — ende-til-ende via headless Chrome', { skip: !chrome.available && 'Chrome ikke funnet' }, () => {
|
||||
test('article: fixture-SVG → PNG med 1200px bredde og aspekt-avledet høyde', async () => {
|
||||
const out = path.join(tmpdir(), 'fig-article.png');
|
||||
const r = await renderFigure({ input: FIXTURE_SVG, target: 'article', out, dataDir: tmpdir() });
|
||||
assert.equal(r.out, out);
|
||||
const dim = pngDimensions(out);
|
||||
assert.equal(dim.width, 1200);
|
||||
assert.equal(dim.height, 675); // fixture er 16:9 (viewBox 1200×675)
|
||||
});
|
||||
|
||||
test('carousel: fixture-SVG → PNG 1080×1350', async () => {
|
||||
const out = path.join(tmpdir(), 'fig-carousel.png');
|
||||
await renderFigure({ input: FIXTURE_SVG, target: 'carousel', out, dataDir: tmpdir() });
|
||||
const dim = pngDimensions(out);
|
||||
assert.equal(dim.width, 1080);
|
||||
assert.equal(dim.height, 1350);
|
||||
});
|
||||
|
||||
test('render uten brand-tokens.json lykkes med nøytrale defaults', async () => {
|
||||
const out = path.join(tmpdir(), 'fig-default-tokens.png');
|
||||
const r = await renderFigure({ input: FIXTURE_SVG, target: 'single', out, dataDir: tmpdir() });
|
||||
assert.equal(r.tokensSource, 'default');
|
||||
assert.ok(fs.existsSync(out));
|
||||
// PNG-magic
|
||||
const buf = fs.readFileSync(out);
|
||||
assert.equal(buf.readUInt32BE(0), 0x89504e47);
|
||||
});
|
||||
|
||||
test('CLI: node build-figur.mjs <fixture> --target carousel --out <png>', () => {
|
||||
const out = path.join(tmpdir(), 'fig-cli.png');
|
||||
const script = path.join(__dirname, '..', 'build-figur.mjs');
|
||||
execFileSync(process.execPath, [script, FIXTURE_SVG, '--target', 'carousel', '--out', out], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const dim = pngDimensions(out);
|
||||
assert.equal(dim.width, 1080);
|
||||
assert.equal(dim.height, 1350);
|
||||
});
|
||||
});
|
||||
20
render/__tests__/fixtures/demo-figur.svg
Normal file
20
render/__tests__/fixtures/demo-figur.svg
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 675" role="img" aria-label="Demo: soylediagram med placeholder-data">
|
||||
<!-- Noytral demo-figur (placeholder-data) for build-figur-testene.
|
||||
Bruker token-CSS-variabler med noytrale fallbacks. -->
|
||||
<rect width="1200" height="675" fill="var(--figur-background, #FFFFFF)"/>
|
||||
<text x="80" y="90" font-family="var(--figur-fontFamily, sans-serif)" font-size="40" font-weight="700" fill="var(--figur-ink, #1A1A1A)">Kvartalsvis utvikling (demo)</text>
|
||||
<text x="80" y="135" font-family="var(--figur-fontFamily, sans-serif)" font-size="26" fill="var(--figur-muted, #5B5B5B)">Placeholder-data — ikke reelle tall</text>
|
||||
<g>
|
||||
<rect x="120" y="380" width="140" height="180" fill="var(--figur-accent, #2F6F9F)"/>
|
||||
<rect x="360" y="320" width="140" height="240" fill="var(--figur-accent, #2F6F9F)"/>
|
||||
<rect x="600" y="250" width="140" height="310" fill="var(--figur-accent, #2F6F9F)"/>
|
||||
<rect x="840" y="180" width="140" height="380" fill="var(--figur-accent, #2F6F9F)"/>
|
||||
</g>
|
||||
<line x1="80" y1="560" x2="1120" y2="560" stroke="var(--figur-rule, #D9D9D9)" stroke-width="2"/>
|
||||
<g font-family="var(--figur-fontFamily, sans-serif)" font-size="26" fill="var(--figur-muted, #5B5B5B)">
|
||||
<text x="160" y="605">Q1</text>
|
||||
<text x="400" y="605">Q2</text>
|
||||
<text x="640" y="605">Q3</text>
|
||||
<text x="880" y="605">Q4</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
365
render/build-figur.mjs
Normal file
365
render/build-figur.mjs
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
#!/usr/bin/env node
|
||||
// build-figur.mjs — render en kodet figur (SVG/HTML) til PNG via headless Chrome.
|
||||
// Bruk: node build-figur.mjs <figur.svg|figur.html> [--target article|carousel|single]
|
||||
// [--out <fil.png>] [--width N] [--height N]
|
||||
// Tre render-mål: article (~1200px bred, innholdsstyrt høyde) · carousel (1080×1350)
|
||||
// · single (1200×1200). Design-tokens leses fra brukerens data-dir
|
||||
// (${LINKEDIN_STUDIO_DATA:-$HOME/.claude/linkedin-studio}/profile/brand-tokens.json)
|
||||
// og injiseres som CSS-variabler (--figur-*); uten fil brukes nøytrale defaults —
|
||||
// brukerens merkevare er DATA, aldri hardkodet her. Designregler håndheves som
|
||||
// warn-validering (aldri hard fail). Ingen nettverkstilgang under render (lokal
|
||||
// fil-input; Chrome kjøres med nettverksdempende flagg). CLI + importerbar modul
|
||||
// (samme dobbeltrolle som hooks/scripts/ical-generator.mjs). Ingen npm-avhengigheter.
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render-mål. height:null = innholdsstyrt (avledes av kildens aspekt, ellers
|
||||
// fallback). Overstyrbare via --width/--height.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const TARGETS = {
|
||||
article: { width: 1200, height: null },
|
||||
carousel: { width: 1080, height: 1350 },
|
||||
single: { width: 1200, height: 1200 },
|
||||
};
|
||||
|
||||
const ARTICLE_FALLBACK_ASPECT = 16 / 9;
|
||||
|
||||
export function deriveDimensions(target, aspect, overrides = {}) {
|
||||
const t = TARGETS[target];
|
||||
if (!t) {
|
||||
throw new Error(`Ukjent mål «${target}». Gyldige mål: ${Object.keys(TARGETS).join(", ")}`);
|
||||
}
|
||||
const warnings = [];
|
||||
const width = overrides.width ?? t.width;
|
||||
let height = overrides.height ?? t.height;
|
||||
if (height == null) {
|
||||
if (aspect) {
|
||||
height = Math.round(width / aspect);
|
||||
} else {
|
||||
height = Math.round(width / ARTICLE_FALLBACK_ASPECT);
|
||||
warnings.push(
|
||||
`Kilden oppgir ingen dimensjoner — bruker fallback-høyde ${height}px (overstyr med --height).`
|
||||
);
|
||||
}
|
||||
}
|
||||
return { width, height, warnings };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Design-tokens: BRUKERDATA fra data-dir, nøytrale defaults ellers.
|
||||
// ---------------------------------------------------------------------------
|
||||
export const DEFAULT_TOKENS = {
|
||||
background: "#FFFFFF",
|
||||
ink: "#1A1A1A",
|
||||
muted: "#5B5B5B",
|
||||
accent: "#2F6F9F",
|
||||
rule: "#D9D9D9",
|
||||
fontFamily: "system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif",
|
||||
};
|
||||
|
||||
function defaultDataDir() {
|
||||
return process.env.LINKEDIN_STUDIO_DATA || path.join(os.homedir(), ".claude", "linkedin-studio");
|
||||
}
|
||||
|
||||
export function loadBrandTokens(dataDir = defaultDataDir()) {
|
||||
const file = path.join(dataDir, "profile", "brand-tokens.json");
|
||||
const warnings = [];
|
||||
if (!fs.existsSync(file)) {
|
||||
return { tokens: { ...DEFAULT_TOKENS }, source: "default", warnings };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("forventet et JSON-objekt");
|
||||
}
|
||||
return { tokens: { ...DEFAULT_TOKENS, ...parsed }, source: "user", warnings };
|
||||
} catch (err) {
|
||||
warnings.push(`Kunne ikke lese brand-tokens.json (${err.message}) — bruker nøytrale defaults.`);
|
||||
return { tokens: { ...DEFAULT_TOKENS }, source: "default", warnings };
|
||||
}
|
||||
}
|
||||
|
||||
export function tokensToCss(tokens) {
|
||||
const vars = Object.entries(tokens)
|
||||
.map(([key, value]) => `--figur-${key}:${value};`)
|
||||
.join("");
|
||||
return `:root{${vars}}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kilde → render-klar HTML. SVG pakkes i en side med eksakt mål-flate;
|
||||
// HTML får token-variablene injisert (etter <head>, ellers prependet).
|
||||
// ---------------------------------------------------------------------------
|
||||
export function wrapSvg(svgContent, { width, height }, tokens) {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><style>
|
||||
${tokensToCss(tokens)}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
html,body{width: ${width}px;height: ${height}px;background:var(--figur-background);overflow:hidden;}
|
||||
svg{display:block;width:100%;height:100%;}
|
||||
</style></head>
|
||||
<body>
|
||||
${svgContent}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
export function injectTokens(html, tokens) {
|
||||
const style = `<style>${tokensToCss(tokens)}</style>`;
|
||||
if (/<head[^>]*>/i.test(html)) {
|
||||
return html.replace(/<head[^>]*>/i, (m) => `${m}${style}`);
|
||||
}
|
||||
return `${style}\n${html}`;
|
||||
}
|
||||
|
||||
// Aspekt (bredde/høyde) fra SVG-kilden: viewBox først, ellers width/height-attributter.
|
||||
export function parseSvgMeta(svgContent) {
|
||||
const vb = svgContent.match(/viewBox\s*=\s*["']\s*[\d.+-]+[\s,]+[\d.+-]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/);
|
||||
if (vb) {
|
||||
const w = parseFloat(vb[1]);
|
||||
const h = parseFloat(vb[2]);
|
||||
if (w > 0 && h > 0) return w / h;
|
||||
}
|
||||
const wm = svgContent.match(/<svg[^>]*\bwidth\s*=\s*["']([\d.]+)(?:px)?["']/);
|
||||
const hm = svgContent.match(/<svg[^>]*\bheight\s*=\s*["']([\d.]+)(?:px)?["']/);
|
||||
if (wm && hm) {
|
||||
const w = parseFloat(wm[1]);
|
||||
const h = parseFloat(hm[1]);
|
||||
if (w > 0 && h > 0) return w / h;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Warn-validering av designregler (aldri hard fail):
|
||||
// - minst 24pt/px effektiv tekststørrelse
|
||||
// - maks 1 primær + 1 sekundærfarge utover nøytraler (gråtoner/hvit/sort)
|
||||
// (≥40 % whitespace er redaksjonell regel — ikke maskinelt målbar her.)
|
||||
// ---------------------------------------------------------------------------
|
||||
const MIN_FONT_SIZE = 24;
|
||||
const MAX_NON_NEUTRAL_COLORS = 2;
|
||||
|
||||
function isNeutralHex(hex6) {
|
||||
const r = parseInt(hex6.slice(0, 2), 16);
|
||||
const g = parseInt(hex6.slice(2, 4), 16);
|
||||
const b = parseInt(hex6.slice(4, 6), 16);
|
||||
const spread = Math.max(r, g, b) - Math.min(r, g, b);
|
||||
return spread <= 18;
|
||||
}
|
||||
|
||||
export function validateFigure(source) {
|
||||
const warnings = [];
|
||||
|
||||
const smallSizes = [];
|
||||
const sizeRe = /font-size\s*[:=]\s*["']?\s*([\d.]+)\s*(px|pt)?/gi;
|
||||
let m;
|
||||
while ((m = sizeRe.exec(source)) !== null) {
|
||||
const size = parseFloat(m[1]);
|
||||
if (size < MIN_FONT_SIZE) smallSizes.push(size);
|
||||
}
|
||||
if (smallSizes.length) {
|
||||
warnings.push(
|
||||
`Tekststørrelse under ${MIN_FONT_SIZE}pt funnet (${smallSizes.join(", ")}) — hev for lesbarhet på mobil.`
|
||||
);
|
||||
}
|
||||
|
||||
const nonNeutral = new Set();
|
||||
const colorRe = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g;
|
||||
while ((m = colorRe.exec(source)) !== null) {
|
||||
let hex = m[1].toLowerCase();
|
||||
if (hex.length === 3) hex = hex.split("").map((c) => c + c).join("");
|
||||
if (!isNeutralHex(hex)) nonNeutral.add(hex);
|
||||
}
|
||||
if (nonNeutral.size > MAX_NON_NEUTRAL_COLORS) {
|
||||
warnings.push(
|
||||
`Fargebudsjett: ${nonNeutral.size} ikke-nøytrale farger (maks 1 primær + 1 sekundær utover nøytraler).`
|
||||
);
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Robust Chrome-oppdagelse: app-bundle-stier → PATH → hint. Prober er
|
||||
// injiserbare for test.
|
||||
// ---------------------------------------------------------------------------
|
||||
const CHROME_APP_CANDIDATES = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
path.join(os.homedir(), "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome"),
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
];
|
||||
const CHROME_PATH_NAMES = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"];
|
||||
|
||||
const CHROME_HINT =
|
||||
"Fant ingen Chrome/Chromium-binær — headless-render krever en.\n" +
|
||||
` Lette etter: ${CHROME_APP_CANDIDATES.join(" · ")} + PATH (${CHROME_PATH_NAMES.join(", ")})\n` +
|
||||
" Installer Google Chrome (https://www.google.com/chrome/) eller `brew install chromium`.";
|
||||
|
||||
function defaultWhich(cmd) {
|
||||
try {
|
||||
const out = execFileSync("/usr/bin/which", [cmd], { encoding: "utf8" }).trim();
|
||||
return out || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveChrome(probes = {}) {
|
||||
const exists = probes.exists || fs.existsSync;
|
||||
const which = probes.which || defaultWhich;
|
||||
for (const candidate of CHROME_APP_CANDIDATES) {
|
||||
if (exists(candidate)) return { available: true, path: candidate };
|
||||
}
|
||||
for (const name of CHROME_PATH_NAMES) {
|
||||
const found = which(name);
|
||||
if (found) return { available: true, path: found };
|
||||
}
|
||||
return { available: false, hint: CHROME_HINT };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: kilde → wrapper-HTML i tmp → headless Chrome-screenshot → PNG.
|
||||
// Kjent macOS-quirk (verifisert lokalt mot Chrome 150): headless skriver PNG-en
|
||||
// og melder «N bytes written to file …» på stderr, men prosessen avslutter
|
||||
// aldri. Watchdog: vi lytter på stderr-kvitteringen (evt. ren exit på andre
|
||||
// plattformer), dreper prosessen og verifiserer at PNG-filen faktisk finnes.
|
||||
// ---------------------------------------------------------------------------
|
||||
const RENDER_TIMEOUT_MS = 30000;
|
||||
|
||||
function runChromeScreenshot(chromePath, args, timeoutMs = RENDER_TIMEOUT_MS) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(chromePath, args, { stdio: ["ignore", "ignore", "pipe"] });
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const finish = (reason) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.kill("SIGKILL");
|
||||
resolve({ reason, stderr });
|
||||
};
|
||||
const timer = setTimeout(() => finish("timeout"), timeoutMs);
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
if (stderr.includes("written to file")) finish("written");
|
||||
});
|
||||
child.on("exit", () => finish("exit"));
|
||||
child.on("error", () => finish("spawn-error"));
|
||||
});
|
||||
}
|
||||
|
||||
export async function renderFigure(opts) {
|
||||
const { input, target = "article", out = null, dataDir, chrome = null } = opts;
|
||||
const inPath = path.isAbsolute(input) ? input : path.join(process.cwd(), input);
|
||||
if (!fs.existsSync(inPath)) throw new Error(`Fant ikke input-fil: ${inPath}`);
|
||||
|
||||
const ext = path.extname(inPath).toLowerCase();
|
||||
if (![".svg", ".html", ".htm"].includes(ext)) {
|
||||
throw new Error(`Ustøttet input-type «${ext}» — bruk .svg eller .html.`);
|
||||
}
|
||||
const source = fs.readFileSync(inPath, "utf8");
|
||||
|
||||
const { tokens, source: tokensSource, warnings } = loadBrandTokens(dataDir);
|
||||
warnings.push(...validateFigure(source));
|
||||
|
||||
const aspect = ext === ".svg" ? parseSvgMeta(source) : null;
|
||||
const dims = deriveDimensions(target, aspect, { width: opts.width ?? null, height: opts.height ?? null });
|
||||
warnings.push(...dims.warnings);
|
||||
|
||||
const pageHtml = ext === ".svg" ? wrapSvg(source, dims, tokens) : injectTokens(source, tokens);
|
||||
|
||||
const resolved = chrome || resolveChrome();
|
||||
if (!resolved.available) throw new Error(resolved.hint);
|
||||
|
||||
const outPath = out
|
||||
? (path.isAbsolute(out) ? out : path.join(process.cwd(), out))
|
||||
: path.join(path.dirname(inPath), `${path.basename(inPath, ext)}-${target}.png`);
|
||||
|
||||
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "build-figur-"));
|
||||
try {
|
||||
const htmlPath = path.join(workDir, "figur.html");
|
||||
fs.writeFileSync(htmlPath, pageHtml, "utf8");
|
||||
const run = await runChromeScreenshot(resolved.path, [
|
||||
"--headless",
|
||||
"--disable-gpu",
|
||||
"--hide-scrollbars",
|
||||
"--force-device-scale-factor=1",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-extensions",
|
||||
"--disable-sync",
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--use-mock-keychain",
|
||||
"--password-store=basic",
|
||||
`--user-data-dir=${path.join(workDir, "profile")}`,
|
||||
`--window-size=${dims.width},${dims.height}`,
|
||||
`--screenshot=${outPath}`,
|
||||
`file://${htmlPath}`,
|
||||
]);
|
||||
if (!fs.existsSync(outPath) || fs.statSync(outPath).size === 0) {
|
||||
const tail = run.stderr.split("\n").filter(Boolean).slice(-5).join("\n");
|
||||
throw new Error(`Chrome skrev ikke ${outPath} (${run.reason}).\n${tail}`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(workDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return { out: outPath, width: dims.width, height: dims.height, warnings, tokensSource };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const USAGE =
|
||||
"Bruk: node build-figur.mjs <figur.svg|figur.html> [--target article|carousel|single] [--out <fil.png>] [--width N] [--height N]";
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const cfg = { input: null, target: "article", out: null, width: null, height: null };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--target") cfg.target = argv[++i];
|
||||
else if (arg === "--out") cfg.out = argv[++i];
|
||||
else if (arg === "--width") cfg.width = parseInt(argv[++i], 10);
|
||||
else if (arg === "--height") cfg.height = parseInt(argv[++i], 10);
|
||||
else if (arg.startsWith("--")) return { error: `Ukjent flagg «${arg}». ${USAGE}` };
|
||||
else if (!cfg.input) cfg.input = arg;
|
||||
else return { error: `Uventet argument «${arg}». ${USAGE}` };
|
||||
}
|
||||
if (!cfg.input) return { error: USAGE };
|
||||
if (!TARGETS[cfg.target]) {
|
||||
return { error: `Ukjent mål «${cfg.target}». Gyldige mål: ${Object.keys(TARGETS).join(", ")}` };
|
||||
}
|
||||
if ((cfg.width !== null && !Number.isFinite(cfg.width)) || (cfg.height !== null && !Number.isFinite(cfg.height))) {
|
||||
return { error: `--width/--height må være heltall. ${USAGE}` };
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cfg = parseArgs(process.argv.slice(2));
|
||||
if (cfg.error) {
|
||||
console.error(cfg.error);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const result = await renderFigure(cfg);
|
||||
for (const w of result.warnings) console.warn(`⚠ ${w}`);
|
||||
console.log(
|
||||
`Figur: ${result.out} (${result.width}×${result.height}, mål: ${cfg.target}, tokens: ${result.tokensSource})`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue