diff --git a/src/js/AvmUtils.js b/src/js/AvmUtils.js
new file mode 100644
index 00000000..c4c29832
--- /dev/null
+++ b/src/js/AvmUtils.js
@@ -0,0 +1,313 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+// Copyright 2013 - UDS/CNRS
+
+function crc32(bytes) {
+ let crc = 0xffffffff;
+
+ for (let i = 0; i < bytes.length; i++) {
+ crc ^= bytes[i];
+
+ for (let j = 0; j < 8; j++) {
+ const mask = -(crc & 1);
+ crc = (crc >>> 1) ^ (0xedb88320 & mask);
+ }
+ }
+
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+function u32be(n) {
+ return new Uint8Array([
+ (n >>> 24) & 0xff,
+ (n >>> 16) & 0xff,
+ (n >>> 8) & 0xff,
+ n & 0xff,
+ ]);
+}
+
+function concatUint8Arrays(arrays) {
+ const total = arrays.reduce((sum, array) => sum + array.length, 0);
+ const out = new Uint8Array(total);
+ let offset = 0;
+
+ for (const array of arrays) {
+ out.set(array, offset);
+ offset += array.length;
+ }
+
+ return out;
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function renderAvmValue(value) {
+ if (Array.isArray(value)) {
+ return [
+ "",
+ ...value.map((item) => ` ${escapeXml(item)}`),
+ "",
+ ].join("\n");
+ }
+
+ return escapeXml(value);
+}
+
+function serializeFitsHeaderValue(value) {
+ if (typeof value === "string") {
+ return `'${value}'`;
+ }
+
+ if (typeof value === "boolean") {
+ return value ? "T" : "F";
+ }
+
+ return `${value}`;
+}
+
+function buildFitsHeaderFromWcs(wcs) {
+ const orderedKeys = [
+ "NAXIS",
+ "NAXIS1",
+ "NAXIS2",
+ "CTYPE1",
+ "CTYPE2",
+ "CRPIX1",
+ "CRPIX2",
+ "CRVAL1",
+ "CRVAL2",
+ "CUNIT1",
+ "CUNIT2",
+ "CD1_1",
+ "CD1_2",
+ "CD2_1",
+ "CD2_2",
+ "PC1_1",
+ "PC1_2",
+ "PC2_1",
+ "PC2_2",
+ "CDELT1",
+ "CDELT2",
+ "CROTA2",
+ "LONPOLE",
+ "LATPOLE",
+ "EQUINOX",
+ "RADESYS",
+ ];
+
+ return orderedKeys
+ .filter((key) => wcs[key] !== undefined && wcs[key] !== null)
+ .map((key) => `${key.padEnd(8, " ")}= ${serializeFitsHeaderValue(wcs[key])}`)
+ .join("\n");
+}
+
+function getCoordinateFrameFromWcs(wcs) {
+ const ctype1 = `${wcs.CTYPE1 || ""}`.trim().toUpperCase();
+ const ctype2 = `${wcs.CTYPE2 || ""}`.trim().toUpperCase();
+ const radesys = `${wcs.RADESYS || ""}`.trim().toUpperCase();
+
+ if (ctype1.startsWith("GLON-") && ctype2.startsWith("GLAT-")) {
+ return "GAL";
+ }
+
+ if (ctype1.startsWith("RA---") && ctype2.startsWith("DEC--")) {
+ return radesys || "ICRS";
+ }
+
+ return null;
+}
+
+function getProjectionFromWcs(wcs) {
+ const ctype1 = `${wcs.CTYPE1 || ""}`.trim().toUpperCase();
+ const projection = ctype1.slice(-3);
+
+ return /^[A-Z0-9]{3}$/.test(projection) ? projection : null;
+}
+
+export function buildAvmFromWcs(wcs, options = {}) {
+ if (!wcs || typeof wcs !== "object") {
+ return null;
+ }
+
+ const coordinateFrame = getCoordinateFrameFromWcs(wcs);
+ const projection = getProjectionFromWcs(wcs);
+
+ if (!coordinateFrame || !projection) {
+ return null;
+ }
+
+ const avm = {
+ "MetadataVersion": "1.2",
+ "Spatial.CoordinateFrame": coordinateFrame,
+ "Spatial.ReferenceValue": [wcs.CRVAL1, wcs.CRVAL2],
+ "Spatial.ReferenceDimension": [wcs.NAXIS1, wcs.NAXIS2],
+ "Spatial.ReferencePixel": [wcs.CRPIX1, wcs.CRPIX2],
+ "Spatial.CoordsystemProjection": projection,
+ "Spatial.FITSheader": buildFitsHeaderFromWcs(wcs),
+ };
+
+ if (wcs.EQUINOX !== undefined && wcs.EQUINOX !== null) {
+ avm["Spatial.Equinox"] = wcs.EQUINOX;
+ }
+
+ if (
+ wcs.CD1_1 !== undefined &&
+ wcs.CD1_2 !== undefined &&
+ wcs.CD2_1 !== undefined &&
+ wcs.CD2_2 !== undefined
+ ) {
+ avm["Spatial.CDMatrix"] = [wcs.CD1_1, wcs.CD1_2, wcs.CD2_1, wcs.CD2_2];
+ } else if (wcs.CDELT1 !== undefined && wcs.CDELT2 !== undefined) {
+ avm["Spatial.Scale"] = [wcs.CDELT1, wcs.CDELT2];
+
+ const rotation = options.rotation ?? wcs.CROTA2;
+ if (rotation !== undefined && rotation !== null) {
+ avm["Spatial.Rotation"] = rotation;
+ }
+ }
+
+ return avm;
+}
+
+export function buildAvmXmpPacket(avm) {
+ const entries = Object.entries(avm)
+ .map(([key, value]) => ` ${renderAvmValue(value)}`)
+ .join("\n");
+
+ return [
+ ``,
+ '',
+ ' ',
+ ' ',
+ entries,
+ ' ',
+ ' ',
+ '',
+ '',
+ ].join("\n");
+}
+
+function makePngChunk(type4, data) {
+ const encoder = new TextEncoder();
+ const type = encoder.encode(type4);
+
+ if (type.length !== 4) {
+ throw new Error("PNG chunk type must be 4 bytes");
+ }
+
+ const crcInput = concatUint8Arrays([type, data]);
+ const crc = crc32(crcInput);
+
+ return concatUint8Arrays([
+ u32be(data.length),
+ type,
+ data,
+ u32be(crc),
+ ]);
+}
+
+function makeXmpITXtChunk(xmpString) {
+ const encoder = new TextEncoder();
+
+ const keyword = encoder.encode("XML:com.adobe.xmp");
+ const compressionFlag = new Uint8Array([0]);
+ const compressionMethod = new Uint8Array([0]);
+ const languageTag = new Uint8Array([0]);
+ const translatedKeyword = new Uint8Array([0]);
+ const text = encoder.encode(xmpString);
+
+ const data = concatUint8Arrays([
+ keyword,
+ new Uint8Array([0]),
+ compressionFlag,
+ compressionMethod,
+ languageTag,
+ translatedKeyword,
+ text,
+ ]);
+
+ return makePngChunk("iTXt", data);
+}
+
+export function insertXmpIntoPng(pngBytes, xmpString) {
+ if (!(pngBytes instanceof Uint8Array)) {
+ throw new Error("pngBytes must be a Uint8Array");
+ }
+
+ const pngSignature = new Uint8Array([
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
+ ]);
+
+ for (let i = 0; i < 8; i++) {
+ if (pngBytes[i] !== pngSignature[i]) {
+ throw new Error("Not a valid PNG");
+ }
+ }
+
+ const xmpChunk = makeXmpITXtChunk(xmpString);
+ let pos = 8;
+ let insertPos = -1;
+
+ while (pos + 12 <= pngBytes.length) {
+ const length =
+ (pngBytes[pos] << 24) |
+ (pngBytes[pos + 1] << 16) |
+ (pngBytes[pos + 2] << 8) |
+ pngBytes[pos + 3];
+
+ const type = String.fromCharCode(
+ pngBytes[pos + 4],
+ pngBytes[pos + 5],
+ pngBytes[pos + 6],
+ pngBytes[pos + 7],
+ );
+
+ const chunkEnd = pos + 12 + length;
+ if (chunkEnd > pngBytes.length) {
+ throw new Error("Corrupt PNG");
+ }
+
+ if (type === "IDAT" || type === "IEND") {
+ insertPos = pos;
+ break;
+ }
+
+ pos = chunkEnd;
+ }
+
+ if (insertPos === -1) {
+ throw new Error("Could not find insertion point in PNG");
+ }
+
+ return concatUint8Arrays([
+ pngBytes.subarray(0, insertPos),
+ xmpChunk,
+ pngBytes.subarray(insertPos),
+ ]);
+}
+
+export async function injectAvmIntoPngBlob(blob, avm) {
+ const pngBytes = new Uint8Array(await blob.arrayBuffer());
+ const xmp = buildAvmXmpPacket(avm);
+ const outBytes = insertXmpIntoPng(pngBytes, xmp);
+
+ return new Blob([outBytes], { type: "image/png" });
+}
+
+export function blobToDataURL(blob) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onloadend = () => resolve(reader.result);
+ reader.onerror = () => reject(new Error("Error reading blob as data URL"));
+ reader.readAsDataURL(blob);
+ });
+}
+
+export function blobToArrayBuffer(blob) {
+ return blob.arrayBuffer();
+}
diff --git a/src/js/View.js b/src/js/View.js
index 5f9ea7b5..35956f0a 100644
--- a/src/js/View.js
+++ b/src/js/View.js
@@ -50,8 +50,55 @@ import { Color } from "./Color.js";
import { SpectraDisplayer } from "./SpectraDisplayer.js";
import { DefaultActionsForContextMenu } from "./DefaultActionsForContextMenu.js";
import { Source } from "./Source.js";
+import { blobToArrayBuffer, blobToDataURL, buildAvmFromWcs, injectAvmIntoPngBlob } from "./AvmUtils.js";
export let View = (function () {
+ const normalizeImageType = function (imgType) {
+ return imgType || "image/png";
+ };
+
+ const canvasToBlob = function (canvas, imgType) {
+ return new Promise((resolve, reject) => {
+ canvas.toBlob((blob) => {
+ if (blob) {
+ resolve(blob);
+ } else {
+ reject(new Error("Canvas toBlob failed"));
+ }
+ }, imgType);
+ });
+ };
+
+ const exportCanvasBlob = async function (view, canvas, imgType) {
+ const effectiveImgType = normalizeImageType(imgType);
+ let blob = await canvasToBlob(canvas, effectiveImgType);
+
+ if (effectiveImgType !== "image/png") {
+ return blob;
+ }
+
+ const wcs = view.aladin.getViewWCS();
+ if (!wcs || typeof wcs !== "object") {
+ return blob;
+ }
+
+ const avm = buildAvmFromWcs(wcs, {
+ rotation: view.aladin.getRotation(),
+ });
+
+ if (!avm) {
+ return blob;
+ }
+
+ try {
+ blob = await injectAvmIntoPngBlob(blob, avm);
+ } catch (error) {
+ console.warn("Could not inject AVM metadata into PNG export", error);
+ }
+
+ return blob;
+ };
+
/** Constructor */
function View(aladin) {
this.aladin = aladin;
@@ -557,20 +604,16 @@ export let View = (function () {
*/
View.prototype.getCanvasDataURL = async function (imgType, width, height, withLogo=true) {
const c = await this.getCanvas(width, height, withLogo);
- return c.toDataURL(imgType);
+ const blob = await exportCanvasBlob(this, c, imgType);
+ return blobToDataURL(blob);
};
/**
* Return ArrayBuffer corresponding to the current view
*/
View.prototype.getCanvasArrayBuffer = async function (imgType, width, height, withLogo=true) {
- return this.getCanvasBlob(imgType, width, height, withLogo)
- .then((blob) => {
- const reader = new FileReader();
- reader.onloadend = () => resolve(reader.result);
- reader.onerror = () => reject(new Error('Error reading blob as ArrayBuffer'));
- reader.readAsArrayBuffer(blob);
- });
+ const blob = await this.getCanvasBlob(imgType, width, height, withLogo);
+ return blobToArrayBuffer(blob);
}
/**
@@ -578,15 +621,7 @@ export let View = (function () {
*/
View.prototype.getCanvasBlob = async function (imgType, width, height, withLogo=true) {
const c = await this.getCanvas(width, height, withLogo);
- return new Promise((resolve, reject) => {
- c.toBlob(blob => {
- if (blob) {
- resolve(blob);
- } else {
- reject(new Error('Canvas toBlob failed'));
- }
- }, imgType);
- });
+ return exportCanvasBlob(this, c, imgType);
}
View.prototype.selectLayer = function (layer) {