← Files stark AI DeveloperARCHIVED FILE

skills/drawio-diagrams/scripts/rasterize-themed-svg.mjs

20.3 KB · Oct 4, 2026 · 12:30 UTC

↓ Download file

#!/usr/bin/env node
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";

import { validatePng, validateSvgXml } from "./render-drawio.mjs";

const MAX_SVG_BYTES = 32 * 1024 * 1024;
const MAX_DIMENSION = 32_768;
const MAX_PIXELS = 16_000_000;
const MAX_EMBEDDED_SVG_BYTES = 2 * 1024 * 1024;
const MAX_EMBEDDED_SVG_DEPTH = 4;
const MAX_EMBEDDED_SVG_TOTAL_BYTES = 8 * 1024 * 1024;
const FIXED_THEMES = new Set(["light", "dark"]);
const ACTIVE_ELEMENTS = new Set([
  "animate",
  "animatemotion",
  "animatetransform",
  "audio",
  "base",
  "button",
  "embed",
  "form",
  "handler",
  "iframe",
  "input",
  "listener",
  "link",
  "meta",
  "object",
  "script",
  "select",
  "set",
  "source",
  "textarea",
  "track",
  "video",
]);
const HTML_RENDER_REFERENCE_ATTRIBUTES = new Set(["background", "poster", "src"]);
const CSS_RENDER_REFERENCE_ATTRIBUTES = new Set([
  "clip-path",
  "cursor",
  "fill",
  "filter",
  "marker-end",
  "marker-mid",
  "marker-start",
  "mask",
  "stroke",
  "style",
]);
const DATA_IMAGE_ELEMENTS = new Set(["feimage", "image", "img"]);

function usage() {
  return `Usage: node scripts/rasterize-themed-svg.mjs input.light.svg output.light.png --browser <absolute-chrome-chromium-or-edge-path>

Rasterizes one fixed-theme SVG through a local Chromium-family browser.
The input must declare exactly one root color-scheme (light or dark), have bounded dimensions,
and contain no active content or remotely loaded render assets. The output is validated and installed no-clobber.`;
}

export function parseArgs(argv) {
  let input = null;
  let output = null;
  let browser = null;

  for (let index = 0; index < argv.length; index += 1) {
    const arg = argv[index];
    if (arg === "--help" || arg === "-h") return { help: true, input, output, browser };
    if (arg === "--browser") {
      browser = argv[++index];
      if (!browser) throw new Error("--browser requires an absolute executable path");
      continue;
    }
    if (arg.startsWith("-")) throw new Error(`Unknown argument: ${arg}`);
    if (!input) input = arg;
    else if (!output) output = arg;
    else throw new Error(`Unexpected extra input: ${arg}`);
  }

  return { help: false, input, output, browser };
}

function parseDimension(value, label) {
  const match = String(value || "")
    .trim()
    .match(/^(\d+(?:\.\d+)?)(?:px)?$/);
  if (!match) throw new Error(`SVG ${label} must be a positive px dimension`);
  const dimension = Math.ceil(Number(match[1]));
  if (!Number.isSafeInteger(dimension) || dimension <= 0 || dimension > MAX_DIMENSION) {
    throw new Error(`SVG ${label} exceeds the ${MAX_DIMENSION}px raster limit`);
  }
  return dimension;
}

function cssDeclarations(style) {
  const source = String(style || "");
  const declarations = [];
  let start = 0;
  let quote = null;
  let parentheses = 0;
  for (let index = 0; index < source.length; index += 1) {
    const character = source[index];
    if (quote) {
      if (character === "\\") {
        throw new Error("SVG root style contains unsupported CSS escape syntax");
      }
      if (character === quote) quote = null;
      continue;
    }
    if (character === '"' || character === "'") {
      quote = character;
    } else if (character === "(") {
      parentheses += 1;
    } else if (character === ")") {
      if (parentheses === 0) throw new Error("SVG root style contains malformed CSS syntax");
      parentheses -= 1;
    } else if (character === ";" && parentheses === 0) {
      declarations.push(source.slice(start, index));
      start = index + 1;
    }
  }
  if (quote || parentheses !== 0) {
    throw new Error("SVG root style contains malformed CSS syntax");
  }
  declarations.push(source.slice(start));
  return declarations.map((part) => part.trim()).filter(Boolean);
}

function rootTheme(style) {
  const declarations = cssDeclarations(style);
  const schemes = declarations
    .map((part) =>
      part
        .match(/^color-scheme\s*:\s*(.+)$/i)?.[1]
        ?.trim()
        .toLowerCase(),
    )
    .filter(Boolean);
  if (schemes.length !== 1) {
    throw new Error("SVG root must declare exactly one color-scheme");
  }
  const tokens = schemes[0].split(/\s+/).filter(Boolean);
  if (tokens.length !== 1 || !FIXED_THEMES.has(tokens[0])) {
    throw new Error("SVG root color-scheme must be fixed light or fixed dark");
  }
  return tokens[0];
}

function localName(name) {
  return String(name)
    .slice(String(name).lastIndexOf(":") + 1)
    .toLowerCase();
}

function assertNoActiveUrlScheme(reference) {
  const separator = String(reference).indexOf(":");
  if (separator === -1) return;
  const scheme = String(reference)
    .slice(0, separator)
    .replace(/[\u0000-\u0020]+/g, "")
    .toLowerCase();
  if (scheme === "javascript" || scheme === "vbscript") {
    throw new Error("SVG contains an active URL scheme");
  }
}

function parseImageDataUrl(reference) {
  const match = String(reference)
    .trim()
    .match(/^data:([^,]*),(.*)$/is);
  if (!match) return null;
  const parameters = match[1].split(";");
  const mediaType = parameters.shift()?.trim().toLowerCase();
  if (!mediaType?.startsWith("image/")) return null;
  return {
    mediaType,
    payload: match[2],
    base64: parameters.some((parameter) => parameter.trim().toLowerCase() === "base64"),
  };
}

function decodeEmbeddedSvgDataUrl(parsed) {
  const { payload, base64 } = parsed;
  if (!payload || payload.length > MAX_EMBEDDED_SVG_BYTES * 4) {
    throw new Error("SVG embedded image data is empty or exceeds the size limit");
  }
  let bytes;
  try {
    if (base64) {
      if (
        payload.length % 4 === 1 ||
        !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?)?$/.test(payload)
      ) {
        throw new Error("invalid base64");
      }
      bytes = Buffer.from(payload, "base64");
    } else if (payload.trimStart().startsWith("<")) {
      bytes = Buffer.from(payload, "utf8");
    } else {
      if (/%(?![0-9A-Fa-f]{2})/.test(payload)) throw new Error("invalid percent encoding");
      bytes = Buffer.from(decodeURIComponent(payload), "utf8");
    }
  } catch {
    throw new Error("SVG embedded image data is not valid UTF-8 or encoded data");
  }
  if (!bytes.length || bytes.length > MAX_EMBEDDED_SVG_BYTES) {
    throw new Error("SVG embedded image data is empty or exceeds the size limit");
  }
  return bytes;
}

function inspectDataImageReference(reference, depth, context, message) {
  const parsed = parseImageDataUrl(reference);
  if (!parsed) throw new Error(message);
  if (parsed.mediaType !== "image/svg+xml") return;
  const bytes = decodeEmbeddedSvgDataUrl(parsed);
  inspectEmbeddedSvg(bytes, depth + 1, context);
}

function assertCssIsSelfContained(source, depth, context) {
  const css = String(source);
  if (css.includes("\\") || css.includes("/*") || css.includes("*/")) {
    throw new Error("SVG CSS escapes and comments are not supported for rasterization");
  }
  if (/(?:^|[^A-Za-z0-9_-])(?:-webkit-)?image-set\s*\(/i.test(css)) {
    throw new Error("SVG CSS image-set() values are not supported for rasterization");
  }
  if (/@import\b/i.test(css)) {
    throw new Error("SVG imported stylesheets are not supported for rasterization");
  }
  for (const match of css.matchAll(/url\s*\(\s*(["']?)(.*?)\1\s*\)/gis)) {
    const reference = match[2].trim();
    assertNoActiveUrlScheme(reference);
    if (!reference || reference.startsWith("#")) continue;
    if (!/^data:/i.test(reference)) throw new Error("SVG contains an external CSS render asset");
    inspectDataImageReference(
      reference,
      depth,
      context,
      "SVG contains an external CSS render asset",
    );
  }
}

function assertRenderReference(
  reference,
  message,
  { allowImageData = false, depth = 0, context } = {},
) {
  const value = String(reference).trim();
  assertNoActiveUrlScheme(value);
  if (!value || value.startsWith("#")) return;
  if (allowImageData && /^data:/i.test(value)) {
    inspectDataImageReference(value, depth, context, message);
    return;
  }
  throw new Error(message);
}

function inspectRasterText({ text, parentName }, depth, context) {
  if (localName(parentName) === "style") assertCssIsSelfContained(text, depth, context);
}

function inspectRasterElement({ name, attributes }, depth, context) {
  const elementName = localName(name);
  if (ACTIVE_ELEMENTS.has(elementName)) {
    throw new Error(`SVG contains an unsupported active content element: ${elementName}`);
  }

  for (const [name, value] of attributes) {
    const attributeName = localName(name);
    if (String(name).toLowerCase() === "xml:base") {
      throw new Error("SVG xml:base is not supported for rasterization");
    }
    if (attributeName.startsWith("on") && attributeName.length > 2) {
      throw new Error(`SVG contains an event handler attribute: ${name}`);
    }
    assertNoActiveUrlScheme(value);
    if (CSS_RENDER_REFERENCE_ATTRIBUTES.has(attributeName)) {
      assertCssIsSelfContained(value, depth, context);
    }
    if (attributeName === "srcset") {
      throw new Error("SVG contains an unsupported HTML source set");
    }
    if (attributeName === "href" && elementName !== "a") {
      assertRenderReference(value, "SVG contains an external image or symbol reference", {
        allowImageData: DATA_IMAGE_ELEMENTS.has(elementName),
        depth,
        context,
      });
    }
    if (HTML_RENDER_REFERENCE_ATTRIBUTES.has(attributeName)) {
      assertRenderReference(value, "SVG contains an external HTML render asset", {
        allowImageData: true,
        depth,
        context,
      });
    }
  }
}

function inspectSvgSource(source, depth, context) {
  if (/<!DOCTYPE\b/i.test(source)) {
    throw new Error("SVG DOCTYPE is not supported for rasterization");
  }
  return validateSvgXml(source, {
    onElement: (entry) => inspectRasterElement(entry, depth, context),
    onText: (entry) => inspectRasterText(entry, depth, context),
  });
}

function positiveSvgLength(value) {
  const match = String(value || "")
    .trim()
    .match(/^\+?(\d+(?:\.\d*)?|\.\d+)(?:[A-Za-z%]+)?$/);
  return Boolean(match && Number.isFinite(Number(match[1])) && Number(match[1]) > 0);
}

function embeddedSvgHasUsableBounds(attributes) {
  if (!attributes) return false;
  const viewBox = attributes.get("viewBox") || attributes.get("viewbox");
  if (viewBox) {
    const values = String(viewBox)
      .trim()
      .split(/[\s,]+/)
      .filter(Boolean)
      .map(Number);
    if (values.length === 4 && values.every(Number.isFinite) && values[2] > 0 && values[3] > 0) {
      return true;
    }
  }
  return positiveSvgLength(attributes.get("width")) && positiveSvgLength(attributes.get("height"));
}

function inspectEmbeddedSvg(bytes, depth, context) {
  if (depth > MAX_EMBEDDED_SVG_DEPTH) {
    throw new Error(`SVG embedded image nesting exceeds ${MAX_EMBEDDED_SVG_DEPTH} levels`);
  }
  context.embeddedBytes += bytes.length;
  if (context.embeddedBytes > MAX_EMBEDDED_SVG_TOTAL_BYTES) {
    throw new Error("SVG embedded image data exceeds the aggregate size limit");
  }
  let source;
  try {
    source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
  } catch {
    throw new Error("SVG embedded image data must use UTF-8 encoding");
  }
  const { rootAttributes } = inspectSvgSource(source, depth, context);
  if (!embeddedSvgHasUsableBounds(rootAttributes)) {
    throw new Error("SVG embedded image needs a positive viewBox or width and height");
  }
}

export function inspectThemedSvg(source) {
  if (Buffer.byteLength(source, "utf8") > MAX_SVG_BYTES) {
    throw new Error(`SVG exceeds the ${MAX_SVG_BYTES}-byte inspection limit`);
  }
  const { rootAttributes: attributes } = inspectSvgSource(source, 0, { embeddedBytes: 0 });
  if (!attributes) throw new Error("SVG root element is missing");
  const width = parseDimension(attributes.get("width"), "width");
  const height = parseDimension(attributes.get("height"), "height");
  if (width * height > MAX_PIXELS) {
    throw new Error(`SVG exceeds the ${MAX_PIXELS}-pixel raster limit`);
  }
  return {
    theme: rootTheme(attributes.get("style")),
    width,
    height,
  };
}

function isPathCandidate(candidate) {
  return path.isAbsolute(candidate) || candidate.includes("/") || candidate.includes("\\");
}

export function findBrowser(configured = null) {
  if (!configured || !path.isAbsolute(configured)) return null;
  let browser;
  try {
    browser = fs.realpathSync(configured);
    if (!fs.statSync(browser).isFile()) return null;
    fs.accessSync(browser, process.platform === "win32" ? fs.constants.F_OK : fs.constants.X_OK);
  } catch {
    return null;
  }
  const result = spawnSync(browser, ["--version"], {
    encoding: "utf8",
    maxBuffer: 64 * 1024,
    timeout: 5_000,
  });
  const version = `${result.stdout || ""}\n${result.stderr || ""}`;
  if (
    result.error ||
    result.status !== 0 ||
    !/\b(?:chromium|google chrome|microsoft edge)\b/i.test(version)
  ) {
    return null;
  }
  return browser;
}

function windowsPath(file) {
  if (process.platform === "win32") return file;
  const result = spawnSync("wslpath", ["-w", file], {
    encoding: "utf8",
    timeout: 5_000,
  });
  if (result.error || result.status !== 0 || !result.stdout.trim()) {
    throw new Error("Windows browser selected, but wslpath could not translate the raster path");
  }
  return result.stdout.trim();
}

function windowsFileUrl(file) {
  const normalized = windowsPath(file).replaceAll("\\", "/");
  if (/^[A-Za-z]:\//.test(normalized)) return `file:///${normalized}`;
  if (normalized.startsWith("//")) return `file:${normalized}`;
  throw new Error("wslpath returned an unsupported Windows path");
}

function browserPaths(browser, input, output, profile) {
  const windowsBrowser = process.platform === "win32" || /\.exe$/i.test(browser);
  return windowsBrowser
    ? {
        inputUrl: windowsFileUrl(input),
        output: windowsPath(output),
        profile: windowsPath(profile),
      }
    : { inputUrl: pathToFileURL(input).href, output, profile };
}

function isolatedBrowserProfile(browser, stagingDirectory) {
  const windowsBrowser = process.platform === "win32" || /\.exe$/i.test(browser);
  if (!windowsBrowser || process.platform === "win32") {
    return fs.mkdtempSync(path.join(stagingDirectory, "browser-profile-"));
  }

  let windowsTemp = null;
  for (const powershell of [
    "powershell.exe",
    "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
  ]) {
    if (isPathCandidate(powershell) && !fs.existsSync(powershell)) continue;
    const result = spawnSync(
      powershell,
      ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "[IO.Path]::GetTempPath()"],
      {
        cwd: os.tmpdir(),
        encoding: "utf8",
        timeout: 5_000,
      },
    );
    if (!result.error && result.status === 0 && result.stdout?.trim()) {
      windowsTemp = result.stdout.trim();
      break;
    }
  }
  if (!windowsTemp) {
    throw new Error("Windows browser selected, but its local temp path could not be resolved");
  }
  const translated = spawnSync("wslpath", ["-u", windowsTemp], {
    encoding: "utf8",
    timeout: 5_000,
  });
  if (translated.error || translated.status !== 0 || !translated.stdout.trim()) {
    throw new Error("Windows browser selected, but its local temp path could not be translated");
  }
  const localTemp = fs.realpathSync(translated.stdout.trim());
  return fs.mkdtempSync(path.join(localTemp, "drawio-themed-svg-profile-"));
}

function runBrowser(browser, input, output, profile, width, height) {
  const browserPath = browserPaths(browser, input, output, profile);
  const args = [
    "--headless=new",
    "--disable-component-update",
    "--disable-gpu",
    "--disable-background-networking",
    "--disable-extensions",
    "--disable-javascript",
    "--disable-sync",
    "--hide-scrollbars",
    "--metrics-recording-only",
    "--no-default-browser-check",
    "--no-first-run",
    "--force-device-scale-factor=1",
    "--force-color-profile=srgb",
    "--host-resolver-rules=MAP * ~NOTFOUND",
    "--run-all-compositor-stages-before-draw",
    `--user-data-dir=${browserPath.profile}`,
    "--virtual-time-budget=1000",
    `--window-size=${width},${height}`,
    `--screenshot=${browserPath.output}`,
    browserPath.inputUrl,
  ];
  const result = spawnSync(browser, args, {
    encoding: "utf8",
    maxBuffer: 1024 * 1024,
    timeout: 30_000,
  });
  if (result.error?.code === "ETIMEDOUT") {
    throw new Error("browser rasterization exceeded the 30s timeout");
  }
  if (result.error || result.status !== 0) {
    throw new Error(
      [
        `browser rasterization failed: ${browser}`,
        result.error?.message,
        result.stderr?.trim(),
        result.stdout?.trim(),
      ]
        .filter(Boolean)
        .join("\n"),
    );
  }
}

function pngDimensions(file) {
  const bytes = fs.readFileSync(file);
  if (bytes.length < 24) throw new Error("browser PNG is too short");
  return {
    width: bytes.readUInt32BE(16),
    height: bytes.readUInt32BE(20),
  };
}

function publishNoClobber(staged, destination) {
  try {
    fs.linkSync(staged, destination);
  } catch (error) {
    if (error?.code === "EEXIST")
      throw new Error(`refusing to replace existing output: ${destination}`);
    try {
      fs.copyFileSync(staged, destination, fs.constants.COPYFILE_EXCL);
    } catch (copyError) {
      if (copyError?.code === "EEXIST") {
        throw new Error(`refusing to replace existing output: ${destination}`);
      }
      throw copyError;
    }
  }
}

function main() {
  let args;
  try {
    args = parseArgs(process.argv.slice(2));
  } catch (error) {
    console.error(error.message);
    console.error(usage());
    process.exitCode = 2;
    return;
  }
  if (args.help) {
    console.log(usage());
    return;
  }
  if (!args.input || !args.output || (!args.browser && !process.env.SVG_RASTER_BROWSER)) {
    console.error(
      "input SVG, output PNG, and a pinned browser (--browser or SVG_RASTER_BROWSER) are required",
    );
    console.error(usage());
    process.exitCode = 2;
    return;
  }

  let stagingDirectory = null;
  let browserProfile = null;
  try {
    const input = fs.realpathSync(args.input);
    const inputStat = fs.statSync(input);
    if (!inputStat.isFile() || inputStat.size <= 0 || inputStat.size > MAX_SVG_BYTES) {
      throw new Error(`input must be a nonempty SVG no larger than ${MAX_SVG_BYTES} bytes`);
    }
    if (path.extname(input).toLowerCase() !== ".svg") throw new Error("input must end in .svg");
    const source = new TextDecoder("utf-8", { fatal: true }).decode(fs.readFileSync(input));
    const inspection = inspectThemedSvg(source);

    const requestedOutput = path.resolve(args.output);
    if (path.extname(requestedOutput).toLowerCase() !== ".png") {
      throw new Error("output must end in .png");
    }
    const outputParent = fs.realpathSync(path.dirname(requestedOutput));
    const output = path.join(outputParent, path.basename(requestedOutput));
    if (fs.existsSync(output)) throw new Error(`refusing to replace existing output: ${output}`);

    const browser = findBrowser(args.browser || process.env.SVG_RASTER_BROWSER);
    if (!browser) {
      throw new Error(
        "the pinned browser must be an absolute executable path to Chrome, Chromium, or Edge",
      );
    }

    stagingDirectory = fs.mkdtempSync(path.join(outputParent, ".themed-svg-raster-"));
    const stagedSvg = path.join(stagingDirectory, "input.svg");
    const stagedPng = path.join(stagingDirectory, "output.png");
    browserProfile = isolatedBrowserProfile(browser, stagingDirectory);
    fs.writeFileSync(stagedSvg, source, { encoding: "utf8", flag: "wx" });
    runBrowser(browser, stagedSvg, stagedPng, browserProfile, inspection.width, inspection.height);
    validatePng(stagedPng);
    const png = pngDimensions(stagedPng);
    if (png.width !== inspection.width || png.height !== inspection.height) {
      throw new Error(
        `browser PNG dimensions ${png.width}x${png.height} do not match SVG ${inspection.width}x${inspection.height}`,
      );
    }
    publishNoClobber(stagedPng, output);
    console.log(`fixed theme: ${inspection.theme}`);
    console.log(`PNG: ${output}`);
  } catch (error) {
    console.error(error.message);
    process.exitCode = 1;
  } finally {
    if (browserProfile) fs.rmSync(browserProfile, { recursive: true, force: true });
    if (stagingDirectory) fs.rmSync(stagingDirectory, { recursive: true, force: true });
  }
}

if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
  main();
}

SHA-256: 9167e9655fcda2fafec4152d270041288e8478b0f47e340431a71b95b6875aa8