SvgFont.java

package io.brunoborges.jairosvg.dom;

import java.awt.geom.AffineTransform;
import java.awt.geom.GeneralPath;
import java.util.LinkedHashMap;
import java.util.Map;

import io.brunoborges.jairosvg.draw.PathDrawer;
import io.brunoborges.jairosvg.surface.Surface;

/**
 * Represents a parsed SVG font with its glyphs. Supports {@code <font>},
 * {@code <font-face>}, {@code <glyph>}, and {@code <missing-glyph>} elements.
 *
 * @see <a href="https://www.w3.org/TR/SVG11/fonts.html">SVG 1.1 — Fonts</a>
 */
public final class SvgFont {

    /** Font family name from {@code <font-face font-family="...">}. */
    public final String family;

    /** Default horizontal advance from {@code <font horiz-adv-x="...">}. */
    final double defaultHorizAdvX;

    /**
     * Units per em from {@code <font-face units-per-em="...">}. Defaults to 1000.
     */
    final double unitsPerEm;

    /** Ascent from {@code <font-face ascent="...">}. */
    final double ascent;

    /** Descent from {@code <font-face descent="...">}. */
    final double descent;

    /** Glyph map: Unicode character(s) → Glyph data. */
    final Map<String, Glyph> glyphs;

    /** Missing glyph (fallback). */
    final Glyph missingGlyph;

    /** Maximum length of any unicode key in the glyph map (for greedy matching). */
    final int maxUnicodeLen;

    SvgFont(String family, double defaultHorizAdvX, double unitsPerEm, double ascent, double descent,
            Map<String, Glyph> glyphs, Glyph missingGlyph) {
        this.family = family;
        this.defaultHorizAdvX = defaultHorizAdvX;
        this.unitsPerEm = unitsPerEm;
        this.ascent = ascent;
        this.descent = descent;
        this.glyphs = glyphs;
        this.missingGlyph = missingGlyph;
        this.maxUnicodeLen = glyphs.keySet().stream().mapToInt(String::length).max().orElse(1);
    }

    /** A single glyph definition with a pre-parsed cached path. */
    public record Glyph(GeneralPath cachedPath, double horizAdvX) {
    }

    /**
     * Parse an SVG font from a {@code <font>} node.
     */
    public static SvgFont parse(Node fontNode) {
        double defaultHorizAdvX = parseDouble(fontNode.get("horiz-adv-x"), 1000);

        // Find <font-face>
        String family = null;
        double unitsPerEm = 1000;
        double ascent = 800;
        double descent = -200;

        for (Node child : fontNode.children) {
            if ("font-face".equals(child.tag)) {
                family = child.get("font-family");
                unitsPerEm = parseDouble(child.get("units-per-em"), 1000);
                ascent = parseDouble(child.get("ascent"), unitsPerEm * 0.8);
                descent = parseDouble(child.get("descent"), -unitsPerEm * 0.2);
                break;
            }
        }

        if (family == null) {
            // Try the font node's id as fallback family name
            family = fontNode.get("id");
        }
        if (family == null)
            return null;

        // Parse glyphs — path data is parsed once and cached in the Glyph record
        Map<String, Glyph> glyphs = new LinkedHashMap<>();
        Glyph missingGlyph = null;

        for (Node child : fontNode.children) {
            if ("glyph".equals(child.tag)) {
                String unicode = child.get("unicode");
                if (unicode == null || unicode.isEmpty())
                    continue;
                String d = child.get("d", "");
                double advX = parseDouble(child.get("horiz-adv-x"), defaultHorizAdvX);
                glyphs.put(unicode, new Glyph(parsePathData(d), advX));
            } else if ("missing-glyph".equals(child.tag)) {
                String d = child.get("d", "");
                double advX = parseDouble(child.get("horiz-adv-x"), defaultHorizAdvX);
                missingGlyph = new Glyph(parsePathData(d), advX);
            }
        }

        return new SvgFont(family, defaultHorizAdvX, unitsPerEm, ascent, descent, glyphs, missingGlyph);
    }

    /**
     * Build a GeneralPath for a glyph, scaled to the given font size. SVG font
     * glyphs are defined with y-axis pointing up (origin at baseline), so we flip
     * vertically around the baseline. Uses the pre-parsed cached path from the
     * Glyph record.
     */
    public GeneralPath buildGlyphPath(Glyph glyph, double fontSize, double xOffset, double yOffset) {
        if (glyph == null || glyph.cachedPath() == null)
            return null;

        double scale = fontSize / unitsPerEm;

        // SVG fonts: y=0 is baseline, positive y is up.
        // Screen: y increases downward, yOffset is the baseline.
        // Flip y-axis and scale to font size.
        AffineTransform transform = new AffineTransform();
        transform.translate(xOffset, yOffset);
        transform.scale(scale, -scale);

        GeneralPath result = new GeneralPath();
        result.append(glyph.cachedPath().getPathIterator(transform), false);
        return result;
    }

    /** Get the scaled horizontal advance for a glyph. */
    public double getAdvance(Glyph glyph, double fontSize) {
        double advX = glyph != null ? glyph.horizAdvX() : defaultHorizAdvX;
        return advX * fontSize / unitsPerEm;
    }

    /**
     * Result of a glyph lookup: the matched glyph and how many chars were consumed.
     */
    public record GlyphMatch(Glyph glyph, int charsConsumed) {
    }

    /**
     * Look up a glyph starting at {@code offset} in {@code text} using greedy
     * longest-match. Handles multi-character unicode values and supplementary
     * (non-BMP) code points.
     */
    public GlyphMatch getGlyph(String text, int offset) {
        // Try longest match first
        int remaining = text.length() - offset;
        for (int len = Math.min(maxUnicodeLen, remaining); len > 0; len--) {
            String candidate = text.substring(offset, offset + len);
            Glyph g = glyphs.get(candidate);
            if (g != null)
                return new GlyphMatch(g, len);
        }
        // No match — consume one code point and use missing glyph
        int cp = text.codePointAt(offset);
        int cpLen = Character.charCount(cp);
        return new GlyphMatch(missingGlyph, cpLen);
    }

    /**
     * Parse SVG path data string into a GeneralPath. Returns null for empty/blank
     * data.
     */
    private static GeneralPath parsePathData(String d) {
        if (d == null || d.isBlank())
            return null;
        try {
            // Create a minimal node with the path data and use PathDrawer
            Node tempNode = new Node();
            tempNode.tag = "path";
            tempNode.set("d", d);

            Surface tempSurface = new Surface();
            tempSurface.path = new GeneralPath();
            tempSurface.contextWidth = 1000;
            tempSurface.contextHeight = 1000;
            tempSurface.dpi = 96;
            tempSurface.fontSize = 12;

            PathDrawer.path(tempSurface, tempNode);
            return tempSurface.path;
        } catch (Exception e) {
            return null;
        }
    }

    private static double parseDouble(String s, double def) {
        if (s == null)
            return def;
        try {
            return Double.parseDouble(s);
        } catch (NumberFormatException e) {
            return def;
        }
    }
}