SvgExporter.java

package org.fresnel.optics;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Locale;

/**
 * SVG export.
 *
 * <p>Two strategies:
 * <ul>
 *   <li>{@link #writeSvgZonePlate(SingleZonePlateParameters, OutputStream)} — true vector
 *       concentric rings for an on-axis circular zone plate (zone radii are
 *       {@code r_n = √(n·λ·f + (n·λ/2)²)} ≈ {@code √(n·λ·f)}). Plotter-friendly,
 *       small file size, scale-independent.</li>
 *   <li>{@link #writeSvgRaster(RenderResult, double, OutputStream)} — wraps any raster
 *       as a base64 PNG inside an SVG {@code <image>} element with explicit
 *       physical {@code width}/{@code height} in millimetres. Works for arbitrary
 *       designs (off-axis, hex macro cell, window foil, hologram).</li>
 * </ul>
 */
public final class SvgExporter {

    private SvgExporter() {}

    /**
     * True-vector SVG of an on-axis circular zone plate. Off-axis parameters are
     * ignored — for off-axis, use {@link #writeSvgRaster(RenderResult, double, OutputStream)}.
     */
    public static void writeSvgZonePlate(SingleZonePlateParameters p, OutputStream out) throws IOException {
        double D = p.apertureDiameterMm();
        double f = p.focalLengthMm();
        double lambdaMm = Units.nmToMm(p.wavelengthNm());
        double R = D / 2.0;
        // Number of zones n_max = R² / (λ·f)
        int nMax = (int) Math.floor((R * R) / (lambdaMm * f));
        boolean positive = p.polarity() == Polarity.POSITIVE;
        try (Writer w = new OutputStreamWriter(out, StandardCharsets.UTF_8)) {
            w.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
            w.write(String.format(Locale.ROOT,
                    "<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\" "
                            + "width=\"%.4fmm\" height=\"%.4fmm\" viewBox=\"%.4f %.4f %.4f %.4f\">\n",
                    D, D, -R, -R, D, D));
            // Background = aperture-clipping circle filled with the OFF colour.
            String onFill  = positive ? "#ffffff" : "#000000";
            String offFill = positive ? "#000000" : "#ffffff";
            w.write(String.format(Locale.ROOT,
                    "  <circle cx=\"0\" cy=\"0\" r=\"%.6f\" fill=\"%s\"/>\n", R, offFill));
            // Zones n = 1..nMax. Zone n is bounded by r_{n-1} and r_n. ON for odd n in positive polarity.
            for (int n = nMax; n >= 1; n--) {
                double rn = Math.sqrt(n * lambdaMm * f);
                if (rn > R) continue;
                boolean on = (n % 2) == 1;          // first zone (n=1) is ON for positive polarity
                String fill = on ? onFill : offFill;
                w.write(String.format(Locale.ROOT,
                        "  <circle cx=\"0\" cy=\"0\" r=\"%.6f\" fill=\"%s\"/>\n", rn, fill));
            }
            w.write("</svg>\n");
        }
    }

    public static byte[] toSvgZonePlateBytes(SingleZonePlateParameters p) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        writeSvgZonePlate(p, baos);
        return baos.toByteArray();
    }

    /**
     * Embed a raster {@link RenderResult} inside an SVG {@code <image>} as a
     * base64-encoded PNG, sized to its physical extent in millimetres.
     */
    public static void writeSvgRaster(RenderResult r, double dpi, OutputStream out) throws IOException {
        BufferedImage img = r.image();
        ByteArrayOutputStream png = new ByteArrayOutputStream();
        // Use plain PNG (without DPI metadata — the SVG provides physical size).
        ImageIO.write(img, "png", png);
        String b64 = Base64.getEncoder().encodeToString(png.toByteArray());
        double wMm = r.widthMm();
        double hMm = r.heightMm();
        try (Writer w = new OutputStreamWriter(out, StandardCharsets.UTF_8)) {
            w.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
            w.write(String.format(Locale.ROOT,
                    "<svg xmlns=\"http://www.w3.org/2000/svg\" "
                            + "xmlns:xlink=\"http://www.w3.org/1999/xlink\" version=\"1.1\" "
                            + "width=\"%.4fmm\" height=\"%.4fmm\" viewBox=\"0 0 %d %d\">\n",
                    wMm, hMm, img.getWidth(), img.getHeight()));
            w.write(String.format(Locale.ROOT,
                    "  <image x=\"0\" y=\"0\" width=\"%d\" height=\"%d\" "
                            + "image-rendering=\"pixelated\" "
                            + "xlink:href=\"data:image/png;base64,%s\"/>\n",
                    img.getWidth(), img.getHeight(), b64));
            w.write(String.format(Locale.ROOT, "  <!-- DPI: %.2f -->\n", dpi));
            w.write("</svg>\n");
        }
    }

    public static byte[] toSvgRasterBytes(RenderResult r, double dpi) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        writeSvgRaster(r, dpi, baos);
        return baos.toByteArray();
    }
}