Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SVG text failing to render #759

Open
alpha0010 opened this issue Jun 7, 2021 · 4 comments
Open

SVG text failing to render #759

alpha0010 opened this issue Jun 7, 2021 · 4 comments

Comments

@alpha0010
Copy link

The attached svg (music generated by Verovio and transformed by rism-digital/verovio#332 (comment), since Macaw does not appear to handle symbol elements) renders correctly except that all text is missing.

Is Macaw supposed to handle tspan svg elements?

iosTransform6.svg.zip

@alpha0010
Copy link
Author

Determined this occurs when tspan elements are nested. If I flatten the hierarchy, then text displays.

@bretrbs
Copy link

bretrbs commented Jan 26, 2022

Did you ever translate that Python code into Swift? Looking to load these Verovio SVGs into Macaw and don’t want to reinvent the wheel.

@alpha0010
Copy link
Author

Happy to share. Here are the relevant snippets (may require edits to adapt to your project).

Swift (older implementation)
import KissXML

class VerovioRenderer {
    // [...]

    private func transformSvgSymbol(_ svg: String) -> String {
        let svgXml = try! XMLDocument(xmlString: svg, options: 0)

        var useConfigs: Dictionary<String, Set<String>> = [:]
        for node in try! svgXml.nodes(forXPath: "//*[local-name()=\"use\"]") {
            if node.kind == XMLElementKind,
               let elem = node as? XMLElement,
               let hrefAttr = elem.attribute(forName: "xlink:href"),
               let oldHref = hrefAttr.stringValue,
               let width = elem.attribute(forName: "width")?.stringValue?.trimmingCharacters(in: .letters),
               let height = elem.attribute(forName: "height")?.stringValue?.trimmingCharacters(in: .letters)
            {
                let elemConfig = "\(width)-\(height)"
                let newHref = "\(oldHref)-\(elemConfig)"
                hrefAttr.stringValue = newHref
                elem.removeAttribute(forName: "width")
                elem.removeAttribute(forName: "height")

                let symbolId = String(oldHref.dropFirst())
                if useConfigs[symbolId] == nil {
                    useConfigs[symbolId] = []
                }
                useConfigs[symbolId]!.insert(elemConfig)
            }
        }

        var defs: [XMLElement] = []
        let transformRe = try! NSRegularExpression(
            pattern: "scale\\((\\d+),\\s*(-?\\d+)\\)",
            options: []
        )
        let viewBoxRe = try! NSRegularExpression(
            pattern: "\\d+\\s+\\d+\\s+(\\d+)\\s+(\\d+)",
            options: []
        )
        for node in try! svgXml.nodes(forXPath: "//*[local-name()=\"symbol\"]") {
            if node.kind == XMLElementKind,
               let elem = node as? XMLElement,
               let symbolId = elem.attribute(forName: "id")?.stringValue,
               let viewBox = elem.attribute(forName: "viewBox")?.stringValue,
               let pathElem = elem.elements(forName: "path").first,
               let transform = pathElem.attribute(forName: "transform")?.stringValue,
               let coords = pathElem.attribute(forName: "d")?.stringValue,
               let parsedTransform = transformRe.firstMatch(in: transform, range: NSRange(transform.startIndex..., in: transform)),
               let parsedViewBox = viewBoxRe.firstMatch(in: viewBox, range: NSRange(viewBox.startIndex..., in: viewBox)),
               let viewBoxWidth = Double(getCaptureGroup(str: viewBox, match: parsedViewBox, index: 1)),
               let viewBoxHeight = Double(getCaptureGroup(str: viewBox, match: parsedViewBox, index: 2)),
               let transformX = Double(getCaptureGroup(str: transform, match: parsedTransform, index: 1)),
               let transformY = Double(getCaptureGroup(str: transform, match: parsedTransform, index: 2))
            {
                for elemConfig in useConfigs[symbolId] ?? [] {
                    let parsedConfig = elemConfig.split(separator: "-")
                    let width = Double(parsedConfig[0])!
                    let height = Double(parsedConfig[1])!

                    let newPathElem = XMLNode.element(withName: "path") as! XMLElement
                    newPathElem.addAttribute(withName: "id", stringValue: "\(symbolId)-\(elemConfig)")
                    newPathElem.addAttribute(withName: "transform", stringValue: "scale(\(transformX * width / viewBoxWidth),\(transformY * height / viewBoxHeight))")
                    newPathElem.addAttribute(withName: "d", stringValue: coords)
                    defs.append(newPathElem)
                }
            }
        }

        if let root = svgXml.rootElement(),
           let innerSvg = root.elements(forName: "svg").first,
           let viewBox = innerSvg.attribute(forName: "viewBox")?.stringValue
        {
            root.elements(forName: "defs").first?.setChildren(defs)
            root.addAttribute(withName: "viewBox", stringValue: viewBox)

            root.removeAttribute(forName: "width")
            root.removeAttribute(forName: "height")
        }

        // Remove nested tspan.
        for node in try! svgXml.nodes(forXPath: "//*[local-name()=\"text\"]") {
            if node.kind == XMLElementKind,
               let elem = node as? XMLElement
            {
                let textNodes = recurseGetTextNodes(elem, parentAttr: [:])
                if textNodes.count > 0 {
                    elem.setChildren(textNodes)
                }
            }
        }

        return removeInnerSvg(xml: svgXml.xmlString)
    }

    private func getCaptureGroup(str: String, match: NSTextCheckingResult, index: Int) -> String {
        if let range = Range(match.range(at: index), in: str) {
            return String(str[range])
        }
        return ""
    }

    private func recurseGetTextNodes(
        _ elem: XMLElement,
        parentAttr: Dictionary<String, String>
    ) -> [XMLNode] {
        let children = elem.elements(forName: "tspan")
        guard children.count > 0 else {
            if let elemText = elem.stringValue, !elemText.isEmpty {
                let textNode = XMLNode.element(withName: "tspan") as! XMLElement
                textNode.setChildren([XMLNode.text(withStringValue: elemText) as! XMLNode])
                for (attr, attrValue) in parentAttr {
                    textNode.addAttribute(withName: attr, stringValue: attrValue)
                }
                return [textNode]
            }
            return []
        }

        var output: [XMLNode] = []
        for child in children {
            let textNodes = recurseGetTextNodes(
                child,
                parentAttr: mergeTextAttributes(node: child, parentAttr: parentAttr)
            )
            output.append(contentsOf: textNodes)
        }
        return output
    }

    private func mergeTextAttributes(node: XMLElement, parentAttr: Dictionary<String, String>) -> Dictionary<String, String> {
        let knownAttrs = ["x", "y", "font-family", "font-size", "font-style", "text-anchor", "class"]
        var output: Dictionary<String, String> = [:]
        for attr in knownAttrs {
            if let attrValue = node.attribute(forName: attr)?.stringValue ?? parentAttr[attr] {
                output[attr] = attrValue
            }
        }
        return output
    }

    private func removeInnerSvg(xml input: String) -> String {
        var xml = input

        // For some reason, the XML library crashes when trying to detach
        // nodes to push up in the hierarchy. Promote elements from
        // inner nested svg element.
        let svgOpenTagRe = try! NSRegularExpression(
            pattern: "<svg[^>]*>",
            options: []
        )
        let openTagMatches = svgOpenTagRe.matches(
            in: xml,
            options: [],
            range: NSRange(xml.startIndex..., in: xml)
        )

        guard openTagMatches.count == 2,
              let innerOpenTag = Range(openTagMatches[1].range(at: 0), in: xml)
        else {
            return input
        }
        xml.removeSubrange(innerOpenTag)

        guard let innerCloseTag = xml.range(of: "</svg>") else {
            return input
        }
        xml.removeSubrange(innerCloseTag)

        return xml
    }
}
C++ (currently use this; probably fixes some issues with the Swift version, though cannot recall what)
#include "VrvSvgFilter.h"

#include "pugixml.hpp"
#include <algorithm>
#include <cstring>
#include <map>
#include <regex>
#include <set>
#include <sstream>
#include <vector>

template <class UnaryPredicate>
inline static void vrvSvgTrim(std::string &s, UnaryPredicate p)
{
    s.erase(std::find_if(s.rbegin(), s.rend(), p).base(), s.end());
    s.erase(s.begin(), std::find_if(s.begin(), s.end(), p));
}

/**
 * Remove alphabetical characters from both ends of the string.
 */
inline static void vrvSvgTrimLetters(std::string &s)
{
    vrvSvgTrim(s, [](int c) { return !std::isalpha(c); });
}

#ifndef ANDROID
/**
 * Verovio has an svg element as a child of the root svg. Flatten it out.
 *
 * Required for iOS renderer to work; breaks layout for Android.
 */
static void removeInnerSvg(const pugi::xml_document &svgXml)
{
    pugi::xml_node root = svgXml.first_child();
    pugi::xml_node innerSvg = root.child("svg");
    // Promote required attributes.
    root.append_attribute("viewBox") = innerSvg.attribute("viewBox").value();
    root.remove_attribute("width");
    root.remove_attribute("height");

    // Promote children.
    for (pugi::xml_node node = innerSvg.first_child(); node;
         node = innerSvg.first_child()) {
        root.append_move(node);
    }
    root.remove_child(innerSvg);
}
#endif

/**
 * Merge whitelisted attributes from the element with the supplied mapping.
 */
static std::map<std::string, std::string> mergeTextAttributes(
    pugi::xml_node child,
    std::map<std::string, std::string> parentAttr)
{
    std::vector<std::string> knownAttrs{
        "x",
        "y",
        "font-family",
        "font-size",
        "font-style",
        "text-anchor",
        "class"};
    std::map<std::string, std::string> output;
    for (const std::string &attr : knownAttrs) {
        pugi::xml_attribute childAttr = child.attribute(attr.c_str());
        if (!childAttr.empty()) {
            output[attr] = childAttr.value();
        } else if (parentAttr.find(attr) != parentAttr.end()) {
            // Attribute not in child? Take forwarded from parent, if exists.
            output[attr] = parentAttr[attr];
        }
    }
    return output;
}

/**
 * Populate a flattened tspan element.
 */
static int appendFlatTspan(
    pugi::xml_node flatTextNode,
    pugi::xml_node elem,
    std::map<std::string, std::string> newAttrs)
{
    // Try to merge nodes.
    pugi::xml_node prevTspan = flatTextNode.last_child();
    if (std::strcmp(prevTspan.name(), "tspan") == 0) {
        int attrMatchCount = 0;
        for (pugi::xml_attribute attr : prevTspan.attributes()) {
            auto itr = newAttrs.find(attr.name());
            if (itr != newAttrs.end() && itr->second == attr.value()) {
                attrMatchCount += 1;
            } else {
                attrMatchCount = -1;
                break;
            }
        }
        if (newAttrs.size() == attrMatchCount) {
            // Previous node has the same attributes. Append text children
            // nodes instead of wrapping in a new tspan.
            std::string combinedText(prevTspan.text().get());
            combinedText += elem.text().get();
            prevTspan.remove_children();
            prevTspan.append_child(pugi::node_pcdata)
                .set_value(combinedText.c_str());
            return 0;
        }
    }

    // Append new node.
    pugi::xml_node textNode = flatTextNode.append_child("tspan");
    textNode.append_child(pugi::node_pcdata).set_value(elem.text().get());
    for (const auto &attr : newAttrs) {
        textNode.append_attribute(attr.first.c_str()) = attr.second.c_str();
    }
    return 1;
}

/**
 * Recursively reduce nested tspan elements to a single layer.
 *
 * @param flatTextNode
 *  Target node within which to create new elements.
 * @param elem
 *  tspan element to flatten.
 *
 * @return
 *  Number of nodes created.
 */
static int recurseFlattenTextNode(
    pugi::xml_node flatTextNode,
    pugi::xml_node elem,
    const std::map<std::string, std::string> &parentAttr)
{
    int numAdded = 0;
    bool hasChild = false;
    for (pugi::xml_node child : elem.children("tspan")) {
        hasChild = true;
        numAdded += recurseFlattenTextNode(
            flatTextNode,
            child,
            mergeTextAttributes(child, parentAttr));
    }
    if (!hasChild && !elem.text().empty()) {
        numAdded += appendFlatTspan(flatTextNode, elem, parentAttr);
    }
    return numAdded;
}

/**
 * Recursively reduce nested tspan elements to a single layer.
 */
static void removeNestedTspan(const pugi::xml_document &svgXml)
{
    for (pugi::xpath_node selectedNode :
         svgXml.select_nodes("//*[local-name()=\"text\"]")) {
        pugi::xml_node node = selectedNode.node();

        pugi::xml_node flatTextNode =
            node.parent().insert_copy_after(node, node);
        flatTextNode.remove_children();
        if (recurseFlattenTextNode(
                flatTextNode,
                node,
                std::map<std::string, std::string>()) > 0) {
            node.parent().remove_child(node);
        } else {
            node.parent().remove_child(flatTextNode);
        }
    }
}

/**
 * Set text font.
 */
static void styleVerseText(const pugi::xml_document &svgXml)
{
    pugi::xml_node style = svgXml.first_child().append_child("style");
    style.append_attribute("type") = "text/css";
#ifdef ANDROID
    style.text() = ".verse .text { font-family: LiberationSerif; }";
#else
    // CSS selector support for iOS renderer is limited.
    style.text() = ".text { font-family: LiberationSerif; }";
#endif
}

/**
 * Convert svg symbol defs to path defs.
 */
std::string transformSvgSymbol(
    const std::string &annotation,
    const std::string &svg,
    int pageNo)
{
    pugi::xml_document svgXml;
    pugi::xml_parse_result parseResult = svgXml.load_string(svg.c_str());
    if (parseResult.status != pugi::status_ok) {
        return parseResult.description();
    }

    // Find symbol usages.
    std::map<std::string, std::set<std::string>> useConfigs;
    for (pugi::xpath_node selectedNode :
         svgXml.select_nodes("//*[local-name()=\"use\"]")) {
        pugi::xml_node node = selectedNode.node();

        pugi::xml_attribute hrefAttr = node.attribute("xlink:href");
        std::string oldHref(hrefAttr.value());
        std::string width(node.attribute("width").value());
        vrvSvgTrimLetters(width);
        std::string height(node.attribute("height").value());
        vrvSvgTrimLetters(height);

        // Retarget to a path def.
        std::string elemConfig(width + '-' + height);
        std::string newHref(oldHref + '-' + elemConfig);
        hrefAttr.set_value(newHref.c_str());
        node.remove_attribute("width");
        node.remove_attribute("height");

        std::string symbolId(oldHref.substr(1));
        useConfigs[symbolId].insert(elemConfig);
    }

    // Create path defs.
    pugi::xml_node defs = svgXml.first_child().child("defs");
    std::regex transformRe(R"(scale\((\d+),\s*(-?\d+)\))");
    std::regex viewBoxRe(R"(\d+\s+\d+\s+(\d+)\s+(\d+))");
    for (pugi::xpath_node selectedNode :
         svgXml.select_nodes("//*[local-name()=\"symbol\"]")) {
        pugi::xml_node node = selectedNode.node();

        std::string symbolId(node.attribute("id").value());
        std::string viewBox(node.attribute("viewBox").value());
        pugi::xml_node pathElem = node.child("path");
        std::string transform(pathElem.attribute("transform").value());
        std::string coords(pathElem.attribute("d").value());

        // Remove symbol def.
        node.parent().remove_child(node);

        // Parse attributes.
        std::smatch parsedTransform;
        if (!std::regex_search(transform, parsedTransform, transformRe)) {
            continue;
        }
        std::smatch parsedViewBox;
        if (!std::regex_search(viewBox, parsedViewBox, viewBoxRe)) {
            continue;
        }
        double transformX = std::stod(parsedTransform[1]);
        double transformY = std::stod(parsedTransform[2]);
        double viewBoxWidth = std::stod(parsedViewBox[1]);
        double viewBoxHeight = std::stod(parsedViewBox[2]);

        // Create def nodes for each required transform of the symbol.
        for (const std::string &elemConfig : useConfigs[symbolId]) {
            size_t dashIdx = elemConfig.find('-');
            double width = std::stod(elemConfig.substr(0, dashIdx));
            double height = std::stod(elemConfig.substr(dashIdx + 1));

            pugi::xml_node newPathElem = defs.append_child("path");
            newPathElem.append_attribute("id") =
                (symbolId + '-' + elemConfig).c_str();

            std::string transformAttr("scale(");
            transformAttr += std::to_string(transformX * width / viewBoxWidth);
            transformAttr += ',';
            transformAttr +=
                std::to_string(transformY * height / viewBoxHeight);
            transformAttr += ')';
            newPathElem.append_attribute("transform") = transformAttr.c_str();
            newPathElem.append_attribute("d") = coords.c_str();
        }
    }

    removeNestedTspan(svgXml);

#ifndef ANDROID
    removeInnerSvg(svgXml);
#endif

    styleVerseText(svgXml);

    std::ostringstream result;
    svgXml.save(result);
    return result.str();
}

@bretrbs
Copy link

bretrbs commented Jan 26, 2022

You're the coolest! I just used the C++ version as with Verovio I've got a mountain of it in the project already. Thanks for hooking me up!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants