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:
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
@@ -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;
|
||||
}
|
||||
@@ -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 & <B> "C"</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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user