import { render } from "./render.js";
/* =========================
EXPORT SVG
========================= */
/**
* @function exportSVG
* @description Exports a geoviz SVG as a downloadable file.
* @param {Array} [options.order=[]] - Layer ordering (mainly for Observable environments).
* @param {string} [options.filename="map.svg"] - Output file name.
* @param {"web"|"portable"} [options.fontMode="web"] -
* Font handling strategy:
* - "web": embeds fonts using @font-face (base64 or data URLs). Best for browsers.
* - "portable": removes embedded fonts and relies on system-installed fonts.
* Best for Inkscape / Illustrator compatibility.
*
* @example
* viz.exportSVG(svg)
*
* @example
* viz.exportSVG(svg, { filename: "map.svg", fontMode: "portable" })
*/
export function exportSVG(
svg,
{ order = [], filename = "map.svg", fontMode = "web" } = {},
) {
const NS = "http://www.w3.org/2000/svg";
const XLINK = "http://www.w3.org/1999/xlink";
const INK = "http://www.inkscape.org/namespaces/inkscape";
const SODIPODI = "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd";
const ADOBE = "http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/";
const svgNode = render(svg, { order });
const clone = svgNode.cloneNode(true);
clone.removeAttribute("id");
clone.setAttribute("xmlns", NS);
clone.setAttribute("xmlns:xlink", XLINK);
clone.setAttribute("xmlns:inkscape", INK);
clone.setAttribute("xmlns:sodipodi", SODIPODI);
clone.setAttribute("xmlns:layer", ADOBE);
// 👉 FONT MODE
if (fontMode === "web") {
embedFontsFromRegistry(clone);
} else {
stripFontEmbeds(clone);
}
clone.querySelectorAll("g[data-layer]").forEach((g) => {
const layerName = g.getAttribute("data-layer") || "layer";
g.setAttribute("id", layerName);
g.setAttributeNS(INK, "groupmode", "layer");
g.setAttributeNS(INK, "label", layerName);
g.setAttributeNS(SODIPODI, "role", "layer");
g.setAttributeNS(ADOBE, "name", layerName);
g.removeAttribute("data-layer");
});
const tooltipLayer = clone.querySelector("g#geoviztooltip");
if (tooltipLayer) tooltipLayer.remove();
const svgString = new XMLSerializer().serializeToString(clone);
downloadBlob(
new Blob([svgString], { type: "image/svg+xml;charset=utf-8" }),
filename,
);
}
/* =========================
EXPORT PNG
========================= */
/**
* @function exportPNG
* @description Exports a geoviz SVG as a high-resolution PNG image.
*
* The PNG is rendered using the browser canvas engine, ensuring visual fidelity
* with the web rendering context.
*
* @param {string} [options.filename="map.png"] - Output file name.
* @param {number} [options.scale=3] - Resolution multiplier (2 = HD, 3 = print quality).
*
* @example
* viz.exportPNG(svg)
*
* @example
* viz.exportPNG(svg, { scale: 4, filename: "map.png" })
*/
export async function exportPNG(svg, { filename = "map.png", scale = 3 } = {}) {
const svgNode = render(svg);
const clone = svgNode.cloneNode(true);
// PNG = web mode (fonts must exist)
embedFontsFromRegistry(clone);
let width = parseFloat(clone.getAttribute("width"));
let height = parseFloat(clone.getAttribute("height"));
if (!width || !height) {
try {
const bbox = clone.getBBox?.() || { width: 800, height: 600 };
width = bbox.width;
height = bbox.height;
} catch {
width = 800;
height = 600;
}
}
if (!clone.hasAttribute("viewBox")) {
clone.setAttribute("viewBox", `0 0 ${width} ${height}`);
}
const svgString = new XMLSerializer().serializeToString(clone);
const svgBlob = new Blob([svgString], {
type: "image/svg+xml;charset=utf-8",
});
const url = URL.createObjectURL(svgBlob);
const img = new Image();
img.crossOrigin = "anonymous";
if (document.fonts) {
await document.fonts.ready;
}
return new Promise((resolve, reject) => {
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = width * scale;
canvas.height = height * scale;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "white";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
if (!blob) {
reject(new Error("PNG conversion failed"));
return;
}
downloadBlob(blob, filename);
URL.revokeObjectURL(url);
resolve();
}, "image/png");
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("SVG image load error"));
};
img.src = url;
});
}
/* =========================
HELPERS
========================= */
function embedFontsFromRegistry(svg) {
const fonts = window.__geoviz_fonts__ || [];
if (!fonts.length) return;
let defs = svg.querySelector("defs");
if (!defs) {
defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
svg.insertBefore(defs, svg.firstChild);
}
const style = document.createElementNS("http://www.w3.org/2000/svg", "style");
let css = "";
for (const f of fonts) {
if (!f.base64) continue;
css += `
@font-face {
font-family: '${f.fontFamily}';
src: url(data:font/${f.format};base64,${f.base64}) format('${f.format}');
font-style: ${f.style || "normal"};
font-weight: ${f.weight || "normal"};
}
`;
}
style.textContent = css;
defs.appendChild(style);
}
/* 👉 MODE INKSCAPE SAFE */
function stripFontEmbeds(svg) {
const defs = svg.querySelector("defs");
if (!defs) return;
defs.querySelectorAll("style").forEach((s) => {
if (s.textContent.includes("@font-face")) {
s.remove();
}
});
}
/* =========================
DOWNLOAD
========================= */
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}