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>
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|