feat(bimi): logo studio, certificate upload and a setup check in the console

The console took pasted SVG text and a URL. It now:

- takes a PNG or SVG and makes the logo BIMI accepts (SVG Tiny PS, square,
  at most 32 KB). An SVG has what the format forbids removed and is told what;
  a PNG is traced into vectors in the browser (imagetracerjs, public domain),
  walking down quality presets until it fits. The person positions it as in the
  photo editor: drag, zoom, background colour, preview in a circle or rounded
  square, and downloads the exact SVG to give a certificate authority
- holds the .pem the authority returns, validated against the logo and domain,
  and serves it at /.well-known/bimi/vmc.pem (routed by the webmail)
- shows the DNS record and runs the setup check (logo, DMARC, record, public
  URLs, certificate)
- a golden-file contract: the SVGs the studio produces are validated by the
  server's own validator (Go) and pinned by the studio's tests (TS)
- found by those tests: an element inside a link kept its onclick when the
  link was unwrapped; links are now unwrapped before cleaning
- the BIMI and photo texts are in Brazilian Portuguese with accents (they had
  been written without), and in Spanish with accents

A VMC cannot be generated from an image: it is issued by a certificate
authority. The README and the panel say so.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-16 17:04:35 -03:00
1 parent 109b8360dd
commit dabd1568d1
15 files changed
+1583 -164

No files matched your search

@@ -5,6 +5,7 @@ import { Globe, RefreshCw, Plus, KeyRound, CheckCircle2, AlertTriangle } from "l
import { useTranslations } from "../../../../i18n/provider";
import { Badge } from "../../../../components/ui/Badge";
import { Button } from "../../../../components/ui/Button";
import { BimiPanel } from "../../../../components/BimiPanel";
/**
* Domains, read from the database.
@@ -79,13 +80,6 @@ export default function AdminDomainsPage() {
const [reconciling, setReconciling] = useState(false);
// The BIMI mark for the selected domain: the one thing an external mail
// client will ever render for this sender.
const [bimi, setBimi] = useState<{ configured: boolean; vmc_url?: string; bytes?: number } | null>(null);
const [bimiSvg, setBimiSvg] = useState("");
const [bimiVmc, setBimiVmc] = useState("");
const [bimiBusy, setBimiBusy] = useState(false);
const [bimiProblems, setBimiProblems] = useState<string[]>([]);
const [verification, setVerification] = useState<DnsVerification | null>(null);
// What the last reconciliation actually did, so the panel can say it rather
// than leaving the operator to infer it from the verification that follows.
@@ -126,73 +120,6 @@ export default function AdminDomainsPage() {
// Asks the protocols service to resolve every expected record and report
// each one. It used to post to the auth service, which has no resolver and
// could only re-read the status already in the database.
// Reloaded whenever the selected domain changes: the mark belongs to the
// domain, not to the console.
useEffect(() => {
if (!selected) {
setBimi(null);
return;
}
let cancelled = false;
void (async () => {
try {
const res = await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`);
const data = res.ok ? await res.json() : { configured: false };
if (cancelled) return;
setBimi(data);
setBimiVmc(data.vmc_url ?? "");
setBimiSvg("");
setBimiProblems([]);
} catch {
if (!cancelled) setBimi({ configured: false });
}
})();
return () => {
cancelled = true;
};
}, [selected?.name]);
const saveBimi = async () => {
if (!selected || !bimiSvg.trim()) return;
setBimiBusy(true);
setBimiProblems([]);
setError(null);
try {
const res = await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ svg: bimiSvg, vmc_url: bimiVmc.trim() }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
// Every rule that failed, not the first: a receiver that rejects the
// mark shows nothing at all, so one round trip per rule is the worst
// possible way to learn this format.
setBimiProblems(data.violations ?? [data.message ?? t("errors.serverError")]);
return;
}
setBimi({ configured: true, vmc_url: bimiVmc.trim(), bytes: bimiSvg.length });
setBimiSvg("");
setNotice(t("admin.bimiSaved"));
} catch {
setError(t("errors.serverError"));
} finally {
setBimiBusy(false);
}
};
const removeBimi = async () => {
if (!selected) return;
setBimiBusy(true);
try {
await fetch(`/api/v1/admin/bimi?domain=${encodeURIComponent(selected.name)}`, { method: "DELETE" });
setBimi({ configured: false });
setBimiVmc("");
} finally {
setBimiBusy(false);
}
};
const handleReconcile = async () => {
if (!selected) return;
setReconciling(true);
@@ -523,67 +450,7 @@ export default function AdminDomainsPage() {
{/* BIMI: the only mark an external mail client ever renders for this
sender, and the reason a profile photo does not travel. */}
{activeTab === "domains" && selected && (
<section className={panel}>
<h2 className="text-[17px] font-medium leading-tight text-slate-100">
{t("admin.bimiTitle", { domain: selected.name })}
</h2>
<p className="text-[12.5px] leading-relaxed text-slate-400">{t("admin.bimiExplainer")}</p>
<div className="flex flex-wrap items-center gap-2.5">
<Badge variant={bimi?.configured ? "success" : "neutral"}>
{bimi?.configured ? t("admin.bimiConfigured") : t("admin.bimiNotConfigured")}
</Badge>
{bimi?.configured && !bimi.vmc_url && (
<span className="text-[11.5px] leading-relaxed text-amber-400">{t("admin.bimiNoVmc")}</span>
)}
</div>
<div>
<label htmlFor="bimi-svg" className={fieldLabel}>{t("admin.bimiSvg")}</label>
<textarea
id="bimi-svg"
value={bimiSvg}
onChange={(e) => setBimiSvg(e.target.value)}
rows={7}
spellCheck={false}
className={`${input} font-mono text-[12px]`}
/>
</div>
<div>
<label htmlFor="bimi-vmc" className={fieldLabel}>{t("admin.bimiVmc")}</label>
<input
id="bimi-vmc"
type="url"
value={bimiVmc}
onChange={(e) => setBimiVmc(e.target.value)}
placeholder="https://.../vmc.pem"
className={input}
/>
</div>
{bimiProblems.length > 0 && (
<div className="flex flex-col gap-1 rounded-xl bg-rose-900/50 px-3.5 py-3 shadow-edge">
<span className="text-[12.5px] text-rose-100">{t("admin.bimiRejected")}</span>
{bimiProblems.map((p) => (
<span key={p} className="font-mono text-[11.5px] leading-relaxed text-rose-200">{p}</span>
))}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" size="sm" onClick={() => void saveBimi()} disabled={bimiBusy || !bimiSvg.trim()}>
{bimiBusy ? t("common.loading") : t("common.save")}
</Button>
{bimi?.configured && (
<Button variant="ghost" size="sm" onClick={() => void removeBimi()} disabled={bimiBusy}>
{t("common.delete")}
</Button>
)}
</div>
</section>
)}
{activeTab === "domains" && selected && <BimiPanel domain={selected.name} onNotice={setNotice} />}
{activeTab === "dkim" && (
<section className={`${panel} max-w-[720px]`}>
@@ -0,0 +1,741 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Upload, ZoomIn, ZoomOut, Download, Trash2, CheckCircle2, AlertTriangle, Copy, Play } from "lucide-react";
import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
import { useTranslations } from "../i18n/provider";
import {
BimiLogoError,
DEFAULT_LAYOUT,
MAX_LOGO_BYTES,
TRACE_PRESETS,
buildLogoSvg,
byteLength,
clampLayout,
isHexColor,
sanitizeSvg,
traceRaster,
type Artwork,
type Layout,
type Raster,
type TracePreset,
} from "../lib/bimiLogo";
/** The frame the logo is positioned in. Square, like the file it produces. */
const VIEWPORT = 288;
/** Bitmaps are traced at this size at most: tracing cost grows with the pixels. */
const TRACE_MAX_SIDE = 512;
type Mask = "circle" | "rounded" | "none";
interface CertificateInfo {
present: boolean;
valid?: boolean;
violations?: string[];
subject?: string;
organization?: string;
issuer?: string;
not_after?: string;
dns_names?: string[];
kind?: string;
matches_logo?: boolean;
warnings?: string[];
}
interface BimiState {
configured: boolean;
svg?: string;
vmc_url?: string;
logo_url?: string;
certificate_url?: string;
certificate?: CertificateInfo;
}
interface CheckResult {
id: string;
status: "ok" | "warn" | "fail";
detail: string;
}
interface BimiPanelProps {
domain: string;
onNotice: (message: string) => void;
}
const panelClass = "flex flex-col gap-4 rounded-2xl bg-dark-panel p-[18px] shadow-edge";
const inputClass =
"w-full min-h-[36px] rounded-[10px] bg-dark-card px-3 py-2 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent";
const labelClass = "mb-1.5 block text-xs text-slate-400";
const subPanel = "flex flex-col gap-3 rounded-xl bg-dark-card/60 p-4 shadow-edge";
function dataUrl(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
function maskClass(mask: Mask): string {
if (mask === "circle") return "rounded-full";
if (mask === "rounded") return "rounded-[22%]";
return "";
}
function formatBytes(n: number): string {
return `${(n / 1024).toFixed(1)} KB`;
}
/** Reads a bitmap file into pixels, scaled down so tracing stays quick. */
async function readRaster(file: File): Promise<Raster> {
const url = URL.createObjectURL(file);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const el = new Image();
el.onload = () => resolve(el);
el.onerror = () => reject(new BimiLogoError("empty"));
el.src = url;
});
const ratio = Math.min(1, TRACE_MAX_SIDE / Math.max(img.naturalWidth, img.naturalHeight));
const width = Math.max(1, Math.round(img.naturalWidth * ratio));
const height = Math.max(1, Math.round(img.naturalHeight * ratio));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) throw new BimiLogoError("empty");
ctx.drawImage(img, 0, 0, width, height);
const { data } = ctx.getImageData(0, 0, width, height);
return { width, height, data };
} finally {
URL.revokeObjectURL(url);
}
}
export function BimiPanel({ domain, onNotice }: BimiPanelProps) {
const t = useTranslations();
const [bimi, setBimi] = useState<BimiState | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// The studio: what was chosen, and how it is placed
const [art, setArt] = useState<Artwork | null>(null);
const [raster, setRaster] = useState<Raster | null>(null);
const [preset, setPreset] = useState<TracePreset>("balanced");
const [loweredAuto, setLoweredAuto] = useState(false);
const [layout, setLayout] = useState<Layout>(DEFAULT_LAYOUT);
const [background, setBackground] = useState("#ffffff");
const [title, setTitle] = useState(domain);
const [mask, setMask] = useState<Mask>("circle");
const [problems, setProblems] = useState<string[]>([]);
const [vmcUrl, setVmcUrl] = useState("");
const [pem, setPem] = useState("");
const [certProblems, setCertProblems] = useState<string[]>([]);
const [checks, setChecks] = useState<{ ready: boolean; checks: CheckResult[] } | null>(null);
const [checking, setChecking] = useState(false);
const [copied, setCopied] = useState(false);
const fileRef = useRef<HTMLInputElement | null>(null);
const pemRef = useRef<HTMLInputElement | null>(null);
const dragRef = useRef<{ x: number; y: number; from: Layout } | null>(null);
const base = `/api/v1/admin/bimi`;
const q = `domain=${encodeURIComponent(domain)}`;
const load = useCallback(async () => {
try {
const res = await fetch(`${base}?${q}`);
const data: BimiState = res.ok ? await res.json() : { configured: false };
setBimi(data);
setVmcUrl(data.vmc_url ?? "");
} catch {
setBimi({ configured: false });
}
}, [base, q]);
// The mark belongs to the domain: a different domain starts from scratch
useEffect(() => {
setArt(null);
setRaster(null);
setProblems([]);
setError(null);
setChecks(null);
setPem("");
setCertProblems([]);
setTitle(domain);
setBimi(null);
void load();
}, [domain, load]);
// ------------------------------------------------------------ the studio
/** Traces pixels, walking down the presets until the file fits the limit. */
const traceFitting = useCallback(
(pixels: Raster, from: TracePreset, current: { layout: Layout; title: string; background: string }) => {
const tracer = import("imagetracerjs").then((m) => m.default);
return tracer.then((engine) => {
const start = TRACE_PRESETS.indexOf(from);
for (let i = start; i < TRACE_PRESETS.length; i++) {
const traced = traceRaster(pixels, TRACE_PRESETS[i], engine);
const size = byteLength(buildLogoSvg(traced, current.layout, current.title, current.background));
if (size <= MAX_LOGO_BYTES || i === TRACE_PRESETS.length - 1) {
return { art: traced, preset: TRACE_PRESETS[i], lowered: i > start };
}
}
throw new BimiLogoError("tooComplex");
});
},
[],
);
const openFile = async (file: File) => {
setError(null);
setProblems([]);
setBusy(true);
try {
setLayout(DEFAULT_LAYOUT);
if (file.type === "image/svg+xml" || /\.svg$/i.test(file.name)) {
setRaster(null);
setLoweredAuto(false);
setArt(sanitizeSvg(await file.text()));
} else if (/^image\/(png|jpeg|webp)$/.test(file.type)) {
const pixels = await readRaster(file);
const result = await traceFitting(pixels, "balanced", { layout: DEFAULT_LAYOUT, title, background });
setRaster(pixels);
setArt(result.art);
setPreset(result.preset);
setLoweredAuto(result.lowered);
} else {
throw new BimiLogoError("empty");
}
} catch (err) {
setArt(null);
const code = err instanceof BimiLogoError ? err.code : "notImage";
setError(t(`admin.bimiError_${code}`));
} finally {
setBusy(false);
}
};
const changePreset = async (next: TracePreset) => {
if (!raster) return;
setPreset(next);
setLoweredAuto(false);
setBusy(true);
try {
const engine = (await import("imagetracerjs")).default;
setArt(traceRaster(raster, next, engine));
} catch {
setError(t("admin.bimiError_empty"));
} finally {
setBusy(false);
}
};
const finalSvg = useMemo(() => (art ? buildLogoSvg(art, layout, title, background) : ""), [art, layout, title, background]);
const size = finalSvg ? byteLength(finalSvg) : 0;
const tooBig = size > MAX_LOGO_BYTES;
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!art) return;
e.currentTarget.setPointerCapture(e.pointerId);
dragRef.current = { x: e.clientX, y: e.clientY, from: layout };
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag) return;
setLayout(
clampLayout({
zoom: drag.from.zoom,
dx: drag.from.dx + (e.clientX - drag.x) / VIEWPORT,
dy: drag.from.dy + (e.clientY - drag.y) / VIEWPORT,
}),
);
};
const endDrag = () => {
dragRef.current = null;
};
const download = () => {
const url = URL.createObjectURL(new Blob([finalSvg], { type: "image/svg+xml" }));
const a = document.createElement("a");
a.href = url;
a.download = `${domain}-bimi.svg`;
a.click();
URL.revokeObjectURL(url);
};
const saveLogo = async () => {
if (!finalSvg || tooBig) return;
setBusy(true);
setProblems([]);
setError(null);
try {
const res = await fetch(`${base}?${q}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ svg: finalSvg, vmc_url: vmcUrl.trim() }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
// Every rule that failed, not the first: a receiver that rejects the
// mark shows nothing at all, so one round trip per rule is the worst
// possible way to learn this format.
setProblems(data.violations ?? [data.message ?? t("errors.serverError")]);
return;
}
setArt(null);
setRaster(null);
onNotice(t("admin.bimiSaved"));
await load();
} catch {
setError(t("errors.serverError"));
} finally {
setBusy(false);
}
};
const saveVmcUrl = async () => {
if (!bimi?.svg) return;
setBusy(true);
try {
await fetch(`${base}?${q}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ svg: bimi.svg, vmc_url: vmcUrl.trim() }),
});
await load();
} finally {
setBusy(false);
}
};
const removeLogo = async () => {
setBusy(true);
try {
await fetch(`${base}?${q}`, { method: "DELETE" });
setChecks(null);
await load();
} finally {
setBusy(false);
}
};
// --------------------------------------------------------- certificate
const sendPem = async (text: string) => {
if (!text.trim()) return;
setBusy(true);
setCertProblems([]);
try {
const res = await fetch(`${base}/certificate?${q}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pem: text }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setCertProblems(data.violations ?? [data.message ?? t("errors.serverError")]);
return;
}
setPem("");
onNotice(t("admin.bimiCertSaved"));
await load();
} catch {
setCertProblems([t("errors.serverError")]);
} finally {
setBusy(false);
}
};
const removeCertificate = async () => {
setBusy(true);
try {
await fetch(`${base}/certificate?${q}`, { method: "DELETE" });
setChecks(null);
await load();
} finally {
setBusy(false);
}
};
// --------------------------------------------------------------- check
const runCheck = async () => {
setChecking(true);
try {
const res = await fetch(`${base}/check?${q}`);
const data = await res.json();
setChecks(res.ok ? { ready: Boolean(data.ready), checks: data.checks ?? [] } : null);
} catch {
setChecks(null);
} finally {
setChecking(false);
}
};
const record = bimi?.configured
? `v=BIMI1; l=${bimi.logo_url};${bimi.certificate?.present ? ` a=${bimi.certificate_url};` : vmcUrl.trim() ? ` a=${vmcUrl.trim()};` : ""}`
: "";
const copyRecord = async () => {
try {
await navigator.clipboard.writeText(record);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* the text is selectable on screen */
}
};
const cert = bimi?.certificate;
const hasCertificate = Boolean(cert?.present);
const noVmc = bimi?.configured && !hasCertificate && !vmcUrl.trim();
const statusBadge = (status: CheckResult["status"]) =>
status === "ok" ? "success" : status === "warn" ? "warning" : "danger";
return (
<section className={panelClass}>
<div>
<h2 className="text-[17px] font-medium leading-tight text-slate-100">{t("admin.bimiTitle", { domain })}</h2>
<p className="mt-1 text-[12.5px] leading-relaxed text-slate-400">{t("admin.bimiExplainer")}</p>
</div>
<div className="flex flex-wrap items-center gap-2.5">
<Badge variant={bimi?.configured ? "success" : "neutral"}>
{bimi?.configured ? t("admin.bimiConfigured") : t("admin.bimiNotConfigured")}
</Badge>
{noVmc && <span className="text-[11.5px] leading-relaxed text-amber-400">{t("admin.bimiNoVmc")}</span>}
</div>
{/* ---------------------------------------------------------- logo */}
<div className={subPanel}>
{!art ? (
<div className="flex flex-wrap items-center gap-4">
<span className="flex h-[88px] w-[88px] flex-none items-center justify-center overflow-hidden rounded-full bg-dark-card shadow-edge">
{bimi?.svg ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={dataUrl(bimi.svg)} alt="" className="h-full w-full object-cover" />
) : (
<span className="text-[11px] text-slate-500">{t("admin.bimiNone")}</span>
)}
</span>
<div className="flex min-w-[220px] flex-1 flex-col gap-2">
<p className="text-[12px] leading-relaxed text-slate-400">{t("admin.bimiFormatHint")}</p>
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => fileRef.current?.click()} disabled={busy}>
<Upload className="h-3.5 w-3.5" />
<span>{bimi?.configured ? t("admin.bimiReplace") : t("admin.bimiChoose")}</span>
</Button>
{bimi?.configured && (
<Button variant="ghost" size="sm" onClick={() => void removeLogo()} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
<span>{t("common.delete")}</span>
</Button>
)}
</div>
</div>
</div>
) : (
<div className="flex flex-wrap items-start gap-6">
<div className="flex flex-col items-center gap-3">
<div
role="img"
aria-label={t("admin.bimiDragHint")}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className={`relative cursor-grab touch-none overflow-hidden shadow-edge active:cursor-grabbing ${maskClass(mask)}`}
style={{ width: VIEWPORT, height: VIEWPORT, background: "repeating-conic-gradient(#1e293b 0% 25%, #0f172a 0% 50%) 0 0 / 16px 16px" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={dataUrl(finalSvg)} alt="" draggable={false} className="pointer-events-none h-full w-full select-none" />
</div>
<div className="flex w-full max-w-[288px] items-center gap-2.5">
<ZoomOut className="h-4 w-4 flex-none text-slate-400" />
<input
type="range"
min={0.2}
max={4}
step={0.01}
value={layout.zoom}
onChange={(e) => setLayout((l) => clampLayout({ ...l, zoom: Number(e.target.value) }))}
aria-label={t("admin.bimiZoom")}
className="h-1 flex-1 cursor-pointer appearance-none rounded-full bg-dark-card accent-indigo-500"
/>
<ZoomIn className="h-4 w-4 flex-none text-slate-400" />
</div>
<div className="flex flex-wrap items-center justify-center gap-1.5" role="group" aria-label={t("admin.bimiMask")}>
{(["circle", "rounded", "none"] as Mask[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMask(m)}
aria-pressed={mask === m}
className={`rounded-md px-2.5 py-1 text-[11px] ${mask === m ? "bg-indigo-800 text-indigo-100" : "bg-slate-800 text-slate-300"}`}
>
{t(`admin.bimiMask_${m}`)}
</button>
))}
</div>
<p className="max-w-[288px] text-center text-[11.5px] text-slate-500">{t("admin.bimiDragHint")}</p>
</div>
<div className="flex min-w-[240px] flex-1 flex-col gap-3">
<div>
<label htmlFor="bimi-title" className={labelClass}>{t("admin.bimiBrandTitle")}</label>
<input id="bimi-title" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={100} className={inputClass} />
</div>
<div>
<label htmlFor="bimi-bg" className={labelClass}>{t("admin.bimiBackground")}</label>
<div className="flex items-center gap-2">
<input
id="bimi-bg"
type="color"
value={isHexColor(background) ? background : "#ffffff"}
onChange={(e) => setBackground(e.target.value)}
className="h-9 w-12 cursor-pointer rounded-md bg-dark-card p-1 shadow-edge"
/>
<span className="font-mono text-[12px] text-slate-400">{background}</span>
</div>
</div>
{raster && (
<div>
<label htmlFor="bimi-preset" className={labelClass}>{t("admin.bimiPreset")}</label>
<select
id="bimi-preset"
value={preset}
onChange={(e) => void changePreset(e.target.value as TracePreset)}
disabled={busy}
className={inputClass}
>
{TRACE_PRESETS.map((p) => (
<option key={p} value={p}>{t(`admin.bimiPreset_${p}`)}</option>
))}
</select>
<p className="mt-1.5 text-[11.5px] leading-relaxed text-slate-500">{t("admin.bimiTraced")}</p>
{loweredAuto && <p className="mt-1 text-[11.5px] leading-relaxed text-amber-400">{t("admin.bimiAutoLowered")}</p>}
</div>
)}
<div className={`text-[12.5px] ${tooBig ? "text-rose-300" : "text-slate-400"}`}>
{t("admin.bimiSize", { size: formatBytes(size), max: formatBytes(MAX_LOGO_BYTES) })}
{tooBig && <span className="mt-1 block text-[11.5px]">{t("admin.bimiTooBig")}</span>}
</div>
{art.removed.length > 0 && (
<p className="text-[11.5px] leading-relaxed text-amber-400">{t("admin.bimiRemoved", { items: art.removed.join(", ") })}</p>
)}
{art.warnings.includes("text") && (
<p className="text-[11.5px] leading-relaxed text-amber-400">{t("admin.bimiTextWarning")}</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => { setArt(null); setRaster(null); }} disabled={busy}>
{t("common.cancel")}
</Button>
<Button variant="secondary" size="sm" onClick={download}>
<Download className="h-3.5 w-3.5" />
<span>{t("admin.bimiDownload")}</span>
</Button>
<Button variant="primary" size="sm" onClick={() => void saveLogo()} disabled={busy || tooBig}>
{busy ? t("common.loading") : t("admin.bimiSaveLogo")}
</Button>
</div>
</div>
</div>
)}
{error && <div className="rounded-xl bg-rose-900/60 px-3.5 py-2.5 text-[12.5px] text-rose-200 shadow-edge">{error}</div>}
{problems.length > 0 && (
<div className="flex flex-col gap-1 rounded-xl bg-rose-900/50 px-3.5 py-3 shadow-edge">
<span className="text-[12.5px] text-rose-100">{t("admin.bimiRejected")}</span>
{problems.map((p) => (
<span key={p} className="font-mono text-[11.5px] leading-relaxed text-rose-200">{p}</span>
))}
</div>
)}
<input
ref={fileRef}
type="file"
accept="image/svg+xml,image/png,image/jpeg,image/webp,.svg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void openFile(file);
// Cleared so choosing the same file twice in a row still fires
e.target.value = "";
}}
/>
</div>
{/* ---------------------------------------------------- certificate */}
{bimi?.configured && (
<div className={subPanel}>
<h3 className="text-[14px] font-medium text-slate-100">{t("admin.bimiCertTitle")}</h3>
<p className="text-[12px] leading-relaxed text-slate-400">{t("admin.bimiCertExplainer")}</p>
{hasCertificate && cert ? (
<div className="flex flex-col gap-2 rounded-lg bg-dark-panel px-3.5 py-3 shadow-edge">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={cert.valid ? "success" : "danger"}>
{cert.valid ? t("admin.bimiCertValid") : t("admin.bimiCertInvalid")}
</Badge>
{cert.kind && cert.kind !== "unknown" && <Badge variant="info">{cert.kind.toUpperCase()}</Badge>}
</div>
{cert.organization && (
<span className="text-[12.5px] text-slate-200">
{t("admin.bimiCertIssued", { organization: cert.organization, issuer: cert.issuer ?? "" })}
</span>
)}
{cert.not_after && (
<span className="text-[12px] text-slate-400">
{t("admin.bimiCertValidUntil", { date: new Date(cert.not_after).toLocaleDateString() })}
</span>
)}
{cert.dns_names && cert.dns_names.length > 0 && (
<span className="break-all text-[11.5px] text-slate-500">{t("admin.bimiCertDomains", { names: cert.dns_names.join(", ") })}</span>
)}
<span className={`text-[12px] ${cert.matches_logo ? "text-slate-300" : "text-rose-300"}`}>
{cert.matches_logo ? t("admin.bimiCertMatches") : t("admin.bimiCertMismatch")}
</span>
{(cert.violations ?? []).map((v) => (
<span key={v} className="font-mono text-[11.5px] leading-relaxed text-rose-200">{v}</span>
))}
{(cert.warnings ?? []).map((w) => (
<span key={w} className="text-[11.5px] leading-relaxed text-amber-400">{w}</span>
))}
<span className="break-all font-mono text-[11px] text-slate-500">{bimi.certificate_url}</span>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => pemRef.current?.click()} disabled={busy}>
<Upload className="h-3.5 w-3.5" />
<span>{hasCertificate ? t("admin.bimiCertReplace") : t("admin.bimiCertChoose")}</span>
</Button>
{hasCertificate && (
<Button variant="ghost" size="sm" onClick={() => void removeCertificate()} disabled={busy}>
<Trash2 className="h-3.5 w-3.5" />
<span>{t("admin.bimiCertRemove")}</span>
</Button>
)}
</div>
<div>
<label htmlFor="bimi-pem" className={labelClass}>{t("admin.bimiCertPaste")}</label>
<textarea
id="bimi-pem"
value={pem}
onChange={(e) => setPem(e.target.value)}
rows={4}
spellCheck={false}
placeholder="-----BEGIN CERTIFICATE-----"
className={`${inputClass} font-mono text-[12px]`}
/>
{pem.trim() && (
<div className="mt-2">
<Button variant="primary" size="sm" onClick={() => void sendPem(pem)} disabled={busy}>
{busy ? t("common.loading") : t("admin.bimiCertSave")}
</Button>
</div>
)}
</div>
{certProblems.length > 0 && (
<div className="flex flex-col gap-1 rounded-xl bg-rose-900/50 px-3.5 py-3 shadow-edge">
<span className="text-[12.5px] text-rose-100">{t("admin.bimiCertRejected")}</span>
{certProblems.map((p) => (
<span key={p} className="font-mono text-[11.5px] leading-relaxed text-rose-200">{p}</span>
))}
</div>
)}
{!hasCertificate && (
<div>
<label htmlFor="bimi-vmc" className={labelClass}>{t("admin.bimiVmc")}</label>
<div className="flex flex-wrap gap-2">
<input
id="bimi-vmc"
type="url"
value={vmcUrl}
onChange={(e) => setVmcUrl(e.target.value)}
placeholder="https://.../vmc.pem"
className={`${inputClass} min-w-[220px] flex-1`}
/>
<Button variant="secondary" size="sm" onClick={() => void saveVmcUrl()} disabled={busy || vmcUrl === (bimi?.vmc_url ?? "")}>
{t("common.save")}
</Button>
</div>
</div>
)}
<input
ref={pemRef}
type="file"
accept=".pem,.crt,.cer,application/x-pem-file,application/pem-certificate-chain,text/plain"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) await sendPem(await file.text());
e.target.value = "";
}}
/>
</div>
)}
{/* ------------------------------------------------------- DNS + check */}
{bimi?.configured && (
<div className={subPanel}>
<h3 className="text-[14px] font-medium text-slate-100">{t("admin.bimiRecordTitle")}</h3>
<p className="text-[12px] leading-relaxed text-slate-400">{t("admin.bimiRecordHint")}</p>
<div className="flex flex-col gap-1.5 rounded-lg bg-dark-panel px-3.5 py-3 shadow-edge">
<span className="font-mono text-[11.5px] text-slate-500">default._bimi.{domain} TXT</span>
<span className="break-all font-mono text-[12px] text-slate-200">{record}</span>
<div>
<Button variant="ghost" size="sm" onClick={() => void copyRecord()}>
<Copy className="h-3.5 w-3.5" />
<span>{copied ? t("admin.bimiCopied") : t("admin.bimiCopy")}</span>
</Button>
</div>
</div>
<div className="mt-1 flex flex-wrap items-center gap-3">
<h3 className="text-[14px] font-medium text-slate-100">{t("admin.bimiCheckTitle")}</h3>
<Button variant="secondary" size="sm" onClick={() => void runCheck()} disabled={checking}>
<Play className="h-3.5 w-3.5" />
<span>{checking ? t("admin.bimiCheckRunning") : t("admin.bimiCheckRun")}</span>
</Button>
</div>
<p className="text-[12px] leading-relaxed text-slate-400">{t("admin.bimiCheckExplainer")}</p>
{checks && (
<div className="flex flex-col gap-2">
<div className={`flex items-center gap-2 text-[13px] ${checks.ready ? "text-indigo-200" : "text-amber-300"}`}>
{checks.ready ? <CheckCircle2 className="h-4 w-4" /> : <AlertTriangle className="h-4 w-4" />}
{checks.ready ? t("admin.bimiCheckReady") : t("admin.bimiCheckNotReady")}
</div>
{checks.checks.map((c) => (
<div key={c.id} className="flex flex-col gap-1 rounded-lg bg-dark-panel px-3.5 py-2.5 shadow-edge">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={statusBadge(c.status)}>{t(`admin.bimiCheckStatus_${c.status}`)}</Badge>
<span className="text-[12.5px] text-slate-200">{t(`admin.bimiCheckItem_${c.id}`)}</span>
</div>
<span className="break-words text-[11.5px] leading-relaxed text-slate-400">{c.detail}</span>
</div>
))}
</div>
)}
</div>
)}
</section>
);
}
+10
View File
@@ -0,0 +1,10 @@
/** imagetracerjs ships no types; only the call the studio makes is described. */
declare module "imagetracerjs" {
const ImageTracer: {
imagedataToSVG(
raster: { width: number; height: number; data: Uint8ClampedArray },
options?: Record<string, number | string | boolean>,
): string;
};
export default ImageTracer;
}
+228
View File
@@ -0,0 +1,228 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ImageTracer from "imagetracerjs";
import {
BimiLogoError,
DEFAULT_LAYOUT,
MAX_LOGO_BYTES,
TRACE_PRESETS,
buildLogoSvg,
byteLength,
clampLayout,
sanitizeSvg,
traceRaster,
type Raster,
} from "./bimiLogo";
/** A drawing from a design tool: namespaces, a style attribute, a link and a script. */
const FROM_A_DESIGN_TOOL = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:xlink="http://www.w3.org/1999/xlink"
width="200" height="100" viewBox="0 0 200 100" onload="alert(1)">
<sodipodi:namedview id="base"/>
<metadata>made with a tool</metadata>
<script>alert(1)</script>
<!-- a comment -->
<defs><linearGradient id="g"><stop offset="0" style="stop-color:#ff0000;stop-opacity:1"/><stop offset="1" stop-color="#0000ff"/></linearGradient></defs>
<rect x="10" y="10" width="80" height="80" style="fill:url(#g);stroke:none;filter:blur(2px)" class="big" inkscape:label="box"/>
<a xlink:href="https://evil.test/"><circle cx="150" cy="50" r="40" fill="#123456" onclick="steal()"/></a>
<image xlink:href="data:image/png;base64,AAAA" width="10" height="10"/>
<use xlink:href="#g"/>
</svg>`;
describe("sanitizeSvg", () => {
it("keeps the drawing and drops everything the format forbids", () => {
const art = sanitizeSvg(FROM_A_DESIGN_TOOL);
expect(art.width).toBe(200);
expect(art.height).toBe(100);
expect(art.inner).toContain("<rect");
expect(art.inner).toContain("<circle");
expect(art.inner).toContain("<linearGradient");
for (const forbidden of ["script", "onclick", "onload", "<image", "<use", "<a ", "evil.test", "sodipodi", "inkscape", "class=", "style=", "<!--", "metadata"]) {
expect(art.inner, forbidden).not.toContain(forbidden);
}
// What was lost that the person should know about
expect(art.removed).toEqual(expect.arrayContaining(["script", "image", "use", "a (link)"]));
// Editor noise is not worth mentioning
expect(art.removed).not.toContain("metadata");
});
it("turns style declarations into attributes, and refuses ones that fetch", () => {
const art = sanitizeSvg(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="5" height="5" style="fill:#ff0000;opacity:.5;background:url(https://x.test/a.png)"/></svg>`,
);
expect(art.inner).toContain('fill="#ff0000"');
expect(art.inner).toContain('opacity=".5"');
expect(art.inner).not.toContain("x.test");
});
it("keeps a gradient reference and drops an external one", () => {
const art = sanitizeSvg(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="5" height="5" fill="url(#g)"/><rect width="5" height="5" fill="url(https://x.test/g)"/></svg>`,
);
expect(art.inner).toContain('fill="url(#g)"');
expect(art.inner).not.toContain("x.test");
});
it("reads the size from width and height when there is no viewBox", () => {
const art = sanitizeSvg(`<svg xmlns="http://www.w3.org/2000/svg" width="64px" height="32"><rect width="64" height="32"/></svg>`);
expect([art.minX, art.minY, art.width, art.height]).toEqual([0, 0, 64, 32]);
});
it("keeps the origin of a viewBox that does not start at zero", () => {
const art = sanitizeSvg(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -20 100 50"><rect x="-10" y="-20" width="100" height="50"/></svg>`);
expect([art.minX, art.minY, art.width, art.height]).toEqual([-10, -20, 100, 50]);
});
it("warns that text depends on fonts", () => {
const art = sanitizeSvg(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><text x="1" y="5">Hi</text></svg>`);
expect(art.warnings).toEqual(["text"]);
});
it.each([
["not XML", "this is not xml <<<", "notXml"],
["not SVG", `<html xmlns="http://www.w3.org/1999/xhtml"><body/></html>`, "notSvg"],
["no size", `<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>`, "noSize"],
["nothing left", `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><script>1</script></svg>`, "empty"],
])("refuses a file that is %s", (_name, source, code) => {
expect(() => sanitizeSvg(source)).toThrowError(BimiLogoError);
try {
sanitizeSvg(source);
} catch (error) {
expect((error as BimiLogoError).code).toBe(code);
}
});
});
describe("buildLogoSvg", () => {
const art = sanitizeSvg(FROM_A_DESIGN_TOOL);
it("is SVG Tiny PS on a square canvas, with one title", () => {
const svg = buildLogoSvg(art, DEFAULT_LAYOUT, "Example", "#ffffff");
expect(svg).toContain('version="1.2"');
expect(svg).toContain('baseProfile="tiny-ps"');
expect(svg).toContain('viewBox="0 0 512 512"');
expect(svg.match(/<title>/g)).toHaveLength(1);
expect(svg).not.toMatch(/<svg[^>]*\s(x|y)=/);
});
it("centres a wide drawing and fills 80% of the square", () => {
// 200x100 scaled to 409.6 wide: 2.048, centred vertically
const svg = buildLogoSvg(art, DEFAULT_LAYOUT, "Example", "#ffffff");
expect(svg).toContain("matrix(2.048 0 0 2.048 51.2 153.6)");
});
it("moves and zooms the way the person positioned it", () => {
const base = buildLogoSvg(art, DEFAULT_LAYOUT, "Example", "#ffffff");
const moved = buildLogoSvg(art, { zoom: 1, dx: 0.1, dy: -0.1 }, "Example", "#ffffff");
expect(moved).toContain("matrix(2.048 0 0 2.048 102.4 102.4)");
expect(moved).not.toBe(base);
expect(buildLogoSvg(art, { zoom: 0.5, dx: 0, dy: 0 }, "Example", "#ffffff")).toContain("matrix(1.024 0 0 1.024");
});
it("accounts for a viewBox that does not start at zero", () => {
const offset = { ...art, minX: -10, minY: -20 };
const plain = buildLogoSvg(art, DEFAULT_LAYOUT, "x", "#fff");
const shifted = buildLogoSvg(offset, DEFAULT_LAYOUT, "x", "#fff");
expect(shifted).not.toBe(plain);
// 10 units at 2.048 is 20.48 further right
expect(shifted).toContain("matrix(2.048 0 0 2.048 71.68 194.56)");
});
it("escapes the title and falls back for a bad colour or an empty title", () => {
const svg = buildLogoSvg(art, DEFAULT_LAYOUT, `A & <B> "C"`, "red; evil");
expect(svg).toContain("<title>A &amp; &lt;B&gt; &quot;C&quot;</title>");
expect(svg).toContain('fill="#ffffff"');
expect(buildLogoSvg(art, DEFAULT_LAYOUT, " ", "#000000")).toContain("<title>Logo</title>");
});
it("keeps the layout inside sane limits", () => {
expect(clampLayout({ zoom: 99, dx: 9, dy: -9 })).toEqual({ zoom: 4, dx: 0.5, dy: -0.5 });
expect(clampLayout({ zoom: 0, dx: 0, dy: 0 }).zoom).toBe(0.2);
});
it("counts bytes, not characters", () => {
expect(byteLength("a")).toBe(1);
expect(byteLength("é")).toBe(2);
});
});
/** 64x64: a red disc on a transparent background, and a blue square. Deterministic. */
function syntheticLogo(): Raster {
const size = 64;
const data = new Uint8ClampedArray(size * size * 4);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const i = (y * size + x) * 4;
const inDisc = (x - 22) ** 2 + (y - 32) ** 2 < 14 ** 2;
const inSquare = x >= 40 && x < 58 && y >= 24 && y < 42;
if (inDisc) data.set([220, 30, 30, 255], i);
else if (inSquare) data.set([20, 60, 200, 255], i);
// else: fully transparent
}
}
return { width: size, height: size, data };
}
describe("traceRaster", () => {
const tracer = ImageTracer as unknown as Parameters<typeof traceRaster>[2];
it("vectorises pixels into paths and drops the transparent background", () => {
const art = traceRaster(syntheticLogo(), "balanced", tracer);
expect(art.width).toBe(64);
expect(art.inner).toContain("<path");
expect(art.inner).not.toContain("rgb(");
expect(art.inner).toMatch(/fill="#[0-9a-f]{6}"/);
// The tracer's own wrapper and comment are gone
expect(art.inner).not.toContain("<svg");
expect(art.inner).not.toContain("imagetracer");
// Both colours made it, nothing is left of the empty background
expect(art.inner.toLowerCase()).toMatch(/#dc1e1e|#d[0-9a-f]1[0-9a-f]1[0-9a-f]/);
expect(art.inner).not.toMatch(/opacity="0"/);
});
it("gets smaller as the preset gets coarser", () => {
const sizes = TRACE_PRESETS.map((preset) => byteLength(traceRaster(syntheticLogo(), preset, tracer).inner));
for (let i = 1; i < sizes.length; i++) expect(sizes[i]).toBeLessThanOrEqual(sizes[i - 1]);
expect(sizes[sizes.length - 1]).toBeLessThan(MAX_LOGO_BYTES);
});
it("refuses an image with nothing in it", () => {
const blank: Raster = { width: 8, height: 8, data: new Uint8ClampedArray(8 * 8 * 4) };
expect(() => traceRaster(blank, "balanced", tracer)).toThrowError(BimiLogoError);
});
});
// The server validates what the studio produces (protocols/internal/domain/bimi).
// Both sides read the same files, so a studio that starts producing something the
// server refuses fails here or there. Regenerate with UPDATE_GOLDEN=1 after
// changing the output on purpose.
describe("what the studio produces is what the server accepts", () => {
const dir = path.resolve(__dirname, "../../../protocols/internal/domain/bimi/testdata/studio");
const tracer = ImageTracer as unknown as Parameters<typeof traceRaster>[2];
const cases: Record<string, () => string> = {
"from-a-design-tool.svg": () => buildLogoSvg(sanitizeSvg(FROM_A_DESIGN_TOOL), DEFAULT_LAYOUT, "Example", "#ffffff"),
"positioned-and-zoomed.svg": () =>
buildLogoSvg(sanitizeSvg(FROM_A_DESIGN_TOOL), { zoom: 1.3, dx: -0.08, dy: 0.05 }, "Example brand", "#0b5c55"),
"traced-from-a-png.svg": () => buildLogoSvg(traceRaster(syntheticLogo(), "balanced", tracer), DEFAULT_LAYOUT, "Traced", "#ffffff"),
};
for (const [name, produce] of Object.entries(cases)) {
it(name, () => {
const output = produce();
const file = path.join(dir, name);
if (process.env.UPDATE_GOLDEN || !existsSync(file)) {
mkdirSync(dir, { recursive: true });
writeFileSync(file, output);
}
expect(output).toBe(readFileSync(file, "utf8"));
expect(byteLength(output)).toBeLessThanOrEqual(MAX_LOGO_BYTES);
});
}
});
+311
View File
@@ -0,0 +1,311 @@
/**
* Turns an uploaded PNG or SVG into the logo BIMI publishes.
*
* BIMI accepts one kind of file: SVG Tiny Portable/Secure, square, at most
* 32 KB, with no script, no raster image inside and nothing fetched from
* elsewhere. A receiver that is handed anything else shows no logo and says
* nothing, so the conversion is done here, before upload, and the server
* checks the result again (protocols/internal/domain/bimi).
*
* Three steps, each a pure function:
* - sanitizeSvg: an uploaded SVG, reduced to what the format allows
* - traceRaster: a PNG, vectorised (a logo cannot carry a bitmap)
* - buildLogoSvg: either one, placed on a square canvas the way the person
* positioned it, as the final document
*/
/** The ceiling the specification sets for the file. */
export const MAX_LOGO_BYTES = 32 * 1024;
/** The square the logo is drawn on. Only the ratio matters to a receiver. */
export const CANVAS = 512;
/** How much of the square the logo fills when it is first placed. */
const FILL = 0.8;
export type LogoErrorCode = "notXml" | "notSvg" | "noSize" | "empty" | "tooComplex";
export class BimiLogoError extends Error {
constructor(readonly code: LogoErrorCode) {
super(code);
}
}
/** Artwork reduced to drawing elements, with the size it was drawn at. */
export interface Artwork {
/** The drawing elements, serialised, without any <svg> wrapper. */
inner: string;
/** Where the drawing starts and how large it is, in its own units. */
minX: number;
minY: number;
width: number;
height: number;
/** Things that were dropped, for the person to know about. */
removed: string[];
/** Things kept that may not look the same everywhere ("text": it depends on fonts). */
warnings: string[];
}
// ------------------------------------------------------------ sanitizing
/** Elements kept: shapes, groups and what gradients need. */
const ALLOWED_ELEMENTS = new Set([
"g", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon",
"defs", "lineargradient", "radialgradient", "stop", "text", "tspan", "title", "desc",
]);
/** Dropped without comment: editor noise that draws nothing. */
const SILENT_ELEMENTS = new Set(["metadata", "namedview", "title", "desc"]);
/** Style declarations that become presentation attributes; the rest are dropped. */
const STYLE_PROPERTIES = new Set([
"fill", "fill-opacity", "fill-rule", "stroke", "stroke-width", "stroke-opacity",
"stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "opacity", "stop-color", "stop-opacity",
]);
const KEPT_ATTRIBUTES = new Set([
"d", "x", "y", "cx", "cy", "r", "rx", "ry", "x1", "y1", "x2", "y2", "width", "height", "points",
"transform", "id", "offset", "gradientunits", "gradienttransform", "spreadmethod", "fx", "fy",
"font-family", "font-size", "font-weight", "text-anchor", "dx", "dy",
...STYLE_PROPERTIES,
]);
function localName(node: Element): string {
return node.localName.toLowerCase();
}
/** Reads "fill:#000;stroke:none" into presentation attributes. */
function applyStyle(el: Element): void {
const style = el.getAttribute("style");
el.removeAttribute("style");
if (!style) return;
for (const declaration of style.split(";")) {
const [rawName, ...rest] = declaration.split(":");
const name = rawName?.trim().toLowerCase();
const value = rest.join(":").trim();
// A url() in a style value would fetch something; only plain values pass
if (name && STYLE_PROPERTIES.has(name) && value && !/url\s*\(\s*(?!#)/i.test(value)) {
el.setAttribute(name, value);
}
}
}
function cleanElement(el: Element, removed: Set<string>): void {
applyStyle(el);
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
// Event handlers, classes, editor namespaces (inkscape:, sodipodi:), links
// out of the document: none of them draw anything, and some are active
if (!KEPT_ATTRIBUTES.has(name)) {
el.removeAttribute(attr.name);
continue;
}
// fill="url(#id)" is how a gradient is used; anything else with url() is out
if (/url\s*\(\s*(?!#)/i.test(attr.value)) el.removeAttribute(attr.name);
}
for (const child of Array.from(el.childNodes)) {
if (child.nodeType === 1) {
const element = child as Element;
const name = localName(element);
if (!ALLOWED_ELEMENTS.has(name)) {
if (!SILENT_ELEMENTS.has(name) && !name.includes(":")) removed.add(name);
el.removeChild(element);
continue;
}
// Titles and descriptions inside the drawing are dropped; the logo gets one title of its own
if (name === "title" || name === "desc") {
el.removeChild(element);
continue;
}
cleanElement(element, removed);
} else if (child.nodeType !== 3) {
// Comments, processing instructions, CDATA
el.removeChild(child);
}
}
}
/**
* An anchor wraps drawing: keep what is inside it, lose the link. Done before
* the cleaning pass, so what comes out of an anchor is cleaned like the rest.
*/
function unwrapAnchors(root: Element, removed: Set<string>): void {
for (const anchor of Array.from(root.getElementsByTagName("a"))) {
removed.add("a (link)");
const parent = anchor.parentNode;
if (!parent) continue;
while (anchor.firstChild) parent.insertBefore(anchor.firstChild, anchor);
parent.removeChild(anchor);
}
}
function numberOf(value: string | null): number | null {
if (!value) return null;
const n = parseFloat(value);
return Number.isFinite(n) && n > 0 ? n : null;
}
/**
* Reduces an uploaded SVG to its drawing. Throws BimiLogoError when it is not
* something that can be placed (not XML, not SVG, no size to place it by).
*/
export function sanitizeSvg(source: string): Artwork {
const doc = new DOMParser().parseFromString(source, "image/svg+xml");
if (doc.getElementsByTagName("parsererror").length > 0) throw new BimiLogoError("notXml");
const root = doc.documentElement;
if (!root || localName(root) !== "svg") throw new BimiLogoError("notSvg");
let minX = 0;
let minY = 0;
let width: number | null = null;
let height: number | null = null;
const viewBox = (root.getAttribute("viewBox") ?? "")
.trim()
.split(/[\s,]+/)
.map(Number);
if (viewBox.length === 4 && viewBox.every(Number.isFinite) && viewBox[2] > 0 && viewBox[3] > 0) {
[minX, minY, width, height] = viewBox;
} else {
width = numberOf(root.getAttribute("width"));
height = numberOf(root.getAttribute("height"));
}
if (!width || !height) throw new BimiLogoError("noSize");
const removed = new Set<string>();
unwrapAnchors(root, removed);
cleanElement(root, removed);
// Serialised whole and unwrapped, so the children carry no stray namespace
const serialised = new XMLSerializer().serializeToString(root);
const inner = serialised
.replace(/^<svg\b[^>]*>/i, "")
.replace(/<\/svg>\s*$/i, "")
.replace(/\s+xmlns(:\w+)?="[^"]*"/g, "")
.trim();
if (!inner || !/<(path|rect|circle|ellipse|line|polyline|polygon|text)\b/i.test(inner)) {
throw new BimiLogoError("empty");
}
// Text is drawn with whatever font the receiver has, not the one it was made in
const warnings = /<text\b/i.test(inner) ? ["text"] : [];
return { inner, minX, minY, width, height, removed: Array.from(removed).sort(), warnings };
}
// ------------------------------------------------------------- tracing
export type TracePreset = "high" | "balanced" | "compact" | "tiny";
/** From most faithful to smallest. A logo that does not fit the limit walks down this list. */
export const TRACE_PRESETS: TracePreset[] = ["high", "balanced", "compact", "tiny"];
const PRESET_OPTIONS: Record<TracePreset, Record<string, number>> = {
high: { numberofcolors: 24, pathomit: 4, ltres: 1, qtres: 1, roundcoords: 1, colorsampling: 2 },
balanced: { numberofcolors: 12, pathomit: 8, ltres: 1, qtres: 1, roundcoords: 1, colorsampling: 2 },
compact: { numberofcolors: 6, pathomit: 16, ltres: 1.5, qtres: 1.5, roundcoords: 0, colorsampling: 2 },
tiny: { numberofcolors: 3, pathomit: 32, ltres: 2, qtres: 2, roundcoords: 0, colorsampling: 2 },
};
/** Pixels, the same shape ImageData has, so tests need no canvas. */
export interface Raster {
width: number;
height: number;
data: Uint8ClampedArray;
}
interface Tracer {
imagedataToSVG(raster: Raster, options: Record<string, number>): string;
}
/** rgb(1, 2, 3) to #010203, which is shorter and what the validator likes best. */
function hex(rgb: string): string {
const m = /rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i.exec(rgb);
if (!m) return rgb;
return "#" + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, "0")).join("");
}
/**
* Vectorises pixels. The result is the drawing only: the tracer's own wrapper
* (and the comment it adds) is discarded, and fully transparent paths, which
* are the empty background, are dropped.
*/
export function traceRaster(raster: Raster, preset: TracePreset, tracer: Tracer): Artwork {
const svg = tracer.imagedataToSVG(raster, { ...PRESET_OPTIONS[preset], strokewidth: 0, viewbox: 0, scale: 1 });
const paths: string[] = [];
for (const match of svg.matchAll(/<path\b[^>]*\/>/g)) {
const tag = match[0];
const opacity = /\bopacity="([\d.]+)"/.exec(tag)?.[1];
if (opacity !== undefined && Number(opacity) === 0) continue;
const fill = /\bfill="([^"]+)"/.exec(tag)?.[1] ?? "#000000";
const d = /\bd="([^"]+)"/.exec(tag)?.[1];
if (!d) continue;
const alpha = opacity !== undefined && Number(opacity) < 1 ? ` fill-opacity="${opacity}"` : "";
paths.push(`<path fill="${hex(fill)}"${alpha} d="${d}"/>`);
}
if (paths.length === 0) throw new BimiLogoError("empty");
return { inner: paths.join(""), minX: 0, minY: 0, width: raster.width, height: raster.height, removed: [], warnings: [] };
}
// ------------------------------------------------------------- building
export interface Layout {
/** 1 fills 80% of the square; larger zooms in, smaller leaves more margin. */
zoom: number;
/** Offset from centred, as a fraction of the square. */
dx: number;
dy: number;
}
export const DEFAULT_LAYOUT: Layout = { zoom: 1, dx: 0, dy: 0 };
export function clampLayout(layout: Layout): Layout {
const zoom = Math.min(4, Math.max(0.2, layout.zoom));
// The logo may be pushed until its centre reaches an edge, no further
return { zoom, dx: Math.min(0.5, Math.max(-0.5, layout.dx)), dy: Math.min(0.5, Math.max(-0.5, layout.dy)) };
}
const round = (n: number) => Math.round(n * 1000) / 1000;
function escapeXml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
export function isHexColor(value: string): boolean {
return /^#[0-9a-f]{6}$/i.test(value);
}
/**
* The final document: the artwork on a solid square, placed as laid out.
*
* The background is solid because a receiver draws the logo on its own
* coloured surface and in its own mask (a circle in Gmail), and a transparent
* mark disappears into half of them.
*/
export function buildLogoSvg(art: Artwork, layout: Layout, title: string, background: string): string {
const { zoom, dx, dy } = clampLayout(layout);
const fill = isHexColor(background) ? background.toLowerCase() : "#ffffff";
const scale = ((CANVAS * FILL) / Math.max(art.width, art.height)) * zoom;
const tx = (CANVAS - art.width * scale) / 2 + dx * CANVAS - art.minX * scale;
const ty = (CANVAS - art.height * scale) / 2 + dy * CANVAS - art.minY * scale;
return (
`<?xml version="1.0" encoding="UTF-8"?>\n` +
`<svg xmlns="http://www.w3.org/2000/svg" version="1.2" baseProfile="tiny-ps" viewBox="0 0 ${CANVAS} ${CANVAS}">` +
`<title>${escapeXml(title.trim() || "Logo")}</title>` +
`<rect width="${CANVAS}" height="${CANVAS}" fill="${fill}"/>` +
`<g transform="matrix(${round(scale)} 0 0 ${round(scale)} ${round(tx)} ${round(ty)})">${art.inner}</g>` +
`</svg>\n`
);
}
/** Bytes the file will occupy, as the server counts them. */
export function byteLength(svg: string): number {
return new TextEncoder().encode(svg).length;
}