exyte / Macaw

Powerful and easy-to-use vector graphics Swift library with SVG support
MIT License
6k stars 552 forks source link

SVG text failing to render #759

Open alpha0010 opened 3 years ago

alpha0010 commented 3 years ago

The attached svg (music generated by Verovio and transformed by https://github.com/rism-digital/verovio/issues/332#issuecomment-252779742, 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 commented 3 years ago

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

bretrbs commented 2 years ago

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 commented 2 years ago

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

Swift (older implementation) ```swift import KissXML class VerovioRenderer { // [...] private func transformSvgSymbol(_ svg: String) -> String { let svgXml = try! XMLDocument(xmlString: svg, options: 0) var useConfigs: Dictionary> = [:] 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 ) -> [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) -> Dictionary { let knownAttrs = ["x", "y", "font-family", "font-size", "font-style", "text-anchor", "class"] var output: Dictionary = [:] 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: "]*>", 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: "") else { return input } xml.removeSubrange(innerCloseTag) return xml } } ```
C++ (currently use this; probably fixes some issues with the Swift version, though cannot recall what) ```C++ #include "VrvSvgFilter.h" #include "pugixml.hpp" #include #include #include #include #include #include #include template 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 mergeTextAttributes( pugi::xml_node child, std::map parentAttr) { std::vector knownAttrs{ "x", "y", "font-family", "font-size", "font-style", "text-anchor", "class"}; std::map 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 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 &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()) > 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> 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 commented 2 years ago

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!