From 6c377a036dffaa2fd53a34f68bfe6989bd994e8d Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 08:55:00 +0200 Subject: [PATCH 01/18] Improved SVG rendering --- examples/graphics/source/examples/Svg.h | 37 +- .../yup_graphics/drawables/yup_Drawable.cpp | 1176 ++++++++++++++--- modules/yup_graphics/drawables/yup_Drawable.h | 77 +- .../yup_graphics/graphics/yup_Graphics.cpp | 18 +- modules/yup_graphics/graphics/yup_Graphics.h | 7 + modules/yup_graphics/primitives/yup_Path.cpp | 2 +- modules/yup_gui/yup_gui.h | 2 +- tests/yup_graphics/yup_Drawable.cpp | 150 ++- tests/yup_graphics/yup_Path.cpp | 8 + 9 files changed, 1243 insertions(+), 234 deletions(-) diff --git a/examples/graphics/source/examples/Svg.h b/examples/graphics/source/examples/Svg.h index 2f03c93e4..edc88123e 100644 --- a/examples/graphics/source/examples/Svg.h +++ b/examples/graphics/source/examples/Svg.h @@ -27,15 +27,11 @@ class SvgDemo : public yup::Component SvgDemo() { updateListOfSvgFiles(); + loadDemoFont(); parseSvgFile (currentSvgFileIndex); } - void resized() override - { - //drawable.setBounds (getLocalBounds()); - } - void mouseDown (const yup::MouseEvent& event) override { ++currentSvgFileIndex; @@ -59,6 +55,8 @@ class SvgDemo : public yup::Component .getParentDirectory() .getParentDirectory(); + dataDirectory = riveBasePath.getChildFile ("data"); + auto files = riveBasePath.getChildFile ("data/svg").findChildFiles (yup::File::findFiles, false, "*.svg"); if (files.isEmpty()) return; @@ -83,12 +81,39 @@ class SvgDemo : public yup::Component YUP_DBG ("Showing " << svgFiles[currentSvgFileIndex].getFullPathName()); drawable.clear(); - drawable.parseSVG (svgFiles[currentSvgFileIndex]); + drawable.parseSVG (svgFiles[currentSvgFileIndex], createParseOptions (svgFiles[currentSvgFileIndex])); repaint(); } + void loadDemoFont() + { + yup::Font font; + if (font.loadFromFile (dataDirectory.getChildFile ("RobotoFlex-VariableFont.ttf")).wasOk()) + demoFont = std::move (font); + } + + yup::Drawable::ParseOptions createParseOptions (const yup::File& svgFile) + { + yup::Drawable::ParseOptions options; + options.baseDirectory = svgFile.getParentDirectory(); + options.fontResolver = [this] (yup::StringRef, float fontSize) -> std::optional + { + if (demoFont) + return demoFont->withHeight (fontSize); + + if (auto theme = yup::ApplicationTheme::getGlobalTheme()) + return theme->getDefaultFont().withHeight (fontSize); + + return std::nullopt; + }; + + return options; + } + yup::Drawable drawable; yup::Array svgFiles; + yup::File dataDirectory; + std::optional demoFont; int currentSvgFileIndex = 0; }; diff --git a/modules/yup_graphics/drawables/yup_Drawable.cpp b/modules/yup_graphics/drawables/yup_Drawable.cpp index e3e478c15..ccba7298d 100644 --- a/modules/yup_graphics/drawables/yup_Drawable.cpp +++ b/modules/yup_graphics/drawables/yup_Drawable.cpp @@ -24,7 +24,7 @@ namespace yup //============================================================================== #ifndef YUP_DRAWABLE_LOGGING -#define YUP_DRAWABLE_LOGGING 0 +#define YUP_DRAWABLE_LOGGING 1 #endif #if YUP_DRAWABLE_LOGGING @@ -44,12 +44,46 @@ Drawable::Drawable() //============================================================================== bool Drawable::parseSVG (const File& svgFile) +{ + ParseOptions options; + options.baseDirectory = svgFile.getParentDirectory(); + + return parseSVG (svgFile, options); +} + +bool Drawable::parseSVG (StringRef svgText) +{ + return parseSVG (svgText, ParseOptions()); +} + +bool Drawable::parseSVG (const File& svgFile, const ParseOptions& options) { clear(); + parseOptions = options; + if (parseOptions.baseDirectory.getFullPathName().isEmpty()) + parseOptions.baseDirectory = svgFile.getParentDirectory(); + XmlDocument svgDoc (svgFile); std::unique_ptr svgRoot (svgDoc.getDocumentElement()); + return parseDocument (std::move (svgRoot)); +} + +bool Drawable::parseSVG (StringRef svgText, const ParseOptions& options) +{ + clear(); + + parseOptions = options; + + XmlDocument svgDoc (String (svgText.text)); + std::unique_ptr svgRoot (svgDoc.getDocumentElement()); + + return parseDocument (std::move (svgRoot)); +} + +bool Drawable::parseDocument (std::unique_ptr svgRoot) +{ if (svgRoot == nullptr || ! svgRoot->hasTagName ("svg")) return false; @@ -74,6 +108,17 @@ bool Drawable::parseSVG (const File& svgFile) // ViewBox transform is now calculated at render-time based on actual target area YUP_DRAWABLE_LOG ("Parse complete - viewBox: " << viewBox.toString() << " size: " << size.getWidth() << "x" << size.getHeight()); + std::function collectStyleElements = [&] (const XmlElement& xml) + { + if (xml.hasTagName ("style")) + parseStyleElement (xml); + + for (auto* child = xml.getFirstChildElement(); child != nullptr; child = child->getNextElement()) + collectStyleElements (*child); + }; + + collectStyleElements (*svgRoot); + auto result = parseElement (*svgRoot, true, {}); if (result) @@ -97,6 +142,7 @@ void Drawable::clear() gradientsById.clear(); clipPaths.clear(); clipPathsById.clear(); + cssRules.clear(); // Reset root element's default presentation attributes to SVG defaults rootHasFill = true; // SVG default fill is black @@ -135,7 +181,7 @@ void Drawable::paint (Graphics& g) // Pass root element's fill/stroke state to top-level elements for (const auto& element : elements) - paintElement (g, *element, rootHasFill, rootHasStroke); + paintElement (g, *element, rootHasFill, rootHasStroke, rootFillColor.value_or (Colors::black)); } void Drawable::paint (Graphics& g, const Rectangle& targetArea, Fitting fitting, Justification justification) @@ -166,17 +212,22 @@ void Drawable::paint (Graphics& g, const Rectangle& targetArea, Fitting f // Pass root element's fill/stroke state to top-level elements for (const auto& element : elements) - paintElement (g, *element, rootHasFill, rootHasStroke); + paintElement (g, *element, rootHasFill, rootHasStroke, rootFillColor.value_or (Colors::black)); } //============================================================================== -void Drawable::paintElement (Graphics& g, const Element& element, bool hasParentFillEnabled, bool hasParentStrokeEnabled) +void Drawable::paintElement (Graphics& g, const Element& element, bool hasParentFillEnabled, bool hasParentStrokeEnabled, Color currentColor, int recursionDepth) { + if (element.hidden || recursionDepth > 64) + return; + const auto savedState = g.saveState(); bool isFillDefined = hasParentFillEnabled; bool isStrokeDefined = hasParentStrokeEnabled; + if (element.color) + currentColor = *element.color; YUP_DRAWABLE_LOG ("paintElement called - hasPath: " << (element.path ? "true" : "false") << " hasTransform: " << (element.transform ? "true" : "false")); @@ -193,6 +244,14 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent if (element.opacity) g.setOpacity (g.getOpacity() * (*element.opacity)); + if (element.viewBox && element.viewportSize) + { + Rectangle viewport (0.0f, 0.0f, element.viewportSize->getWidth(), element.viewportSize->getHeight()); + auto viewBoxTransform = calculateTransformForTarget (*element.viewBox, viewport, element.preserveAspectRatioFitting, element.preserveAspectRatioJustification); + if (! viewBoxTransform.isIdentity()) + g.addTransform (viewBoxTransform); + } + // Apply clipping path if specified bool hasClipping = false; if (element.clipPathUrl) @@ -204,7 +263,12 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent for (const auto& clipElement : clipPath->elements) { if (clipElement->path) - combinedClipPath.appendPath (*clipElement->path); + { + if (clipElement->transform) + combinedClipPath.appendPath (*clipElement->path, *clipElement->transform); + else + combinedClipPath.appendPath (*clipElement->path); + } } if (! combinedClipPath.isEmpty()) @@ -224,6 +288,14 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent g.setFillColor (fillColor); isFillDefined = true; } + else if (element.fillCurrentColor) + { + Color fillColor = currentColor; + if (element.fillOpacity) + fillColor = fillColor.withMultipliedAlpha (*element.fillOpacity); + g.setFillColor (fillColor); + isFillDefined = true; + } else if (element.fillUrl) { YUP_DRAWABLE_LOG ("Looking for gradient with ID: " << *element.fillUrl); @@ -273,64 +345,56 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent { if (auto refElement = elementsById[*element.reference]; refElement != nullptr && refElement->path) { - YUP_DRAWABLE_LOG ("Rendering use element - reference: " << *element.reference); - YUP_DRAWABLE_LOG ("Use element transform: " << (element.transform ? element.transform->toString() : "none")); - YUP_DRAWABLE_LOG ("Referenced element local transform: " << (refElement->localTransform ? refElement->localTransform->toString() : "none")); - YUP_DRAWABLE_LOG ("Graphics transform during use fill: " << g.getTransform().toString()); + const bool useDefinesFill = element.fillColor || element.fillCurrentColor || element.fillUrl || element.noFill; + if (useDefinesFill || ! refElement->noFill) + { + const auto savedReferenceState = g.saveState(); - // For elements, apply only the referenced element's own transform (not inherited parents) - const auto savedTransform = g.getTransform(); - if (refElement->localTransform) - g.setTransform (refElement->localTransform->followedBy (savedTransform)); + if (! useDefinesFill) + { + if (refElement->fillColor) + { + Color fillColor = *refElement->fillColor; + if (refElement->fillOpacity) + fillColor = fillColor.withMultipliedAlpha (*refElement->fillOpacity); - // SVG spec: fill is applied to both closed and unclosed paths - g.fillPath (*refElement->path); + g.setFillColor (fillColor); + } + else if (refElement->fillCurrentColor) + { + Color fillColor = currentColor; + if (refElement->fillOpacity) + fillColor = fillColor.withMultipliedAlpha (*refElement->fillOpacity); - if (refElement->localTransform) - g.setTransform (savedTransform); + g.setFillColor (fillColor); + } + } + + YUP_DRAWABLE_LOG ("Rendering use element - reference: " << *element.reference); + YUP_DRAWABLE_LOG ("Use element transform: " << (element.transform ? element.transform->toString() : "none")); + YUP_DRAWABLE_LOG ("Referenced element local transform: " << (refElement->localTransform ? refElement->localTransform->toString() : "none")); + YUP_DRAWABLE_LOG ("Graphics transform during use fill: " << g.getTransform().toString()); + + // For elements, apply only the referenced element's own transform (not inherited parents) + const auto savedTransform = g.getTransform(); + if (refElement->localTransform) + g.setTransform (refElement->localTransform->followedBy (savedTransform)); + + // SVG spec: fill is applied to both closed and unclosed paths + g.fillPath (*refElement->path); + + if (refElement->localTransform) + g.setTransform (savedTransform); + } } } else if (element.text && element.textPosition) { - /* - // Create StyledText for text rendering - StyledText styledText; - styledText.setText (*element.text); - - // Set font properties - if (element.fontSize) - styledText.setFontSize (*element.fontSize); - else - styledText.setFontSize (12.0f); - - if (element.fontFamily) - styledText.setFontFamily (*element.fontFamily); - - // Set text alignment based on text-anchor - if (element.textAnchor) - { - if (*element.textAnchor == "middle") - styledText.setHorizontalAlignment (StyledText::HorizontalAlignment::Center); - else if (*element.textAnchor == "end") - styledText.setHorizontalAlignment (StyledText::HorizontalAlignment::Right); - else - styledText.setHorizontalAlignment (StyledText::HorizontalAlignment::Left); - } - - // Render text at specified position - auto textBounds = styledText.getBounds(); - g.drawStyledText (styledText, element.textPosition->getX(), element.textPosition->getY() - textBounds.getHeight()); - */ + renderTextElement (g, element); } - else if (element.imageHref && element.imageBounds) + else if ((element.imageHref || element.image) && element.imageBounds) { - // TODO: Load and render image - // For now, draw a placeholder rectangle - g.setFillColor (Colors::lightgray); - g.fillRect (*element.imageBounds); - g.setStrokeColor (Colors::darkgray); - g.setStrokeWidth (1.0f); - g.strokeRect (*element.imageBounds); + renderImageElement (g, element); } } @@ -343,6 +407,14 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent g.setStrokeColor (strokeColor); isStrokeDefined = true; } + else if (element.strokeCurrentColor) + { + Color strokeColor = currentColor; + if (element.strokeOpacity) + strokeColor = strokeColor.withMultipliedAlpha (*element.strokeOpacity); + g.setStrokeColor (strokeColor); + isStrokeDefined = true; + } else if (element.strokeUrl) { if (auto gradient = getGradientById (*element.strokeUrl)) @@ -394,16 +466,64 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent } } - if (isStrokeDefined && ! element.noStroke) + bool referenceDefinesStroke = false; + if (! isStrokeDefined && element.reference) { - if (element.path) + if (auto refElement = elementsById[*element.reference]; refElement != nullptr) + referenceDefinesStroke = (refElement->strokeColor || refElement->strokeCurrentColor || refElement->strokeUrl) && ! refElement->noStroke; + } + + if ((isStrokeDefined || referenceDefinesStroke) && ! element.noStroke) + { + const Path* pathToStroke = element.path ? std::addressof (*element.path) : nullptr; + std::optional dashedPath; + + if (pathToStroke != nullptr && element.strokeDashArray && ! element.strokeDashArray->isEmpty()) { - g.strokePath (*element.path); + dashedPath = createDashedPath (*pathToStroke, *element.strokeDashArray, element.strokeDashOffset.value_or (0.0f)); + pathToStroke = std::addressof (*dashedPath); + } + + if (pathToStroke != nullptr) + { + g.strokePath (*pathToStroke); } else if (element.reference) { if (auto refElement = elementsById[*element.reference]; refElement != nullptr && refElement->path) { + const bool useDefinesStroke = element.strokeColor || element.strokeCurrentColor || element.strokeUrl || element.noStroke; + const auto savedReferenceState = g.saveState(); + + if (! useDefinesStroke) + { + if (refElement->strokeColor) + { + Color strokeColor = *refElement->strokeColor; + if (refElement->strokeOpacity) + strokeColor = strokeColor.withMultipliedAlpha (*refElement->strokeOpacity); + + g.setStrokeColor (strokeColor); + } + else if (refElement->strokeCurrentColor) + { + Color strokeColor = currentColor; + if (refElement->strokeOpacity) + strokeColor = strokeColor.withMultipliedAlpha (*refElement->strokeOpacity); + + g.setStrokeColor (strokeColor); + } + + if (refElement->strokeWidth) + g.setStrokeWidth (*refElement->strokeWidth); + + if (refElement->strokeJoin) + g.setStrokeJoin (*refElement->strokeJoin); + + if (refElement->strokeCap) + g.setStrokeCap (*refElement->strokeCap); + } + YUP_DRAWABLE_LOG ("Stroking use element - reference: " << *element.reference); YUP_DRAWABLE_LOG ("Graphics transform during stroke: " << g.getTransform().toString()); @@ -420,12 +540,36 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent } } + if (element.reference) + { + if (auto refElement = elementsById[*element.reference]; refElement != nullptr && ! refElement->children.empty()) + { + const auto savedTransform = g.getTransform(); + if (refElement->localTransform) + g.setTransform (refElement->localTransform->followedBy (savedTransform)); + + if (refElement->viewBox) + { + auto viewportSizeToUse = element.viewportSize.value_or (refElement->viewportSize.value_or (Size (refElement->viewBox->getWidth(), refElement->viewBox->getHeight()))); + Rectangle viewport (0.0f, 0.0f, viewportSizeToUse.getWidth(), viewportSizeToUse.getHeight()); + auto viewBoxTransform = calculateTransformForTarget (*refElement->viewBox, viewport, refElement->preserveAspectRatioFitting, refElement->preserveAspectRatioJustification); + if (! viewBoxTransform.isIdentity()) + g.addTransform (viewBoxTransform); + } + + for (const auto& childElement : refElement->children) + paintElement (g, *childElement, isFillDefined && ! element.noFill, isStrokeDefined && ! element.noStroke, currentColor, recursionDepth + 1); + + g.setTransform (savedTransform); + } + } + for (const auto& childElement : element.children) { YUP_DRAWABLE_LOG ("Rendering child element - current graphics transform: " << g.getTransform().toString()); // Pass fill/stroke state to children, but respect explicit "none" values // If this element has fill="none", children should not inherit fill - paintElement (g, *childElement, isFillDefined && ! element.noFill, isStrokeDefined && ! element.noStroke); + paintElement (g, *childElement, isFillDefined && ! element.noFill, isStrokeDefined && ! element.noStroke, currentColor, recursionDepth + 1); } // paintDebugElement (g, element); @@ -437,6 +581,10 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin { Element::Ptr e = new Element; bool isRootElement = element.hasTagName ("svg"); + e->tagName = element.getTagNameWithoutNamespace(); + + if (auto classes = element.getStringAttribute ("class"); classes.isNotEmpty()) + e->classNames = StringArray::fromTokens (classes, " \t\r\n", ""); if (auto id = element.getStringAttribute ("id"); id.isNotEmpty()) { @@ -444,12 +592,21 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin elementsById.set (id, e); } + const float inheritedFontSize = parent != nullptr && parent->fontSize ? *parent->fontSize : 12.0f; + const float viewportWidth = viewBox.getWidth() > 0.0f ? viewBox.getWidth() : (size.getWidth() > 0.0f ? size.getWidth() : 100.0f); + const float viewportHeight = viewBox.getHeight() > 0.0f ? viewBox.getHeight() : (size.getHeight() > 0.0f ? size.getHeight() : 100.0f); + const float viewportDiagonal = std::sqrt ((viewportWidth * viewportWidth + viewportHeight * viewportHeight) * 0.5f); + if (element.hasTagName ("path")) { auto path = Path(); String pathData = element.getStringAttribute ("d"); - if (pathData.isEmpty() || ! path.fromString (pathData)) + auto trimmedPathData = pathData.trimStart(); + if (trimmedPathData.isNotEmpty() && ! String ("MmZzLlHhVvCcSsQqTtAa").containsChar (trimmedPathData[0])) + return false; + + if (! pathData.isEmpty() && ! path.fromString (pathData)) return false; e->path = std::move (path); @@ -469,12 +626,20 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin else if (element.hasTagName ("use")) { String href = element.getStringAttribute ("href"); + if (href.isEmpty()) + href = element.getStringAttribute ("xlink:href"); + if (href.isNotEmpty() && href.startsWith ("#")) e->reference = href.substring (1); // Handle x,y positioning for use elements (SVG spec requirement) - auto x = element.getFloatAttribute ("x"); - auto y = element.getFloatAttribute ("y"); + auto x = parseLengthAttribute (element, "x", 0.0f, inheritedFontSize, viewportWidth); + auto y = parseLengthAttribute (element, "y", 0.0f, inheritedFontSize, viewportHeight); + auto width = parseLengthAttribute (element, "width", 0.0f, inheritedFontSize, viewportWidth); + auto height = parseLengthAttribute (element, "height", 0.0f, inheritedFontSize, viewportHeight); + if (width > 0.0f && height > 0.0f) + e->viewportSize = Size (width, height); + AffineTransform useTransform; if (x != 0.0f || y != 0.0f) useTransform = AffineTransform::translation (x, y); @@ -494,10 +659,10 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin } else if (element.hasTagName ("ellipse")) { - auto cx = element.getFloatAttribute ("cx"); - auto cy = element.getFloatAttribute ("cy"); - auto rx = element.getFloatAttribute ("rx"); - auto ry = element.getFloatAttribute ("ry"); + auto cx = parseLengthAttribute (element, "cx", 0.0f, inheritedFontSize, viewportWidth); + auto cy = parseLengthAttribute (element, "cy", 0.0f, inheritedFontSize, viewportHeight); + auto rx = parseLengthAttribute (element, "rx", 0.0f, inheritedFontSize, viewportDiagonal); + auto ry = parseLengthAttribute (element, "ry", 0.0f, inheritedFontSize, viewportDiagonal); auto path = Path(); path.addCenteredEllipse (cx, cy, rx, ry); @@ -508,9 +673,9 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin } else if (element.hasTagName ("circle")) { - auto cx = element.getFloatAttribute ("cx"); - auto cy = element.getFloatAttribute ("cy"); - auto r = element.getFloatAttribute ("r"); + auto cx = parseLengthAttribute (element, "cx", 0.0f, inheritedFontSize, viewportWidth); + auto cy = parseLengthAttribute (element, "cy", 0.0f, inheritedFontSize, viewportHeight); + auto r = parseLengthAttribute (element, "r", 0.0f, inheritedFontSize, viewportDiagonal); auto path = Path(); path.addCenteredEllipse (cx, cy, r, r); @@ -521,12 +686,12 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin } else if (element.hasTagName ("rect")) { - auto x = element.getFloatAttribute ("x"); - auto y = element.getFloatAttribute ("y"); - auto width = element.getFloatAttribute ("width"); - auto height = element.getFloatAttribute ("height"); - auto rx = element.getFloatAttribute ("rx"); - auto ry = element.getFloatAttribute ("ry"); + auto x = parseLengthAttribute (element, "x", 0.0f, inheritedFontSize, viewportWidth); + auto y = parseLengthAttribute (element, "y", 0.0f, inheritedFontSize, viewportHeight); + auto width = parseLengthAttribute (element, "width", 0.0f, inheritedFontSize, viewportWidth); + auto height = parseLengthAttribute (element, "height", 0.0f, inheritedFontSize, viewportHeight); + auto rx = parseLengthAttribute (element, "rx", 0.0f, inheritedFontSize, viewportWidth); + auto ry = parseLengthAttribute (element, "ry", 0.0f, inheritedFontSize, viewportHeight); auto path = Path(); if (rx > 0.0f || ry > 0.0f) @@ -550,10 +715,10 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin } else if (element.hasTagName ("line")) { - auto x1 = element.getFloatAttribute ("x1"); - auto y1 = element.getFloatAttribute ("y1"); - auto x2 = element.getFloatAttribute ("x2"); - auto y2 = element.getFloatAttribute ("y2"); + auto x1 = parseLengthAttribute (element, "x1", 0.0f, inheritedFontSize, viewportWidth); + auto y1 = parseLengthAttribute (element, "y1", 0.0f, inheritedFontSize, viewportHeight); + auto x2 = parseLengthAttribute (element, "x2", 0.0f, inheritedFontSize, viewportWidth); + auto y2 = parseLengthAttribute (element, "y2", 0.0f, inheritedFontSize, viewportHeight); auto path = Path(); path.startNewSubPath (x1, y1); @@ -609,19 +774,36 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin currentTransform = parseTransform (element, currentTransform, *e); parseStyle (element, currentTransform, *e); } - else if (element.hasTagName ("text")) + else if (element.hasTagName ("text") || element.hasTagName ("tspan")) { - float x = element.getFloatAttribute ("x"); - float y = element.getFloatAttribute ("y"); + const auto defaultTextPosition = parent != nullptr && parent->textPosition ? *parent->textPosition : Point (0.0f, 0.0f); + float x = parseLengthAttribute (element, "x", defaultTextPosition.getX(), inheritedFontSize, viewportWidth); + float y = parseLengthAttribute (element, "y", defaultTextPosition.getY(), inheritedFontSize, viewportHeight); e->textPosition = Point (x, y); - e->text = element.getAllSubText(); + String directText; + for (auto* child : element.getChildIterator()) + { + if (child->isTextElement()) + directText += child->getText(); + } + + e->text = directText.isNotEmpty() ? directText : (element.hasTagName ("tspan") ? element.getAllSubText() : String()); + + if (auto xList = element.getStringAttribute ("x"); xList.isNotEmpty()) + e->textX = parseLengthList (xList, inheritedFontSize, viewportWidth); + if (auto yList = element.getStringAttribute ("y"); yList.isNotEmpty()) + e->textY = parseLengthList (yList, inheritedFontSize, viewportHeight); + if (auto dxList = element.getStringAttribute ("dx"); dxList.isNotEmpty()) + e->textDx = parseLengthList (dxList, inheritedFontSize, viewportWidth); + if (auto dyList = element.getStringAttribute ("dy"); dyList.isNotEmpty()) + e->textDy = parseLengthList (dyList, inheritedFontSize, viewportHeight); String fontFamily = element.getStringAttribute ("font-family"); if (fontFamily.isNotEmpty()) e->fontFamily = fontFamily; - float fontSize = element.getFloatAttribute ("font-size"); + float fontSize = parseLengthAttribute (element, "font-size", 0.0f, inheritedFontSize, inheritedFontSize); if (fontSize > 0.0f) e->fontSize = fontSize; @@ -634,10 +816,10 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin } else if (element.hasTagName ("image")) { - auto x = element.getFloatAttribute ("x"); - auto y = element.getFloatAttribute ("y"); - auto width = element.getFloatAttribute ("width"); - auto height = element.getFloatAttribute ("height"); + auto x = parseLengthAttribute (element, "x", 0.0f, inheritedFontSize, viewportWidth); + auto y = parseLengthAttribute (element, "y", 0.0f, inheritedFontSize, viewportHeight); + auto width = parseLengthAttribute (element, "width", 0.0f, inheritedFontSize, viewportWidth); + auto height = parseLengthAttribute (element, "height", 0.0f, inheritedFontSize, viewportHeight); e->imageBounds = Rectangle (x, y, width, height); @@ -646,13 +828,45 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin href = element.getStringAttribute ("xlink:href"); if (href.isNotEmpty()) + { e->imageHref = href; + e->image = loadImageFromHref (href); + } + + currentTransform = parseTransform (element, currentTransform, *e); + parseStyle (element, currentTransform, *e); + } + else if (element.hasTagName ("svg") || element.hasTagName ("symbol")) + { + e->isSymbol = element.hasTagName ("symbol"); + if (e->isSymbol) + e->hidden = true; + + if (auto view = element.getStringAttribute ("viewBox"); view.isNotEmpty()) + { + auto coords = StringArray::fromTokens (view, " ,", ""); + if (coords.size() == 4) + e->viewBox = Rectangle (coords[0].getFloatValue(), coords[1].getFloatValue(), coords[2].getFloatValue(), coords[3].getFloatValue()); + } + + auto width = parseLengthAttribute (element, "width", e->viewBox ? e->viewBox->getWidth() : viewportWidth, inheritedFontSize, viewportWidth); + auto height = parseLengthAttribute (element, "height", e->viewBox ? e->viewBox->getHeight() : viewportHeight, inheritedFontSize, viewportHeight); + if (width > 0.0f && height > 0.0f) + e->viewportSize = Size (width, height); + + if (auto preserveAspectRatio = element.getStringAttribute ("preserveAspectRatio"); preserveAspectRatio.isNotEmpty()) + { + e->preserveAspectRatioFitting = parsePreserveAspectRatio (preserveAspectRatio); + e->preserveAspectRatioJustification = parseAspectRatioAlignment (preserveAspectRatio); + } currentTransform = parseTransform (element, currentTransform, *e); parseStyle (element, currentTransform, *e); } else if (element.hasTagName ("defs")) { + e->hidden = true; + // Parse definitions like gradients and clip paths for (auto* child = element.getFirstChildElement(); child != nullptr; child = child->getNextElement()) { @@ -660,8 +874,31 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin parseGradient (*child); else if (child->hasTagName ("clipPath")) parseClipPath (*child); + else if (child->hasTagName ("style")) + parseStyleElement (*child); } } + else if (element.hasTagName ("style")) + { + parseStyleElement (element); + return true; + } + + if (parent != nullptr) + { + if (! e->fontFamily && parent->fontFamily) + e->fontFamily = parent->fontFamily; + if (! e->fontSize && parent->fontSize) + e->fontSize = parent->fontSize; + if (! e->textAnchor && parent->textAnchor) + e->textAnchor = parent->textAnchor; + if (! e->letterSpacing && parent->letterSpacing) + e->letterSpacing = parent->letterSpacing; + if (! e->wordSpacing && parent->wordSpacing) + e->wordSpacing = parent->wordSpacing; + if (! e->color && parent->color) + e->color = parent->color; + } for (auto* child = element.getFirstChildElement(); child != nullptr; child = child->getNextElement()) { @@ -670,10 +907,45 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin parseGradient (*child); else if (child->hasTagName ("clipPath")) parseClipPath (*child); + else if (child->hasTagName ("style")) + parseStyleElement (*child); else parseElement (*child, isRootElement, currentTransform, e.get()); } + if (e->tagName == "text" || e->tagName == "tspan") + { + auto cursor = e->textPosition.value_or (Point (0.0f, 0.0f)); + + for (auto& childElement : e->children) + { + if (childElement->tagName != "tspan") + continue; + + auto position = cursor; + + if (childElement->textX && ! childElement->textX->isEmpty()) + position.setX (childElement->textX->getFirst()); + + if (childElement->textY && ! childElement->textY->isEmpty()) + position.setY (childElement->textY->getFirst()); + + if (childElement->textDx && ! childElement->textDx->isEmpty()) + position.setX (position.getX() + childElement->textDx->getFirst()); + + if (childElement->textDy && ! childElement->textDy->isEmpty()) + position.setY (position.getY() + childElement->textDy->getFirst()); + + childElement->textPosition = position; + childElement->textX.reset(); + childElement->textY.reset(); + childElement->textDx.reset(); + childElement->textDy.reset(); + + cursor = position; + } + } + if (isRootElement) { // Store root SVG element's default fill/stroke for inheritance by top-level elements @@ -712,12 +984,10 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin void Drawable::parseStyle (const XmlElement& element, const AffineTransform& currentTransform, Element& e) { - // Parse CSS style attribute first String styleAttr = element.getStringAttribute ("style"); - if (styleAttr.isNotEmpty()) - parseCSSStyle (styleAttr, e); - // Parse individual attributes (these override style attribute values) + // Parse presentation attributes first. Author CSS and inline style are applied after + // this block so they can override presentation attributes. String fill = element.getStringAttribute ("fill"); if (fill.isNotEmpty()) { @@ -726,6 +996,8 @@ void Drawable::parseStyle (const XmlElement& element, const AffineTransform& cur String gradientUrl = extractGradientUrl (fill); if (gradientUrl.isNotEmpty()) e.fillUrl = gradientUrl; + else if (fill == "currentColor") + e.fillCurrentColor = true; else { e.fillColor = Color::fromString (fill); @@ -746,6 +1018,8 @@ void Drawable::parseStyle (const XmlElement& element, const AffineTransform& cur String gradientUrl = extractGradientUrl (stroke); if (gradientUrl.isNotEmpty()) e.strokeUrl = gradientUrl; + else if (stroke == "currentColor") + e.strokeCurrentColor = true; else e.strokeColor = Color::fromString (stroke); } @@ -826,6 +1100,36 @@ void Drawable::parseStyle (const XmlElement& element, const AffineTransform& cur String fillRule = element.getStringAttribute ("fill-rule"); if (fillRule == "evenodd" || fillRule == "nonzero") e.fillRule = fillRule; + + String color = element.getStringAttribute ("color"); + if (color.isNotEmpty() && color != "currentColor") + e.color = Color::fromString (color); + + String display = element.getStringAttribute ("display"); + String visibility = element.getStringAttribute ("visibility"); + if (display == "none" || visibility == "hidden" || visibility == "collapse") + e.hidden = true; + + String fontFamily = element.getStringAttribute ("font-family"); + if (fontFamily.isNotEmpty()) + e.fontFamily = fontFamily; + + String fontSize = element.getStringAttribute ("font-size"); + if (fontSize.isNotEmpty()) + e.fontSize = parseUnit (fontSize, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); + + String letterSpacing = element.getStringAttribute ("letter-spacing"); + if (letterSpacing.isNotEmpty() && letterSpacing != "normal") + e.letterSpacing = parseUnit (letterSpacing, 0.0f, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); + + String wordSpacing = element.getStringAttribute ("word-spacing"); + if (wordSpacing.isNotEmpty() && wordSpacing != "normal") + e.wordSpacing = parseUnit (wordSpacing, 0.0f, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); + + applyStylesheetRules (element, e); + + if (styleAttr.isNotEmpty()) + parseCSSStyle (styleAttr, e); } //============================================================================== @@ -838,6 +1142,17 @@ AffineTransform Drawable::parseTransform (const XmlElement& element, const Affin { result = parseTransform (transformString); + if (auto transformOrigin = element.getStringAttribute ("transform-origin"); transformOrigin.isNotEmpty()) + { + auto origin = parseLengthList (transformOrigin, 12.0f, 100.0f); + if (origin.size() >= 2) + { + result = AffineTransform::translation (-origin[0], -origin[1]) + .followedBy (result) + .followedBy (AffineTransform::translation (origin[0], origin[1])); + } + } + e.transform = result; e.localTransform = result; // Store the local transform separately for use by elements @@ -882,14 +1197,14 @@ AffineTransform Drawable::parseTransform (const String& transformString) Array params; while (! data.isEmpty() && *data != ')') { - if (*data == ',' || *data == ' ') + if (*data == ',' || data.isWhitespace()) { ++data; continue; } String number; - while (! data.isEmpty() && (*data == '-' || *data == '.' || *data == 'e' || (*data >= '0' && *data <= '9'))) + while (! data.isEmpty() && (*data == '-' || *data == '+' || *data == '.' || *data == 'e' || *data == 'E' || (*data >= '0' && *data <= '9'))) { number += *data; ++data; @@ -1369,7 +1684,6 @@ Drawable::ClipPath::Ptr Drawable::getClipPathById (const String& id) void Drawable::parseCSSStyle (const String& styleString, Element& e) { - // Parse CSS style declarations separated by semicolons auto declarations = StringArray::fromTokens (styleString, ";", ""); for (const auto& declaration : declarations) @@ -1380,129 +1694,286 @@ void Drawable::parseCSSStyle (const String& styleString, Element& e) String property = declaration.substring (0, colonPos).trim(); String value = declaration.substring (colonPos + 1).trim(); - if (property == "fill") - { - if (value != "none") - { - String gradientUrl = extractGradientUrl (value); - if (gradientUrl.isNotEmpty()) - e.fillUrl = gradientUrl; - else - e.fillColor = Color::fromString (value); - } - else - { - e.noFill = true; - } - } - else if (property == "stroke") - { - if (value != "none") - { - String gradientUrl = extractGradientUrl (value); - if (gradientUrl.isNotEmpty()) - e.strokeUrl = gradientUrl; - else - e.strokeColor = Color::fromString (value); - } - else - { - e.noStroke = true; - } - } - else if (property == "stroke-width") - { - float strokeWidth = value.getFloatValue(); - if (strokeWidth > 0.0f) - e.strokeWidth = strokeWidth; - } - else if (property == "stroke-linejoin") - { - if (value == "round") - e.strokeJoin = StrokeJoin::Round; - else if (value == "miter") - e.strokeJoin = StrokeJoin::Miter; - else if (value == "bevel") - e.strokeJoin = StrokeJoin::Bevel; - } - else if (property == "stroke-linecap") - { - if (value == "round") - e.strokeCap = StrokeCap::Round; - else if (value == "square") - e.strokeCap = StrokeCap::Square; - else if (value == "butt") - e.strokeCap = StrokeCap::Butt; - } - else if (property == "opacity") - { - float opacity = value.getFloatValue(); - if (opacity >= 0.0f && opacity <= 1.0f) - e.opacity = opacity; - } - else if (property == "font-family") - { - e.fontFamily = value; - } - else if (property == "font-size") - { - float fontSize = value.getFloatValue(); - if (fontSize > 0.0f) - e.fontSize = fontSize; - } - else if (property == "text-anchor") - { - e.textAnchor = value; - } - else if (property == "clip-path") - { - String clipPathUrl = extractGradientUrl (value); - if (clipPathUrl.isNotEmpty()) - e.clipPathUrl = clipPathUrl; - } - else if (property == "stroke-dasharray") - { - if (value != "none") - { - auto dashValues = StringArray::fromTokens (value, " ,", ""); - if (! dashValues.isEmpty()) - { - Array dashes; - for (const auto& dash : dashValues) - { - float dashValue = parseUnit (dash); - if (dashValue >= 0.0f) - dashes.add (dashValue); - } + applyStyleProperty (property, value, e); + } + } +} - if (! dashes.isEmpty()) - e.strokeDashArray = dashes; - } - } - } - else if (property == "stroke-dashoffset") - { - e.strokeDashOffset = parseUnit (value); - } - else if (property == "fill-opacity") - { - float opacity = value.getFloatValue(); - if (opacity >= 0.0f && opacity <= 1.0f) - e.fillOpacity = opacity; - } - else if (property == "stroke-opacity") - { - float opacity = value.getFloatValue(); - if (opacity >= 0.0f && opacity <= 1.0f) - e.strokeOpacity = opacity; - } - else if (property == "fill-rule") - { - if (value == "evenodd" || value == "nonzero") - e.fillRule = value; - } +void Drawable::applyStyleProperty (StringRef propertyRef, StringRef valueRef, Element& e) +{ + String property (propertyRef.text); + String value (valueRef.text); + + property = property.trim().toLowerCase(); + value = value.trim(); + + if (property == "fill") + { + e.fillCurrentColor = false; + e.fillUrl.reset(); + e.fillColor.reset(); + e.noFill = false; + + if (value == "none") + e.noFill = true; + else if (value == "currentColor") + e.fillCurrentColor = true; + else if (auto url = extractUrlId (value); url.isNotEmpty()) + e.fillUrl = url; + else if (value.isNotEmpty()) + e.fillColor = Color::fromString (value); + } + else if (property == "stroke") + { + e.strokeCurrentColor = false; + e.strokeUrl.reset(); + e.strokeColor.reset(); + e.noStroke = false; + + if (value == "none") + e.noStroke = true; + else if (value == "currentColor") + e.strokeCurrentColor = true; + else if (auto url = extractUrlId (value); url.isNotEmpty()) + e.strokeUrl = url; + else if (value.isNotEmpty()) + e.strokeColor = Color::fromString (value); + } + else if (property == "color") + { + if (value != "currentColor" && value != "inherit") + e.color = Color::fromString (value); + } + else if (property == "stroke-width") + { + float strokeWidth = parseUnit (value, e.strokeWidth.value_or (1.0f), e.fontSize.value_or (12.0f)); + if (strokeWidth >= 0.0f) + e.strokeWidth = strokeWidth; + } + else if (property == "stroke-linejoin") + { + if (value == "round") + e.strokeJoin = StrokeJoin::Round; + else if (value == "miter") + e.strokeJoin = StrokeJoin::Miter; + else if (value == "bevel") + e.strokeJoin = StrokeJoin::Bevel; + } + else if (property == "stroke-linecap") + { + if (value == "round") + e.strokeCap = StrokeCap::Round; + else if (value == "square") + e.strokeCap = StrokeCap::Square; + else if (value == "butt") + e.strokeCap = StrokeCap::Butt; + } + else if (property == "opacity") + { + float opacity = value.getFloatValue(); + if (opacity >= 0.0f && opacity <= 1.0f) + e.opacity = opacity; + } + else if (property == "display") + { + if (value == "none") + e.hidden = true; + } + else if (property == "visibility") + { + e.hidden = value == "hidden" || value == "collapse"; + } + else if (property == "font-family") + { + e.fontFamily = value.unquoted(); + } + else if (property == "font-size") + { + float fontSize = parseUnit (value, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); + if (fontSize > 0.0f) + e.fontSize = fontSize; + } + else if (property == "text-anchor") + { + e.textAnchor = value; + } + else if (property == "letter-spacing") + { + if (value != "normal") + e.letterSpacing = parseUnit (value, 0.0f, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); + } + else if (property == "word-spacing") + { + if (value != "normal") + e.wordSpacing = parseUnit (value, 0.0f, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); + } + else if (property == "clip-path") + { + String clipPathUrl = extractUrlId (value); + if (clipPathUrl.isNotEmpty()) + e.clipPathUrl = clipPathUrl; + } + else if (property == "stroke-dasharray") + { + if (value == "none") + e.strokeDashArray.reset(); + else + { + auto dashes = parseLengthList (value, e.fontSize.value_or (12.0f), 100.0f); + if (! dashes.isEmpty()) + e.strokeDashArray = dashes; } } + else if (property == "stroke-dashoffset") + { + e.strokeDashOffset = parseUnit (value); + } + else if (property == "fill-opacity") + { + float opacity = value.getFloatValue(); + if (opacity >= 0.0f && opacity <= 1.0f) + e.fillOpacity = opacity; + } + else if (property == "stroke-opacity") + { + float opacity = value.getFloatValue(); + if (opacity >= 0.0f && opacity <= 1.0f) + e.strokeOpacity = opacity; + } + else if (property == "fill-rule" || property == "clip-rule") + { + if (value == "evenodd" || value == "nonzero") + e.fillRule = value; + } +} + +//============================================================================== + +void Drawable::applyStylesheetRules (const XmlElement& xmlElement, Element& e) +{ + std::vector matchedRules; + + for (const auto& rule : cssRules) + { + if (matchesCssSelector (xmlElement, rule)) + matchedRules.push_back (std::addressof (rule)); + } + + std::stable_sort (matchedRules.begin(), matchedRules.end(), [] (const CssRule* a, const CssRule* b) + { + if (a->specificity != b->specificity) + return a->specificity < b->specificity; + + return a->order < b->order; + }); + + for (const auto* rule : matchedRules) + { + for (const auto& declaration : rule->declarations) + { + auto colonPos = declaration.indexOf (":"); + if (colonPos > 0) + applyStyleProperty (declaration.substring (0, colonPos).trim(), declaration.substring (colonPos + 1).trim(), e); + } + } +} + +void Drawable::parseStyleElement (const XmlElement& element) +{ + auto css = element.getAllSubText(); + int ruleOrder = static_cast (cssRules.size()); + + while (css.isNotEmpty()) + { + auto openBrace = css.indexOf ("{"); + auto closeBrace = css.indexOf ("}"); + if (openBrace <= 0 || closeBrace <= openBrace) + break; + + auto selectorText = css.substring (0, openBrace).trim(); + auto declarationText = css.substring (openBrace + 1, closeBrace).trim(); + + css = css.substring (closeBrace + 1); + + auto selectors = StringArray::fromTokens (selectorText, ",", ""); + auto declarations = StringArray::fromTokens (declarationText, ";", ""); + + for (auto selector : selectors) + { + selector = selector.trim(); + if (selector.isEmpty()) + continue; + + CssRule rule; + rule.selector = selector; + rule.declarations = declarations; + rule.order = ruleOrder++; + + if (selector.startsWithChar ('#')) + rule.specificity = 100; + else if (selector.startsWithChar ('.')) + rule.specificity = 10; + else if (selector.containsChar ('#')) + rule.specificity = 101; + else if (selector.containsChar ('.')) + rule.specificity = 11; + else + rule.specificity = 1; + + cssRules.push_back (std::move (rule)); + } + } +} + +bool Drawable::matchesCssSelector (const XmlElement& xmlElement, const CssRule& rule) const +{ + auto selector = rule.selector.trim(); + if (selector.isEmpty() || selector.containsChar (' ') || selector.containsChar ('>') || selector.containsChar ('+')) + return false; + + String tagName; + String id; + String className; + + auto hashIndex = selector.indexOf ("#"); + auto dotIndex = selector.indexOf ("."); + auto splitIndex = -1; + + if (hashIndex >= 0 && dotIndex >= 0) + splitIndex = jmin (hashIndex, dotIndex); + else + splitIndex = jmax (hashIndex, dotIndex); + + if (splitIndex > 0) + tagName = selector.substring (0, splitIndex); + + if (hashIndex == 0) + id = selector.substring (1); + else if (hashIndex > 0) + id = selector.substring (hashIndex + 1, dotIndex > hashIndex ? dotIndex : selector.length()); + + if (dotIndex == 0) + className = selector.substring (1); + else if (dotIndex > 0) + className = selector.substring (dotIndex + 1); + + if (splitIndex < 0 && ! selector.startsWithChar ('#') && ! selector.startsWithChar ('.')) + tagName = selector; + + if (tagName.isNotEmpty() && tagName != xmlElement.getTagNameWithoutNamespace()) + return false; + + if (id.isNotEmpty() && id != xmlElement.getStringAttribute ("id")) + return false; + + if (className.isNotEmpty()) + { + auto classes = StringArray::fromTokens (xmlElement.getStringAttribute ("class"), " \t\r\n", ""); + if (! classes.contains (className)) + return false; + } + + return tagName.isNotEmpty() || id.isNotEmpty() || className.isNotEmpty(); } //============================================================================== @@ -1518,7 +1989,13 @@ float Drawable::parseUnit (const String& value, float defaultValue, float fontSi // Extract numeric part and unit int unitStart = 0; - while (unitStart < trimmed.length() && (CharacterFunctions::isDigit (trimmed[unitStart]) || trimmed[unitStart] == '.' || trimmed[unitStart] == '-' || trimmed[unitStart] == '+')) + while (unitStart < trimmed.length() + && (CharacterFunctions::isDigit (trimmed[unitStart]) + || trimmed[unitStart] == '.' + || trimmed[unitStart] == '-' + || trimmed[unitStart] == '+' + || trimmed[unitStart] == 'e' + || trimmed[unitStart] == 'E')) { unitStart++; } @@ -1558,6 +2035,260 @@ float Drawable::parseUnit (const String& value, float defaultValue, float fontSi return numericValue; // Unknown unit, treat as user units } +float Drawable::parseLengthAttribute (const XmlElement& element, StringRef attributeName, float defaultValue, float fontSize, float viewportSize) +{ + auto value = element.getStringAttribute (attributeName); + if (value.isEmpty()) + return defaultValue; + + return parseUnit (value, defaultValue, fontSize, viewportSize); +} + +Array Drawable::parseLengthList (const String& value, float fontSize, float viewportSize) +{ + Array result; + auto tokens = StringArray::fromTokens (value, " ,\t\r\n", ""); + + for (const auto& token : tokens) + { + if (token.isNotEmpty()) + result.add (parseUnit (token, 0.0f, fontSize, viewportSize)); + } + + return result; +} + +std::optional Drawable::loadImageFromHref (const String& href) const +{ + if (parseOptions.imageResolver) + { + if (auto resolved = parseOptions.imageResolver (href, parseOptions.baseDirectory)) + return resolved; + } + + if (href.startsWithIgnoreCase ("http:") || href.startsWithIgnoreCase ("https:")) + return std::nullopt; + + MemoryBlock imageData; + + if (href.startsWithIgnoreCase ("data:")) + { + if (! parseOptions.allowDataImages) + return std::nullopt; + + auto comma = href.indexOfChar (','); + if (comma < 0) + return std::nullopt; + + auto metadata = href.substring (0, comma).toLowerCase(); + auto payload = href.substring (comma + 1); + + if (metadata.contains (";base64")) + { + MemoryOutputStream decoded; + if (! Base64::convertFromBase64 (decoded, payload)) + return std::nullopt; + + imageData = decoded.getMemoryBlock(); + } + else + { + imageData = MemoryBlock (payload.toRawUTF8(), static_cast (payload.getNumBytesAsUTF8())); + } + } + else + { + if (! parseOptions.allowLocalImages || parseOptions.baseDirectory.getFullPathName().isEmpty()) + return std::nullopt; + + auto imageFile = parseOptions.baseDirectory.getChildFile (href); + if (! imageFile.existsAsFile() || ! imageFile.loadFileAsData (imageData)) + return std::nullopt; + } + + if (imageData.isEmpty()) + return std::nullopt; + + auto result = Image::loadFromData (imageData.asBytes()); + if (result.failed()) + return std::nullopt; + + return result.getValue(); +} + +Font Drawable::resolveFont (const Element& element) const +{ + const auto fontSize = element.fontSize.value_or (12.0f); + + if (parseOptions.fontResolver) + { + if (auto resolved = parseOptions.fontResolver (element.fontFamily.value_or (String()), fontSize)) + return resolved->withHeight (fontSize); + } + + return Font().withHeight (fontSize); +} + +void Drawable::renderTextElement (Graphics& g, const Element& element) +{ + if (! element.text || ! element.textPosition || element.text->isEmpty()) + return; + + auto position = *element.textPosition; + + if (element.textX && ! element.textX->isEmpty()) + position.setX (element.textX->getFirst()); + if (element.textY && ! element.textY->isEmpty()) + position.setY (element.textY->getFirst()); + if (element.textDx && ! element.textDx->isEmpty()) + position.setX (position.getX() + element.textDx->getFirst()); + if (element.textDy && ! element.textDy->isEmpty()) + position.setY (position.getY() + element.textDy->getFirst()); + + const auto font = resolveFont (element); + const auto fontSize = element.fontSize.value_or (12.0f); + const auto textWidth = jmax (fontSize, static_cast (element.text->length()) * fontSize * 0.85f); + const auto textHeight = fontSize * 2.25f; + const auto textPadding = fontSize * 0.5f; + + auto textX = position.getX(); + if (element.textAnchor == "middle") + textX -= textWidth * 0.5f; + else if (element.textAnchor == "end") + textX -= textWidth; + + Rectangle textBounds (textX - textPadding, + position.getY() - (fontSize * 1.5f), + textWidth + (textPadding * 2.0f), + textHeight); + + StyledText styledText; + { + auto modifier = styledText.startUpdate(); + modifier.setMaxSize (Size (textBounds.getWidth(), textBounds.getHeight())); + modifier.setWrap (StyledText::noWrap); + modifier.setHorizontalAlign (StyledText::left); + modifier.appendText (*element.text, font, -1.0f, element.letterSpacing.value_or (0.0f)); + } + + g.fillFittedText (styledText, textBounds); +} + +void Drawable::renderImageElement (Graphics& g, const Element& element) +{ + if (! element.imageBounds) + return; + + if (element.image) + { + g.drawImage (*element.image, *element.imageBounds); + return; + } + + if (element.imageHref) + { + if (auto image = loadImageFromHref (*element.imageHref)) + g.drawImage (*image, *element.imageBounds); + } +} + +Path Drawable::createDashedPath (const Path& source, const Array& dashArray, float dashOffset) const +{ + if (dashArray.isEmpty()) + return source; + + Array positiveDashes; + for (auto dash : dashArray) + { + if (dash > 0.0f) + positiveDashes.add (dash); + } + + if (positiveDashes.isEmpty()) + return source; + + Path result; + float totalPatternLength = 0.0f; + for (auto dash : positiveDashes) + totalPatternLength += dash; + + if (totalPatternLength <= 0.0f) + return source; + + int dashIndex = 0; + float patternPosition = std::fmod (jmax (0.0f, dashOffset), totalPatternLength); + while (patternPosition > positiveDashes[dashIndex]) + { + patternPosition -= positiveDashes[dashIndex]; + dashIndex = (dashIndex + 1) % positiveDashes.size(); + } + + auto drawLineDash = [&] (Point start, Point end) + { + const auto length = start.distanceTo (end); + if (length <= 0.0f) + return; + + auto direction = (end - start) / length; + float distance = 0.0f; + + while (distance < length) + { + const auto remainingInDash = positiveDashes[dashIndex] - patternPosition; + const auto step = jmin (remainingInDash, length - distance); + + if ((dashIndex % 2) == 0 && step > 0.0f) + { + auto dashStart = start + direction * distance; + auto dashEnd = start + direction * (distance + step); + result.startNewSubPath (dashStart); + result.lineTo (dashEnd); + } + + distance += step; + patternPosition = 0.0f; + dashIndex = (dashIndex + 1) % positiveDashes.size(); + } + }; + + Point current; + Point subPathStart; + bool hasCurrent = false; + + for (const auto& segment : source) + { + switch (segment.verb) + { + case Path::Verb::MoveTo: + current = segment.point; + subPathStart = current; + hasCurrent = true; + break; + + case Path::Verb::LineTo: + if (hasCurrent) + drawLineDash (current, segment.point); + current = segment.point; + break; + + case Path::Verb::Close: + if (hasCurrent) + drawLineDash (current, subPathStart); + current = subPathStart; + break; + + case Path::Verb::QuadTo: + case Path::Verb::CubicTo: + if (hasCurrent) + drawLineDash (current, segment.point); + current = segment.point; + break; + } + } + + return result; +} + //============================================================================== Rectangle Drawable::calculateBounds() const @@ -1725,11 +2456,13 @@ Justification Drawable::parseAspectRatioAlignment (const String& preserveAspectR String Drawable::extractGradientUrl (const String& value) { - if (! value.contains ("url(#")) - return String(); + return extractUrlId (value); +} +String Drawable::extractUrlId (const String& value) +{ // Find the start of the URL - int urlStart = value.indexOf ("url(#"); + int urlStart = value.indexOf ("url("); if (urlStart == -1) return String(); @@ -1738,8 +2471,11 @@ String Drawable::extractGradientUrl (const String& value) if (urlEnd == -1) return String(); - // Extract the ID part (between "url(#" and ")") - String url = value.substring (urlStart + 5, urlEnd); // +5 to skip "url(#" + String url = value.substring (urlStart + 4, urlEnd).trim().unquoted(); + if (! url.startsWithChar ('#')) + return String(); + + url = url.substring (1); YUP_DRAWABLE_LOG ("Extracted gradient URL: '" << url << "' from: '" << value << "'"); return url; } diff --git a/modules/yup_graphics/drawables/yup_Drawable.h b/modules/yup_graphics/drawables/yup_Drawable.h index c8b6deb7d..45933ba21 100644 --- a/modules/yup_graphics/drawables/yup_Drawable.h +++ b/modules/yup_graphics/drawables/yup_Drawable.h @@ -31,6 +31,26 @@ namespace yup class YUP_API Drawable { public: + //============================================================================== + /** Options used when parsing SVG data. */ + struct ParseOptions + { + /** Base directory used to resolve local image hrefs. */ + File baseDirectory; + + /** Allows image data embedded as data: URIs. */ + bool allowDataImages = true; + + /** Allows local file image hrefs relative to baseDirectory. Network URLs are never loaded. */ + bool allowLocalImages = true; + + /** Optional custom image resolver. Return std::nullopt to use the default resolver. */ + std::function (StringRef href, const File& baseDirectory)> imageResolver; + + /** Optional custom font resolver. Return std::nullopt to use the default font. */ + std::function (StringRef family, float size)> fontResolver; + }; + //============================================================================== /** Constructor. */ Drawable(); @@ -44,6 +64,20 @@ class YUP_API Drawable */ bool parseSVG (const File& svgFile); + /** Parses SVG text. + + @param svgText The SVG XML text to parse. + + @return True if the SVG text was parsed successfully, false otherwise. + */ + bool parseSVG (StringRef svgText); + + /** Parses an SVG file with custom parse options. */ + bool parseSVG (const File& svgFile, const ParseOptions& options); + + /** Parses SVG text with custom parse options. */ + bool parseSVG (StringRef svgText, const ParseOptions& options); + //============================================================================== /** Clears the drawable. */ void clear(); @@ -80,7 +114,9 @@ class YUP_API Drawable { using Ptr = ReferenceCountedObjectPtr; + String tagName; std::optional id; + StringArray classNames; std::optional transform; std::optional localTransform; // Transform from the element itself (not accumulated) @@ -89,6 +125,9 @@ class YUP_API Drawable std::optional fillColor; std::optional strokeColor; + std::optional color; + bool fillCurrentColor = false; + bool strokeCurrentColor = false; std::optional fillOpacity; std::optional strokeOpacity; std::optional strokeWidth; @@ -108,6 +147,12 @@ class YUP_API Drawable std::optional fontFamily; std::optional fontSize; std::optional textAnchor; + std::optional letterSpacing; + std::optional wordSpacing; + std::optional> textX; + std::optional> textY; + std::optional> textDx; + std::optional> textDy; // Gradient properties std::optional fillUrl; @@ -116,9 +161,16 @@ class YUP_API Drawable // Image properties std::optional imageHref; std::optional> imageBounds; + std::optional image; // Clipping properties std::optional clipPathUrl; + std::optional> viewBox; + std::optional> viewportSize; + Fitting preserveAspectRatioFitting = Fitting::scaleToFit; + Justification preserveAspectRatioJustification = Justification::center; + bool isSymbol = false; + bool hidden = false; std::vector children; }; @@ -178,7 +230,15 @@ class YUP_API Drawable std::vector elements; }; - void paintElement (Graphics& g, const Element& element, bool hasParentFillEnabled, bool hasParentStrokeEnabled); + struct CssRule + { + String selector; + StringArray declarations; + int specificity = 0; + int order = 0; + }; + + void paintElement (Graphics& g, const Element& element, bool hasParentFillEnabled, bool hasParentStrokeEnabled, Color currentColor, int recursionDepth = 0); void paintDebugElement (Graphics& g, const Element& element); bool parseElement (const XmlElement& element, bool parentIsRoot, AffineTransform currentTransform, Element* parent = nullptr); void parseStyle (const XmlElement& element, const AffineTransform& currentTransform, Element& e); @@ -190,9 +250,22 @@ class YUP_API Drawable void parseClipPath (const XmlElement& element); ClipPath::Ptr getClipPathById (const String& id); void parseCSSStyle (const String& styleString, Element& e); + void applyStyleProperty (StringRef property, StringRef value, Element& e); + void applyStylesheetRules (const XmlElement& xmlElement, Element& e); + void parseStyleElement (const XmlElement& element); float parseUnit (const String& value, float defaultValue = 0.0f, float fontSize = 12.0f, float viewportSize = 100.0f); + float parseLengthAttribute (const XmlElement& element, StringRef attributeName, float defaultValue, float fontSize, float viewportSize); + Array parseLengthList (const String& value, float fontSize, float viewportSize); AffineTransform parseTransform (const String& transformString); String extractGradientUrl (const String& value); + String extractUrlId (const String& value); + bool parseDocument (std::unique_ptr svgRoot); + bool matchesCssSelector (const XmlElement& xmlElement, const CssRule& rule) const; + std::optional loadImageFromHref (const String& href) const; + Font resolveFont (const Element& element) const; + Path createDashedPath (const Path& source, const Array& dashArray, float dashOffset) const; + void renderTextElement (Graphics& g, const Element& element); + void renderImageElement (Graphics& g, const Element& element); // SVG preserveAspectRatio parsing Fitting parsePreserveAspectRatio (const String& preserveAspectRatio); @@ -212,6 +285,8 @@ class YUP_API Drawable HashMap gradientsById; std::vector clipPaths; HashMap clipPathsById; + std::vector cssRules; + ParseOptions parseOptions; // Root SVG element's default presentation attributes bool rootHasFill = true; // SVG default fill is black diff --git a/modules/yup_graphics/graphics/yup_Graphics.cpp b/modules/yup_graphics/graphics/yup_Graphics.cpp index 9200d3b27..5905f05fa 100644 --- a/modules/yup_graphics/graphics/yup_Graphics.cpp +++ b/modules/yup_graphics/graphics/yup_Graphics.cpp @@ -674,11 +674,19 @@ void Graphics::renderFillPath (const Path& path, const RenderOptions& options, c //============================================================================== void Graphics::drawImageAt (const Image& image, const Point& pos) +{ + drawImage (image, Rectangle (pos.getX(), pos.getY(), static_cast (image.getWidth()), static_cast (image.getHeight()))); +} + +void Graphics::drawImage (const Image& image, const Rectangle& targetArea) { auto renderContext = context.renderContext(); if (renderContext == nullptr) return; + if (targetArea.isEmpty()) + return; + if (! image.createTextureIfNotPresent (context)) return; @@ -690,6 +698,7 @@ void Graphics::drawImageAt (const Image& image, const Point& pos) unitRectPath->line ({ 1, 0 }); unitRectPath->line ({ 1, 1 }); unitRectPath->line ({ 0, 1 }); + unitRectPath->close(); return unitRectPath; }(); @@ -697,9 +706,12 @@ void Graphics::drawImageAt (const Image& image, const Point& pos) paint.image (image.getTexture(), jlimit (0.0f, 1.0f, options.opacity)); paint.blendMode (toBlendMode (options.blendMode)); + const auto imageTransform = AffineTransform::scaling (targetArea.getWidth(), targetArea.getHeight()) + .translated (targetArea.getX(), targetArea.getY()) + .followedBy (options.getTransform()); + renderer.save(); - renderer.scale (image.getWidth(), image.getHeight()); - renderer.transform (options.getTransform().toMat2D()); + renderer.transform (imageTransform.toMat2D()); renderer.drawPath (unitRectPath.get(), std::addressof (paint)); renderer.restore(); } @@ -795,7 +807,7 @@ void Graphics::renderFittedText (const StyledText& text, const Rectangle& rive::RawPath path; path.addRect (rect.toAABB()); - path.transformInPlace (options.getFixedTransform().toMat2D()); + path.transformInPlace (options.getTransform().toMat2D()); auto renderPath = rive::make_rcp (rive::FillRule::clockwise, path); renderer.clipPath (renderPath.get()); diff --git a/modules/yup_graphics/graphics/yup_Graphics.h b/modules/yup_graphics/graphics/yup_Graphics.h index 69482ca2d..389820477 100644 --- a/modules/yup_graphics/graphics/yup_Graphics.h +++ b/modules/yup_graphics/graphics/yup_Graphics.h @@ -454,6 +454,13 @@ class YUP_API Graphics */ void drawImageAt (const Image& image, const Point& pos); + /** Draws an image into a target rectangle. + + @param image The image to draw. + @param targetArea The destination rectangle in the current coordinate space. + */ + void drawImage (const Image& image, const Rectangle& targetArea); + //============================================================================== /** Draws an attributed text. diff --git a/modules/yup_graphics/primitives/yup_Path.cpp b/modules/yup_graphics/primitives/yup_Path.cpp index 9ff75d6c3..d092b215a 100644 --- a/modules/yup_graphics/primitives/yup_Path.cpp +++ b/modules/yup_graphics/primitives/yup_Path.cpp @@ -1122,7 +1122,7 @@ Path& Path::addArc (const Rectangle& rect, Path& Path::addCenteredArc (float centerX, float centerY, float radiusX, float radiusY, float rotationOfEllipse, float fromRadians, float toRadians, bool startAsNewSubPath) { - const int segments = jlimit (2, 54, static_cast ((toRadians - fromRadians) / 0.1f)); + const int segments = jlimit (2, 54, static_cast (std::ceil (std::abs (toRadians - fromRadians) / 0.1f))); const float delta = (toRadians - fromRadians) / segments; const float cosTheta = std::cos (rotationOfEllipse); diff --git a/modules/yup_gui/yup_gui.h b/modules/yup_gui/yup_gui.h index 9a2de2d1b..130177e09 100644 --- a/modules/yup_gui/yup_gui.h +++ b/modules/yup_gui/yup_gui.h @@ -66,7 +66,7 @@ Enable logging of windowing events like movement, resizes, mouse interactions. */ #ifndef YUP_ENABLE_WINDOWING_EVENT_LOGGING -#define YUP_ENABLE_WINDOWING_EVENT_LOGGING 1 +#define YUP_ENABLE_WINDOWING_EVENT_LOGGING 0 #endif //============================================================================== diff --git a/tests/yup_graphics/yup_Drawable.cpp b/tests/yup_graphics/yup_Drawable.cpp index 104010815..9c1ca39c0 100644 --- a/tests/yup_graphics/yup_Drawable.cpp +++ b/tests/yup_graphics/yup_Drawable.cpp @@ -156,6 +156,68 @@ TEST (DrawableTests, ParseMinimalValidSVG) tempFile.deleteFile(); } +TEST (DrawableTests, ParseSVGFromString) +{ + Drawable drawable; + + bool result = drawable.parseSVG (""); + + EXPECT_TRUE (result); + EXPECT_EQ (20.0f, drawable.getBounds().getWidth()); + EXPECT_EQ (10.0f, drawable.getBounds().getHeight()); + + auto context = GraphicsContext::createContext (GraphicsContext::Headless, {}); + auto renderer = context->makeRenderer (32, 32); + Graphics graphics (*context, *renderer); + + EXPECT_NO_THROW ({ + drawable.paint (graphics, Rectangle (0.0f, 0.0f, 32.0f, 32.0f)); + }); +} + +TEST (DrawableTests, ParseSVGFromStringWithCSSCascadeAndCurrentColor) +{ + Drawable drawable; + + bool result = drawable.parseSVG ( + "" + "" + "" + "" + ""); + + EXPECT_TRUE (result); +} + +TEST (DrawableTests, ParseSVGWithParseOptionsImageResolver) +{ + Drawable drawable; + + Drawable::ParseOptions options; + options.imageResolver = [] (StringRef href, const File&) -> std::optional + { + if (href == "custom-image") + { + Image image (2, 2, PixelFormat::RGBA); + image.fill (0xffff0000); + return image; + } + + return std::nullopt; + }; + + bool result = drawable.parseSVG ( + "" + "" + "", + options); + + EXPECT_TRUE (result); +} + TEST (DrawableTests, ParseSVGWithViewBox) { Drawable drawable; @@ -499,6 +561,91 @@ TEST (DrawableTests, ParseSVGWithNestedGroups) tempFile.deleteFile(); } +TEST (DrawableTests, ParseSVGWithSymbolAndUse) +{ + Drawable drawable; + + bool result = drawable.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + ""); + + EXPECT_TRUE (result); +} + +TEST (DrawableTests, ParseSVGWithDefsXLinkUseAndTspanFlow) +{ + Drawable drawable; + + bool result = drawable.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "first" + "second" + "" + ""); + + EXPECT_TRUE (result); + + auto context = GraphicsContext::createContext (GraphicsContext::Headless, {}); + auto renderer = context->makeRenderer (80, 40); + Graphics graphics (*context, *renderer); + + EXPECT_NO_THROW ({ + drawable.paint (graphics, Rectangle (0.0f, 0.0f, 80.0f, 40.0f)); + }); +} + +TEST (DrawableTests, ParseSVGWithUnitsNestedViewportTextAndDash) +{ + Drawable drawable; + + bool result = drawable.parseSVG ( + "" + "" + "" + "OK!" + "" + ""); + + EXPECT_TRUE (result); + EXPECT_EQ (192.0f, drawable.getBounds().getWidth()); + EXPECT_EQ (96.0f, drawable.getBounds().getHeight()); +} + +TEST (DrawableTests, ParseSVGWithTransformOrigin) +{ + Drawable drawable; + + bool result = drawable.parseSVG ( + "" + "" + "Sweep flag" + "" + ""); + + EXPECT_TRUE (result); + + auto context = GraphicsContext::createContext (GraphicsContext::Headless, {}); + auto renderer = context->makeRenderer (360, 360); + Graphics graphics (*context, *renderer); + + EXPECT_NO_THROW ({ + drawable.paint (graphics, Rectangle (0.0f, 0.0f, 360.0f, 360.0f)); + }); +} + // ============================================================================== // SVG Style Tests // ============================================================================== @@ -702,8 +849,7 @@ TEST (DrawableTests, ParseSVGWithInvalidPath) bool result = drawable.parseSVG (tempFile); - // Path::fromString always returns true, so parsing succeeds even with invalid data - EXPECT_TRUE (result); + EXPECT_FALSE (result); tempFile.deleteFile(); } diff --git a/tests/yup_graphics/yup_Path.cpp b/tests/yup_graphics/yup_Path.cpp index fb5ec9d20..c9d0dc6ca 100644 --- a/tests/yup_graphics/yup_Path.cpp +++ b/tests/yup_graphics/yup_Path.cpp @@ -151,6 +151,14 @@ TEST (PathTests, AddArc) EXPECT_FALSE (p.getBounds().isEmpty()); } +TEST (PathTests, AddCenteredArcUsesAbsoluteSweepForSegments) +{ + Path p; + p.addCenteredArc (100.0f, 145.0f, 45.0f, 45.0f, 0.0f, -MathConstants::halfPi, -MathConstants::pi, true); + + EXPECT_GT (p.size(), 4); +} + TEST (PathTests, AddPolygon) { Path p; From f23f52b050ad4eddc32212090cd4b9896f9ae8ff Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 11:28:05 +0200 Subject: [PATCH 02/18] More work on SVG --- examples/graphics/data/svg/mozilla2.svg | 60 + .../yup_graphics/drawables/yup_Drawable.cpp | 1362 ++++++++++++++++- modules/yup_graphics/drawables/yup_Drawable.h | 26 + .../yup_graphics/graphics/yup_Graphics.cpp | 18 +- modules/yup_graphics/graphics/yup_Graphics.h | 7 +- 5 files changed, 1417 insertions(+), 56 deletions(-) create mode 100644 examples/graphics/data/svg/mozilla2.svg diff --git a/examples/graphics/data/svg/mozilla2.svg b/examples/graphics/data/svg/mozilla2.svg new file mode 100644 index 000000000..8f537cb8c --- /dev/null +++ b/examples/graphics/data/svg/mozilla2.svg @@ -0,0 +1,60 @@ + + + + + + + Large arc flag + + Sweep flag + + 0 + 1 + 0 + 1 + + + + + + + + + + + + + \ No newline at end of file diff --git a/modules/yup_graphics/drawables/yup_Drawable.cpp b/modules/yup_graphics/drawables/yup_Drawable.cpp index ccba7298d..c28898181 100644 --- a/modules/yup_graphics/drawables/yup_Drawable.cpp +++ b/modules/yup_graphics/drawables/yup_Drawable.cpp @@ -45,6 +45,8 @@ Drawable::Drawable() bool Drawable::parseSVG (const File& svgFile) { + YUP_DRAWABLE_LOG ("parseSVG(file) - file: " << svgFile.getFullPathName()); + ParseOptions options; options.baseDirectory = svgFile.getParentDirectory(); @@ -53,11 +55,20 @@ bool Drawable::parseSVG (const File& svgFile) bool Drawable::parseSVG (StringRef svgText) { + YUP_DRAWABLE_LOG ("parseSVG(text) - length: " << String (svgText.text).length()); + return parseSVG (svgText, ParseOptions()); } bool Drawable::parseSVG (const File& svgFile, const ParseOptions& options) { + YUP_DRAWABLE_LOG ("parseSVG(file, options) - file: " << svgFile.getFullPathName() + << " baseDirectory: " << options.baseDirectory.getFullPathName() + << " allowDataImages: " << (options.allowDataImages ? "true" : "false") + << " allowLocalImages: " << (options.allowLocalImages ? "true" : "false") + << " hasImageResolver: " << (options.imageResolver ? "true" : "false") + << " hasFontResolver: " << (options.fontResolver ? "true" : "false")); + clear(); parseOptions = options; @@ -72,6 +83,13 @@ bool Drawable::parseSVG (const File& svgFile, const ParseOptions& options) bool Drawable::parseSVG (StringRef svgText, const ParseOptions& options) { + YUP_DRAWABLE_LOG ("parseSVG(text, options) - length: " << String (svgText.text).length() + << " baseDirectory: " << options.baseDirectory.getFullPathName() + << " allowDataImages: " << (options.allowDataImages ? "true" : "false") + << " allowLocalImages: " << (options.allowLocalImages ? "true" : "false") + << " hasImageResolver: " << (options.imageResolver ? "true" : "false") + << " hasFontResolver: " << (options.fontResolver ? "true" : "false")); + clear(); parseOptions = options; @@ -85,7 +103,14 @@ bool Drawable::parseSVG (StringRef svgText, const ParseOptions& options) bool Drawable::parseDocument (std::unique_ptr svgRoot) { if (svgRoot == nullptr || ! svgRoot->hasTagName ("svg")) + { + YUP_DRAWABLE_LOG ("parseDocument failed - root is " << (svgRoot == nullptr ? "null" : svgRoot->getTagName())); return false; + } + + YUP_DRAWABLE_LOG ("parseDocument - root attributes width: " << svgRoot->getStringAttribute ("width") + << " height: " << svgRoot->getStringAttribute ("height") + << " viewBox: " << svgRoot->getStringAttribute ("viewBox")); if (auto view = svgRoot->getStringAttribute ("viewBox"); view.isNotEmpty()) { @@ -97,6 +122,10 @@ bool Drawable::parseDocument (std::unique_ptr svgRoot) viewBox.setWidth (coords.getReference (2).getFloatValue()); viewBox.setHeight (coords.getReference (3).getFloatValue()); } + else + { + YUP_DRAWABLE_LOG ("Invalid root viewBox - expected 4 coordinates, got: " << coords.size() << " value: " << view); + } } auto width = svgRoot->getFloatAttribute ("width"); @@ -122,7 +151,25 @@ bool Drawable::parseDocument (std::unique_ptr svgRoot) auto result = parseElement (*svgRoot, true, {}); if (result) + { bounds = calculateBounds(); + YUP_DRAWABLE_LOG ("parseDocument result - success: true" + << " topLevelElements: " << elements.size() + << " ids: " << elementsById.size() + << " gradients: " << gradients.size() + << " filters: " << filters.size() + << " clipPaths: " << clipPaths.size() + << " cssRules: " << cssRules.size() + << " bounds: " << bounds.toString() + << " rootHasFill: " << (rootHasFill ? "true" : "false") + << " rootHasStroke: " << (rootHasStroke ? "true" : "false") + << " rootFillColor: " << (rootFillColor ? rootFillColor->toString() : "none") + << " rootStrokeColor: " << (rootStrokeColor ? rootStrokeColor->toString() : "none")); + } + else + { + YUP_DRAWABLE_LOG ("parseDocument result - success: false"); + } return result; } @@ -131,6 +178,13 @@ bool Drawable::parseDocument (std::unique_ptr svgRoot) void Drawable::clear() { + YUP_DRAWABLE_LOG ("clear - previous topLevelElements: " << elements.size() + << " ids: " << elementsById.size() + << " gradients: " << gradients.size() + << " filters: " << filters.size() + << " clipPaths: " << clipPaths.size() + << " cssRules: " << cssRules.size()); + viewBox = { 0.0f, 0.0f, 0.0f, 0.0f }; size = { 0.0f, 0.0f }; bounds = { 0.0f, 0.0f, 0.0f, 0.0f }; @@ -140,6 +194,8 @@ void Drawable::clear() elementsById.clear(); gradients.clear(); gradientsById.clear(); + filters.clear(); + filtersById.clear(); clipPaths.clear(); clipPathsById.clear(); cssRules.clear(); @@ -162,6 +218,15 @@ Rectangle Drawable::getBounds() const void Drawable::paint (Graphics& g) { + YUP_DRAWABLE_LOG ("paint - bounds: " << bounds.toString() + << " viewBox: " << viewBox.toString() + << " size: " << size.getWidth() << "x" << size.getHeight() + << " topLevelElements: " << elements.size() + << " drawableTransform: " << transform.toString() + << " graphicsTransform: " << g.getTransform().toString() + << " rootHasFill: " << (rootHasFill ? "true" : "false") + << " rootHasStroke: " << (rootHasStroke ? "true" : "false")); + const auto savedState = g.saveState(); g.setStrokeWidth (1.0f); @@ -177,7 +242,10 @@ void Drawable::paint (Graphics& g) g.setStrokeColor (*rootStrokeColor); if (! transform.isIdentity()) + { + YUP_DRAWABLE_LOG ("paint - applying drawable transform: " << transform.toString()); g.addTransform (transform); + } // Pass root element's fill/stroke state to top-level elements for (const auto& element : elements) @@ -189,12 +257,19 @@ void Drawable::paint (Graphics& g, const Rectangle& targetArea, Fitting f YUP_DRAWABLE_LOG ("Fitted paint called - bounds: " << bounds.toString() << " targetArea: " << targetArea.toString()); if (bounds.isEmpty()) + { + YUP_DRAWABLE_LOG ("Fitted paint skipped - drawable bounds are empty"); return; + } const auto savedState = g.saveState(); auto finalBounds = viewBox.isEmpty() ? bounds : viewBox; auto finalTransform = calculateTransformForTarget (finalBounds, targetArea, fitting, justification); + YUP_DRAWABLE_LOG ("Fitted paint transform - sourceBounds: " << finalBounds.toString() + << " transform: " << finalTransform.toString() + << " graphicsTransformBefore: " << g.getTransform().toString() + << " topLevelElements: " << elements.size()); if (! finalTransform.isIdentity()) g.addTransform (finalTransform); @@ -219,8 +294,21 @@ void Drawable::paint (Graphics& g, const Rectangle& targetArea, Fitting f void Drawable::paintElement (Graphics& g, const Element& element, bool hasParentFillEnabled, bool hasParentStrokeEnabled, Color currentColor, int recursionDepth) { - if (element.hidden || recursionDepth > 64) + if (element.hidden) + { + YUP_DRAWABLE_LOG ("paintElement skipped - hidden tag: " << element.tagName + << " id: " << (element.id ? *element.id : "none") + << " depth: " << recursionDepth); return; + } + + if (recursionDepth > 64) + { + YUP_DRAWABLE_LOG ("paintElement skipped - recursion limit tag: " << element.tagName + << " id: " << (element.id ? *element.id : "none") + << " depth: " << recursionDepth); + return; + } const auto savedState = g.saveState(); @@ -229,7 +317,24 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent if (element.color) currentColor = *element.color; - YUP_DRAWABLE_LOG ("paintElement called - hasPath: " << (element.path ? "true" : "false") << " hasTransform: " << (element.transform ? "true" : "false")); + const auto transformBeforeElement = g.getTransform(); + + YUP_DRAWABLE_LOG ("paintElement - tag: " << element.tagName + << " id: " << (element.id ? *element.id : "none") + << " depth: " << recursionDepth + << " hasPath: " << (element.path ? "true" : "false") + << " pathBounds: " << (element.path ? element.path->getBounds().toString() : "none") + << " hasTransform: " << (element.transform ? "true" : "false") + << " hasReference: " << (element.reference ? "true" : "false") + << " reference: " << (element.reference ? *element.reference : "none") + << " filter: " << element.filterUrl.value_or (String ("none")) + << " children: " << element.children.size() + << " parentFill: " << (hasParentFillEnabled ? "true" : "false") + << " parentStroke: " << (hasParentStrokeEnabled ? "true" : "false") + << " noFill: " << (element.noFill ? "true" : "false") + << " noStroke: " << (element.noStroke ? "true" : "false") + << " graphicsTransform: " << g.getTransform().toString() + << " opacity: " << g.getOpacity()); // Apply element transform if present - use proper composition for coordinate systems if (element.transform) @@ -242,12 +347,48 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent } if (element.opacity) + { + YUP_DRAWABLE_LOG ("Applying opacity - tag: " << element.tagName + << " elementOpacity: " << *element.opacity + << " graphicsOpacityBefore: " << g.getOpacity()); g.setOpacity (g.getOpacity() * (*element.opacity)); + YUP_DRAWABLE_LOG ("After opacity - graphicsOpacity: " << g.getOpacity()); + } + + if (element.filterUrl) + { + YUP_DRAWABLE_LOG ("Resolving filter - tag: " << element.tagName << " filter: " << *element.filterUrl); + + if (auto filter = resolveFilter (getFilterById (*element.filterUrl))) + { + if (filter->gaussianBlurStdDeviation) + { + const auto feather = jmax (g.getFeather(), *filter->gaussianBlurStdDeviation); + YUP_DRAWABLE_LOG ("Applying GaussianBlur filter as feather - id: " << *element.filterUrl + << " stdDeviation: " << *filter->gaussianBlurStdDeviation + << " previousFeather: " << g.getFeather() + << " appliedFeather: " << feather); + g.setFeather (feather); + } + else + { + YUP_DRAWABLE_LOG ("Filter resolved without supported primitives - id: " << *element.filterUrl); + } + } + else + { + YUP_DRAWABLE_LOG ("Filter not found - id: " << *element.filterUrl); + } + } if (element.viewBox && element.viewportSize) { Rectangle viewport (0.0f, 0.0f, element.viewportSize->getWidth(), element.viewportSize->getHeight()); auto viewBoxTransform = calculateTransformForTarget (*element.viewBox, viewport, element.preserveAspectRatioFitting, element.preserveAspectRatioJustification); + YUP_DRAWABLE_LOG ("Applying nested viewBox - tag: " << element.tagName + << " viewBox: " << element.viewBox->toString() + << " viewport: " << viewport.toString() + << " transform: " << viewBoxTransform.toString()); if (! viewBoxTransform.isIdentity()) g.addTransform (viewBoxTransform); } @@ -256,16 +397,59 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent bool hasClipping = false; if (element.clipPathUrl) { + YUP_DRAWABLE_LOG ("Resolving clip path - tag: " << element.tagName << " clipPath: " << *element.clipPathUrl); + if (auto clipPath = getClipPathById (*element.clipPathUrl)) { + std::optional> clipObjectBounds; + + if (clipPath->units == ClipPath::ObjectBoundingBox) + { + if (element.path) + clipObjectBounds = element.path->getBounds(); + else if (element.reference) + { + if (auto refElement = elementsById[*element.reference]; refElement != nullptr && refElement->path) + clipObjectBounds = refElement->path->getBounds(); + } + else if (element.imageBounds) + { + clipObjectBounds = *element.imageBounds; + } + + YUP_DRAWABLE_LOG ("Clip path uses objectBoundingBox units - id: " << *element.clipPathUrl + << " hasObjectBounds: " << (clipObjectBounds ? "true" : "false") + << " objectBounds: " << (clipObjectBounds ? clipObjectBounds->toString() : String ("none"))); + } + // Create a combined path from all clip path elements Path combinedClipPath; + bool clipUsesNonZeroWinding = true; + for (const auto& clipElement : clipPath->elements) { if (clipElement->path) { - if (clipElement->transform) - combinedClipPath.appendPath (*clipElement->path, *clipElement->transform); + if (! clipElement->path->isUsingNonZeroWinding()) + clipUsesNonZeroWinding = false; + + AffineTransform clipTransform = clipElement->transform.value_or (AffineTransform::identity()); + + if (clipPath->units == ClipPath::ObjectBoundingBox) + { + if (! clipObjectBounds || clipObjectBounds->isEmpty()) + { + YUP_DRAWABLE_LOG ("Skipping objectBoundingBox clip path without bounds - id: " << *element.clipPathUrl); + continue; + } + + auto unitsTransform = AffineTransform::translation (clipObjectBounds->getX(), clipObjectBounds->getY()) + .scaled (clipObjectBounds->getWidth(), clipObjectBounds->getHeight()); + clipTransform = clipTransform.followedBy (unitsTransform); + } + + if (! clipTransform.isIdentity()) + combinedClipPath.appendPath (*clipElement->path, clipTransform); else combinedClipPath.appendPath (*clipElement->path); } @@ -273,10 +457,39 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent if (! combinedClipPath.isEmpty()) { + combinedClipPath.setUsingNonZeroWinding (clipUsesNonZeroWinding); + + const auto transformDuringClip = g.getTransform(); + if (element.transform) + { + YUP_DRAWABLE_LOG ("Applying clip path in pre-element transform space - id: " << *element.clipPathUrl + << " clipTransform: " << transformBeforeElement.toString() + << " elementTransform: " << element.transform->toString() + << " drawTransform: " << transformDuringClip.toString()); + g.setTransform (transformBeforeElement); + } + g.setClipPath (combinedClipPath); + + if (element.transform) + g.setTransform (transformDuringClip); + hasClipping = true; + YUP_DRAWABLE_LOG ("Applied clip path - id: " << *element.clipPathUrl + << " elements: " << clipPath->elements.size() + << " fillRule: " << (clipUsesNonZeroWinding ? "nonzero" : "evenodd") + << " bounds: " << combinedClipPath.getBounds().toString()); + } + else + { + YUP_DRAWABLE_LOG ("Clip path resolved but empty - id: " << *element.clipPathUrl + << " elements: " << clipPath->elements.size()); } } + else + { + YUP_DRAWABLE_LOG ("Clip path not found - id: " << *element.clipPathUrl); + } } // Setup fill @@ -287,6 +500,7 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent fillColor = fillColor.withMultipliedAlpha (*element.fillOpacity); g.setFillColor (fillColor); isFillDefined = true; + YUP_DRAWABLE_LOG ("Fill color selected - tag: " << element.tagName << " color: " << fillColor.toString()); } else if (element.fillCurrentColor) { @@ -295,6 +509,7 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent fillColor = fillColor.withMultipliedAlpha (*element.fillOpacity); g.setFillColor (fillColor); isFillDefined = true; + YUP_DRAWABLE_LOG ("Fill currentColor selected - tag: " << element.tagName << " color: " << fillColor.toString()); } else if (element.fillUrl) { @@ -328,6 +543,11 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent { // Inherit parent fill - don't change graphics state, just mark as defined isFillDefined = true; + YUP_DRAWABLE_LOG ("Fill inherited - tag: " << element.tagName); + } + else + { + YUP_DRAWABLE_LOG ("No fill selected - tag: " << element.tagName); } if (isFillDefined && ! element.noFill) @@ -339,6 +559,10 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent // TODO: Apply fill-rule when Graphics class supports it // if (element.fillRule) // g.setFillRule (*element.fillRule == "evenodd" ? FillRule::EvenOdd : FillRule::NonZero); + YUP_DRAWABLE_LOG ("Filling path - tag: " << element.tagName + << " id: " << (element.id ? *element.id : "none") + << " bounds: " << element.path->getBounds().toString() + << " clip: " << (hasClipping ? "true" : "false")); g.fillPath (*element.path); } else if (element.reference) @@ -386,16 +610,43 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent if (refElement->localTransform) g.setTransform (savedTransform); } + else + { + YUP_DRAWABLE_LOG ("Use fill skipped - reference disables fill: " << *element.reference); + } + } + else + { + YUP_DRAWABLE_LOG ("Use fill skipped - missing reference/path: " << *element.reference); } } else if (element.text && element.textPosition) { + YUP_DRAWABLE_LOG ("Rendering text - tag: " << element.tagName + << " textLength: " << element.text->length() + << " position: " << element.textPosition->toString()); renderTextElement (g, element); } else if ((element.imageHref || element.image) && element.imageBounds) { + YUP_DRAWABLE_LOG ("Rendering image - tag: " << element.tagName + << " href: " << (element.imageHref ? *element.imageHref : "embedded") + << " bounds: " << element.imageBounds->toString() + << " alreadyLoaded: " << (element.image ? "true" : "false")); renderImageElement (g, element); } + else + { + YUP_DRAWABLE_LOG ("Fill branch had nothing to render - tag: " << element.tagName + << " hasText: " << (element.text ? "true" : "false") + << " hasImage: " << ((element.imageHref || element.image) ? "true" : "false")); + } + } + else + { + YUP_DRAWABLE_LOG ("Fill skipped - tag: " << element.tagName + << " isFillDefined: " << (isFillDefined ? "true" : "false") + << " noFill: " << (element.noFill ? "true" : "false")); } // Setup stroke @@ -406,6 +657,7 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent strokeColor = strokeColor.withMultipliedAlpha (*element.strokeOpacity); g.setStrokeColor (strokeColor); isStrokeDefined = true; + YUP_DRAWABLE_LOG ("Stroke color selected - tag: " << element.tagName << " color: " << strokeColor.toString()); } else if (element.strokeCurrentColor) { @@ -414,9 +666,11 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent strokeColor = strokeColor.withMultipliedAlpha (*element.strokeOpacity); g.setStrokeColor (strokeColor); isStrokeDefined = true; + YUP_DRAWABLE_LOG ("Stroke currentColor selected - tag: " << element.tagName << " color: " << strokeColor.toString()); } else if (element.strokeUrl) { + YUP_DRAWABLE_LOG ("Looking for stroke gradient with ID: " << *element.strokeUrl); if (auto gradient = getGradientById (*element.strokeUrl)) { auto resolvedGradient = resolveGradient (gradient); @@ -434,22 +688,41 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent gradientBounds ? std::addressof (*gradientBounds) : nullptr); g.setStrokeColorGradient (colorGradient); isStrokeDefined = true; + YUP_DRAWABLE_LOG ("Applied gradient to stroke"); + } + else + { + YUP_DRAWABLE_LOG ("Stroke gradient not found for ID: " << *element.strokeUrl); } } else if (hasParentStrokeEnabled) { // Inherit parent stroke - don't change graphics state, just mark as defined isStrokeDefined = true; + YUP_DRAWABLE_LOG ("Stroke inherited - tag: " << element.tagName); + } + else + { + YUP_DRAWABLE_LOG ("No stroke selected - tag: " << element.tagName); } if (element.strokeJoin) + { + YUP_DRAWABLE_LOG ("Applying stroke join - tag: " << element.tagName); g.setStrokeJoin (*element.strokeJoin); + } if (element.strokeCap) + { + YUP_DRAWABLE_LOG ("Applying stroke cap - tag: " << element.tagName); g.setStrokeCap (*element.strokeCap); + } if (element.strokeWidth) + { + YUP_DRAWABLE_LOG ("Applying stroke width - tag: " << element.tagName << " width: " << *element.strokeWidth); g.setStrokeWidth (*element.strokeWidth); + } // Apply stroke dash patterns if (element.strokeDashArray) @@ -463,6 +736,9 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent // g.setStrokeDashPattern (dashArray.getRawDataPointer(), dashArray.size()); // if (element.strokeDashOffset) // g.setStrokeDashOffset (*element.strokeDashOffset); + YUP_DRAWABLE_LOG ("Stroke dash array parsed - tag: " << element.tagName + << " count: " << dashArray.size() + << " offset: " << element.strokeDashOffset.value_or (0.0f)); } } @@ -482,10 +758,17 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent { dashedPath = createDashedPath (*pathToStroke, *element.strokeDashArray, element.strokeDashOffset.value_or (0.0f)); pathToStroke = std::addressof (*dashedPath); + YUP_DRAWABLE_LOG ("Created dashed stroke path - tag: " << element.tagName + << " sourceBounds: " << element.path->getBounds().toString() + << " dashedBounds: " << dashedPath->getBounds().toString()); } if (pathToStroke != nullptr) { + YUP_DRAWABLE_LOG ("Stroking path - tag: " << element.tagName + << " id: " << (element.id ? *element.id : "none") + << " bounds: " << pathToStroke->getBounds().toString() + << " referenceDefinesStroke: " << (referenceDefinesStroke ? "true" : "false")); g.strokePath (*pathToStroke); } else if (element.reference) @@ -537,8 +820,23 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent if (refElement->localTransform) g.setTransform (savedTransform); } + else + { + YUP_DRAWABLE_LOG ("Use stroke skipped - missing reference/path: " << *element.reference); + } + } + else + { + YUP_DRAWABLE_LOG ("Stroke branch had no path/reference - tag: " << element.tagName); } } + else + { + YUP_DRAWABLE_LOG ("Stroke skipped - tag: " << element.tagName + << " isStrokeDefined: " << (isStrokeDefined ? "true" : "false") + << " referenceDefinesStroke: " << (referenceDefinesStroke ? "true" : "false") + << " noStroke: " << (element.noStroke ? "true" : "false")); + } if (element.reference) { @@ -553,15 +851,27 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent auto viewportSizeToUse = element.viewportSize.value_or (refElement->viewportSize.value_or (Size (refElement->viewBox->getWidth(), refElement->viewBox->getHeight()))); Rectangle viewport (0.0f, 0.0f, viewportSizeToUse.getWidth(), viewportSizeToUse.getHeight()); auto viewBoxTransform = calculateTransformForTarget (*refElement->viewBox, viewport, refElement->preserveAspectRatioFitting, refElement->preserveAspectRatioJustification); + YUP_DRAWABLE_LOG ("Applying referenced viewBox - reference: " << *element.reference + << " viewBox: " << refElement->viewBox->toString() + << " viewport: " << viewport.toString() + << " transform: " << viewBoxTransform.toString()); if (! viewBoxTransform.isIdentity()) g.addTransform (viewBoxTransform); } + YUP_DRAWABLE_LOG ("Rendering referenced children - reference: " << *element.reference + << " childCount: " << refElement->children.size() + << " transform: " << g.getTransform().toString()); + for (const auto& childElement : refElement->children) paintElement (g, *childElement, isFillDefined && ! element.noFill, isStrokeDefined && ! element.noStroke, currentColor, recursionDepth + 1); g.setTransform (savedTransform); } + else + { + YUP_DRAWABLE_LOG ("No referenced children rendered - reference: " << *element.reference); + } } for (const auto& childElement : element.children) @@ -583,6 +893,12 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin bool isRootElement = element.hasTagName ("svg"); e->tagName = element.getTagNameWithoutNamespace(); + YUP_DRAWABLE_LOG ("parseElement - tag: " << e->tagName + << " id: " << element.getStringAttribute ("id", "none") + << " parent: " << (parent != nullptr ? parent->tagName : "none") + << " parentIsRoot: " << (parentIsRoot ? "true" : "false") + << " currentTransform: " << currentTransform.toString()); + if (auto classes = element.getStringAttribute ("class"); classes.isNotEmpty()) e->classNames = StringArray::fromTokens (classes, " \t\r\n", ""); @@ -604,12 +920,25 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin String pathData = element.getStringAttribute ("d"); auto trimmedPathData = pathData.trimStart(); if (trimmedPathData.isNotEmpty() && ! String ("MmZzLlHhVvCcSsQqTtAa").containsChar (trimmedPathData[0])) + { + YUP_DRAWABLE_LOG ("parseElement failed - invalid path command start: " << trimmedPathData[0] + << " id: " << element.getStringAttribute ("id", "none")); return false; + } if (! pathData.isEmpty() && ! path.fromString (pathData)) + { + YUP_DRAWABLE_LOG ("parseElement failed - path.fromString failed" + << " id: " << element.getStringAttribute ("id", "none") + << " pathLength: " << pathData.length()); return false; + } e->path = std::move (path); + YUP_DRAWABLE_LOG ("Parsed path - id: " << element.getStringAttribute ("id", "none") + << " pathLength: " << pathData.length() + << " bounds: " << e->path->getBounds().toString() + << " empty: " << (e->path->isEmpty() ? "true" : "false")); currentTransform = parseTransform (element, currentTransform, *e); parseStyle (element, currentTransform, *e); @@ -632,6 +961,10 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin if (href.isNotEmpty() && href.startsWith ("#")) e->reference = href.substring (1); + YUP_DRAWABLE_LOG ("Parsed use attributes - id: " << element.getStringAttribute ("id", "none") + << " href: " << href + << " reference: " << (e->reference ? *e->reference : "none")); + // Handle x,y positioning for use elements (SVG spec requirement) auto x = parseLengthAttribute (element, "x", 0.0f, inheritedFontSize, viewportWidth); auto y = parseLengthAttribute (element, "y", 0.0f, inheritedFontSize, viewportHeight); @@ -640,6 +973,13 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin if (width > 0.0f && height > 0.0f) e->viewportSize = Size (width, height); + YUP_DRAWABLE_LOG ("Parsed use geometry - reference: " << (e->reference ? *e->reference : "none") + << " x: " << x + << " y: " << y + << " width: " << width + << " height: " << height + << " hasViewportSize: " << (e->viewportSize ? "true" : "false")); + AffineTransform useTransform; if (x != 0.0f || y != 0.0f) useTransform = AffineTransform::translation (x, y); @@ -668,6 +1008,13 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin path.addCenteredEllipse (cx, cy, rx, ry); e->path = std::move (path); + YUP_DRAWABLE_LOG ("Parsed ellipse - id: " << element.getStringAttribute ("id", "none") + << " cx: " << cx + << " cy: " << cy + << " rx: " << rx + << " ry: " << ry + << " bounds: " << e->path->getBounds().toString()); + currentTransform = parseTransform (element, currentTransform, *e); parseStyle (element, currentTransform, *e); } @@ -681,6 +1028,12 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin path.addCenteredEllipse (cx, cy, r, r); e->path = std::move (path); + YUP_DRAWABLE_LOG ("Parsed circle - id: " << element.getStringAttribute ("id", "none") + << " cx: " << cx + << " cy: " << cy + << " r: " << r + << " bounds: " << e->path->getBounds().toString()); + currentTransform = parseTransform (element, currentTransform, *e); parseStyle (element, currentTransform, *e); } @@ -710,6 +1063,15 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin e->path = std::move (path); + YUP_DRAWABLE_LOG ("Parsed rect - id: " << element.getStringAttribute ("id", "none") + << " x: " << x + << " y: " << y + << " width: " << width + << " height: " << height + << " rx: " << rx + << " ry: " << ry + << " bounds: " << e->path->getBounds().toString()); + currentTransform = parseTransform (element, currentTransform, *e); parseStyle (element, currentTransform, *e); } @@ -725,6 +1087,13 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin path.lineTo (x2, y2); e->path = std::move (path); + YUP_DRAWABLE_LOG ("Parsed line - id: " << element.getStringAttribute ("id", "none") + << " x1: " << x1 + << " y1: " << y1 + << " x2: " << x2 + << " y2: " << y2 + << " bounds: " << e->path->getBounds().toString()); + currentTransform = parseTransform (element, currentTransform, *e); parseStyle (element, currentTransform, *e); } @@ -747,6 +1116,14 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin } e->path = std::move (path); + YUP_DRAWABLE_LOG ("Parsed polygon - id: " << element.getStringAttribute ("id", "none") + << " coordinateCount: " << coords.size() + << " bounds: " << e->path->getBounds().toString() + << " empty: " << (e->path->isEmpty() ? "true" : "false")); + } + else + { + YUP_DRAWABLE_LOG ("Parsed polygon with empty points - id: " << element.getStringAttribute ("id", "none")); } currentTransform = parseTransform (element, currentTransform, *e); @@ -769,6 +1146,14 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin } e->path = std::move (path); + YUP_DRAWABLE_LOG ("Parsed polyline - id: " << element.getStringAttribute ("id", "none") + << " coordinateCount: " << coords.size() + << " bounds: " << e->path->getBounds().toString() + << " empty: " << (e->path->isEmpty() ? "true" : "false")); + } + else + { + YUP_DRAWABLE_LOG ("Parsed polyline with empty points - id: " << element.getStringAttribute ("id", "none")); } currentTransform = parseTransform (element, currentTransform, *e); @@ -777,6 +1162,21 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin else if (element.hasTagName ("text") || element.hasTagName ("tspan")) { const auto defaultTextPosition = parent != nullptr && parent->textPosition ? *parent->textPosition : Point (0.0f, 0.0f); + YUP_DRAWABLE_LOG ("Parsing text font inputs - tag: " << e->tagName + << " id: " << element.getStringAttribute ("id", "none") + << " inheritedFontSize: " << inheritedFontSize + << " parentFontFamily: " << (parent != nullptr && parent->fontFamily ? *parent->fontFamily : "none") + << " parentFontSize: " << (parent != nullptr && parent->fontSize ? String (*parent->fontSize) : String ("none")) + << " rawFontFamily: " << element.getStringAttribute ("font-family") + << " rawFontSize: " << element.getStringAttribute ("font-size") + << " rawFontWeight: " << element.getStringAttribute ("font-weight") + << " rawFontStyle: " << element.getStringAttribute ("font-style") + << " rawFontStretch: " << element.getStringAttribute ("font-stretch") + << " rawTextAnchor: " << element.getStringAttribute ("text-anchor") + << " rawDominantBaseline: " << element.getStringAttribute ("dominant-baseline") + << " rawAlignmentBaseline: " << element.getStringAttribute ("alignment-baseline") + << " rawBaselineShift: " << element.getStringAttribute ("baseline-shift")); + float x = parseLengthAttribute (element, "x", defaultTextPosition.getX(), inheritedFontSize, viewportWidth); float y = parseLengthAttribute (element, "y", defaultTextPosition.getY(), inheritedFontSize, viewportHeight); e->textPosition = Point (x, y); @@ -788,31 +1188,79 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin directText += child->getText(); } - e->text = directText.isNotEmpty() ? directText : (element.hasTagName ("tspan") ? element.getAllSubText() : String()); + auto text = directText.isNotEmpty() ? directText : (element.hasTagName ("tspan") ? element.getAllSubText() : String()); + auto normalizedText = text.replaceCharacters ("\t\r\n", " ").trim(); + while (normalizedText.contains (" ")) + normalizedText = normalizedText.replace (" ", " "); + + e->text = normalizedText; + + YUP_DRAWABLE_LOG ("Parsed text - tag: " << e->tagName + << " id: " << element.getStringAttribute ("id", "none") + << " rawTextLength: " << text.length() + << " rawText: " << text + << " textLength: " << (e->text ? e->text->length() : 0) + << " text: " << (e->text ? *e->text : String()) + << " position: " << e->textPosition->toString()); if (auto xList = element.getStringAttribute ("x"); xList.isNotEmpty()) + { e->textX = parseLengthList (xList, inheritedFontSize, viewportWidth); + YUP_DRAWABLE_LOG ("Parsed text x list - tag: " << e->tagName << " count: " << e->textX->size() << " first: " << (e->textX->isEmpty() ? 0.0f : e->textX->getFirst())); + } if (auto yList = element.getStringAttribute ("y"); yList.isNotEmpty()) + { e->textY = parseLengthList (yList, inheritedFontSize, viewportHeight); + YUP_DRAWABLE_LOG ("Parsed text y list - tag: " << e->tagName << " count: " << e->textY->size() << " first: " << (e->textY->isEmpty() ? 0.0f : e->textY->getFirst())); + } if (auto dxList = element.getStringAttribute ("dx"); dxList.isNotEmpty()) + { e->textDx = parseLengthList (dxList, inheritedFontSize, viewportWidth); + YUP_DRAWABLE_LOG ("Parsed text dx list - tag: " << e->tagName << " count: " << e->textDx->size() << " first: " << (e->textDx->isEmpty() ? 0.0f : e->textDx->getFirst())); + } if (auto dyList = element.getStringAttribute ("dy"); dyList.isNotEmpty()) + { e->textDy = parseLengthList (dyList, inheritedFontSize, viewportHeight); + YUP_DRAWABLE_LOG ("Parsed text dy list - tag: " << e->tagName << " count: " << e->textDy->size() << " first: " << (e->textDy->isEmpty() ? 0.0f : e->textDy->getFirst())); + } String fontFamily = element.getStringAttribute ("font-family"); if (fontFamily.isNotEmpty()) + { e->fontFamily = fontFamily; + YUP_DRAWABLE_LOG ("Parsed text font-family attribute: " << fontFamily); + } float fontSize = parseLengthAttribute (element, "font-size", 0.0f, inheritedFontSize, inheritedFontSize); if (fontSize > 0.0f) + { e->fontSize = fontSize; + YUP_DRAWABLE_LOG ("Parsed text font-size attribute: " << fontSize); + } + else if (element.getStringAttribute ("font-size").isNotEmpty()) + { + YUP_DRAWABLE_LOG ("Text font-size attribute ignored - parsed non-positive value: " << fontSize); + } String textAnchor = element.getStringAttribute ("text-anchor"); if (textAnchor.isNotEmpty()) + { e->textAnchor = textAnchor; + YUP_DRAWABLE_LOG ("Parsed text-anchor attribute: " << textAnchor); + } currentTransform = parseTransform (element, currentTransform, *e); parseStyle (element, currentTransform, *e); + + YUP_DRAWABLE_LOG ("Parsed text font result - tag: " << e->tagName + << " id: " << element.getStringAttribute ("id", "none") + << " fontFamily: " << e->fontFamily.value_or (String ("none")) + << " fontSize: " << e->fontSize.value_or (0.0f) + << " textAnchor: " << e->textAnchor.value_or (String ("none")) + << " letterSpacing: " << e->letterSpacing.value_or (0.0f) + << " wordSpacing: " << e->wordSpacing.value_or (0.0f) + << " unsupportedFontWeight: " << element.getStringAttribute ("font-weight") + << " unsupportedFontStyle: " << element.getStringAttribute ("font-style")); } else if (element.hasTagName ("image")) { @@ -833,6 +1281,11 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin e->image = loadImageFromHref (href); } + YUP_DRAWABLE_LOG ("Parsed image - id: " << element.getStringAttribute ("id", "none") + << " href: " << href + << " bounds: " << e->imageBounds->toString() + << " loaded: " << (e->image ? "true" : "false")); + currentTransform = parseTransform (element, currentTransform, *e); parseStyle (element, currentTransform, *e); } @@ -842,11 +1295,18 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin if (e->isSymbol) e->hidden = true; + YUP_DRAWABLE_LOG ("Parsed container start - tag: " << e->tagName + << " id: " << element.getStringAttribute ("id", "none") + << " hidden: " << (e->hidden ? "true" : "false") + << " viewBoxAttr: " << element.getStringAttribute ("viewBox")); + if (auto view = element.getStringAttribute ("viewBox"); view.isNotEmpty()) { auto coords = StringArray::fromTokens (view, " ,", ""); if (coords.size() == 4) e->viewBox = Rectangle (coords[0].getFloatValue(), coords[1].getFloatValue(), coords[2].getFloatValue(), coords[3].getFloatValue()); + else + YUP_DRAWABLE_LOG ("Invalid nested viewBox - tag: " << e->tagName << " value: " << view << " coordinateCount: " << coords.size()); } auto width = parseLengthAttribute (element, "width", e->viewBox ? e->viewBox->getWidth() : viewportWidth, inheritedFontSize, viewportWidth); @@ -862,16 +1322,26 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin currentTransform = parseTransform (element, currentTransform, *e); parseStyle (element, currentTransform, *e); + + YUP_DRAWABLE_LOG ("Parsed container - tag: " << e->tagName + << " id: " << element.getStringAttribute ("id", "none") + << " viewBox: " << (e->viewBox ? e->viewBox->toString() : "none") + << " viewportSize: " << (e->viewportSize ? String (e->viewportSize->getWidth()) + "x" + String (e->viewportSize->getHeight()) : String ("none")) + << " hidden: " << (e->hidden ? "true" : "false")); } else if (element.hasTagName ("defs")) { e->hidden = true; + YUP_DRAWABLE_LOG ("Parsing defs - id: " << element.getStringAttribute ("id", "none")); + // Parse definitions like gradients and clip paths for (auto* child = element.getFirstChildElement(); child != nullptr; child = child->getNextElement()) { if (child->hasTagName ("linearGradient") || child->hasTagName ("radialGradient")) parseGradient (*child); + else if (child->hasTagName ("filter")) + parseFilter (*child); else if (child->hasTagName ("clipPath")) parseClipPath (*child); else if (child->hasTagName ("style")) @@ -880,9 +1350,15 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin } else if (element.hasTagName ("style")) { + YUP_DRAWABLE_LOG ("Parsing style element"); parseStyleElement (element); return true; } + else + { + YUP_DRAWABLE_LOG ("Unsupported SVG element parsed as container only - tag: " << e->tagName + << " id: " << element.getStringAttribute ("id", "none")); + } if (parent != nullptr) { @@ -898,19 +1374,39 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin e->wordSpacing = parent->wordSpacing; if (! e->color && parent->color) e->color = parent->color; + + YUP_DRAWABLE_LOG ("Inherited text/color state - tag: " << e->tagName + << " id: " << (e->id ? *e->id : "none") + << " fontFamily: " << (e->fontFamily ? *e->fontFamily : "none") + << " fontSize: " << e->fontSize.value_or (0.0f) + << " textAnchor: " << (e->textAnchor ? *e->textAnchor : "none") + << " hasColor: " << (e->color ? "true" : "false")); } for (auto* child = element.getFirstChildElement(); child != nullptr; child = child->getNextElement()) { + if (child->isTextElement()) + { + YUP_DRAWABLE_LOG ("Skipping XML text child - parent: " << e->tagName); + continue; + } + // Parse gradients and clip paths regardless of whether they're in or not if (child->hasTagName ("linearGradient") || child->hasTagName ("radialGradient")) parseGradient (*child); + else if (child->hasTagName ("filter")) + parseFilter (*child); else if (child->hasTagName ("clipPath")) parseClipPath (*child); else if (child->hasTagName ("style")) parseStyleElement (*child); else - parseElement (*child, isRootElement, currentTransform, e.get()); + { + const auto childResult = parseElement (*child, isRootElement, currentTransform, e.get()); + YUP_DRAWABLE_LOG ("Child parse result - parent: " << e->tagName + << " child: " << child->getTagNameWithoutNamespace() + << " result: " << (childResult ? "true" : "false")); + } } if (e->tagName == "text" || e->tagName == "tspan") @@ -943,6 +1439,8 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin childElement->textDy.reset(); cursor = position; + YUP_DRAWABLE_LOG ("Resolved tspan position - text: " << childElement->text.value_or (String()) + << " position: " << childElement->textPosition->toString()); } } @@ -969,13 +1467,29 @@ bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, Affin rootHasStroke = false; } + YUP_DRAWABLE_LOG ("Root element parsed - rootHasFill: " << (rootHasFill ? "true" : "false") + << " rootHasStroke: " << (rootHasStroke ? "true" : "false") + << " rootFillColor: " << (rootFillColor ? rootFillColor->toString() : "none") + << " rootStrokeColor: " << (rootStrokeColor ? rootStrokeColor->toString() : "none")); + return true; } if (parent != nullptr && ! parentIsRoot) + { + YUP_DRAWABLE_LOG ("Adding element to parent - tag: " << e->tagName + << " id: " << (e->id ? *e->id : "none") + << " parent: " << parent->tagName + << " parentChildCountBefore: " << parent->children.size()); parent->children.push_back (std::move (e)); + } else + { + YUP_DRAWABLE_LOG ("Adding top-level element - tag: " << e->tagName + << " id: " << (e->id ? *e->id : "none") + << " topLevelCountBefore: " << elements.size()); elements.push_back (std::move (e)); + } return true; } @@ -986,6 +1500,13 @@ void Drawable::parseStyle (const XmlElement& element, const AffineTransform& cur { String styleAttr = element.getStringAttribute ("style"); + YUP_DRAWABLE_LOG ("parseStyle - tag: " << e.tagName + << " id: " << e.id.value_or (String ("none")) + << " fillAttr: " << element.getStringAttribute ("fill") + << " strokeAttr: " << element.getStringAttribute ("stroke") + << " style: " << styleAttr + << " currentTransform: " << currentTransform.toString()); + // Parse presentation attributes first. Author CSS and inline style are applied after // this block so they can override presentation attributes. String fill = element.getStringAttribute ("fill"); @@ -995,9 +1516,15 @@ void Drawable::parseStyle (const XmlElement& element, const AffineTransform& cur { String gradientUrl = extractGradientUrl (fill); if (gradientUrl.isNotEmpty()) + { e.fillUrl = gradientUrl; + YUP_DRAWABLE_LOG ("Parsed fill gradient URL: " << gradientUrl); + } else if (fill == "currentColor") + { e.fillCurrentColor = true; + YUP_DRAWABLE_LOG ("Parsed fill currentColor"); + } else { e.fillColor = Color::fromString (fill); @@ -1007,6 +1534,7 @@ void Drawable::parseStyle (const XmlElement& element, const AffineTransform& cur else { e.noFill = true; + YUP_DRAWABLE_LOG ("Parsed fill none"); } } @@ -1017,15 +1545,25 @@ void Drawable::parseStyle (const XmlElement& element, const AffineTransform& cur { String gradientUrl = extractGradientUrl (stroke); if (gradientUrl.isNotEmpty()) + { e.strokeUrl = gradientUrl; + YUP_DRAWABLE_LOG ("Parsed stroke gradient URL: " << gradientUrl); + } else if (stroke == "currentColor") + { e.strokeCurrentColor = true; + YUP_DRAWABLE_LOG ("Parsed stroke currentColor"); + } else + { e.strokeColor = Color::fromString (stroke); + YUP_DRAWABLE_LOG ("Parsed stroke color: " << stroke << " -> " << e.strokeColor->toString()); + } } else { e.noStroke = true; + YUP_DRAWABLE_LOG ("Parsed stroke none"); } } @@ -1047,18 +1585,46 @@ void Drawable::parseStyle (const XmlElement& element, const AffineTransform& cur float strokeWidth = element.getFloatAttribute ("stroke-width", -1.0f); if (strokeWidth > 0.0f) + { e.strokeWidth = strokeWidth; + YUP_DRAWABLE_LOG ("Parsed stroke-width: " << strokeWidth); + } float opacity = element.getFloatAttribute ("opacity", -1.0f); if (opacity >= 0.0f && opacity <= 1.0f) + { e.opacity = opacity; + YUP_DRAWABLE_LOG ("Parsed opacity: " << opacity); + } String clipPath = element.getStringAttribute ("clip-path"); if (clipPath.isNotEmpty()) { String clipPathUrl = extractGradientUrl (clipPath); if (clipPathUrl.isNotEmpty()) + { e.clipPathUrl = clipPathUrl; + YUP_DRAWABLE_LOG ("Parsed clip-path URL: " << clipPathUrl); + } + } + + String filter = element.getStringAttribute ("filter"); + if (filter.isNotEmpty()) + { + if (filter == "none") + { + e.filterUrl.reset(); + YUP_DRAWABLE_LOG ("Parsed filter none"); + } + else if (auto filterUrl = extractUrlId (filter); filterUrl.isNotEmpty()) + { + e.filterUrl = filterUrl; + YUP_DRAWABLE_LOG ("Parsed filter URL: " << filterUrl); + } + else + { + YUP_DRAWABLE_LOG ("Unsupported filter value ignored: " << filter); + } } // Parse stroke-dasharray @@ -1077,59 +1643,136 @@ void Drawable::parseStyle (const XmlElement& element, const AffineTransform& cur } if (! dashes.isEmpty()) + { e.strokeDashArray = dashes; + YUP_DRAWABLE_LOG ("Parsed stroke-dasharray count: " << dashes.size()); + } } } // Parse stroke-dashoffset String dashOffset = element.getStringAttribute ("stroke-dashoffset"); if (dashOffset.isNotEmpty()) + { e.strokeDashOffset = parseUnit (dashOffset); + YUP_DRAWABLE_LOG ("Parsed stroke-dashoffset: " << *e.strokeDashOffset << " from: " << dashOffset); + } // Parse fill-opacity float fillOpacity = element.getFloatAttribute ("fill-opacity", -1.0f); if (fillOpacity >= 0.0f && fillOpacity <= 1.0f) + { e.fillOpacity = fillOpacity; + YUP_DRAWABLE_LOG ("Parsed fill-opacity: " << fillOpacity); + } // Parse stroke-opacity float strokeOpacity = element.getFloatAttribute ("stroke-opacity", -1.0f); if (strokeOpacity >= 0.0f && strokeOpacity <= 1.0f) + { e.strokeOpacity = strokeOpacity; + YUP_DRAWABLE_LOG ("Parsed stroke-opacity: " << strokeOpacity); + } // Parse fill-rule String fillRule = element.getStringAttribute ("fill-rule"); if (fillRule == "evenodd" || fillRule == "nonzero") + { e.fillRule = fillRule; + YUP_DRAWABLE_LOG ("Parsed fill-rule: " << fillRule); + } + + String clipRule = element.getStringAttribute ("clip-rule"); + if (clipRule == "evenodd" || clipRule == "nonzero") + { + e.clipRule = clipRule; + YUP_DRAWABLE_LOG ("Parsed clip-rule: " << clipRule); + } String color = element.getStringAttribute ("color"); if (color.isNotEmpty() && color != "currentColor") + { e.color = Color::fromString (color); + YUP_DRAWABLE_LOG ("Parsed color: " << color << " -> " << e.color->toString()); + } String display = element.getStringAttribute ("display"); String visibility = element.getStringAttribute ("visibility"); if (display == "none" || visibility == "hidden" || visibility == "collapse") + { e.hidden = true; + YUP_DRAWABLE_LOG ("Parsed hidden state - display: " << display << " visibility: " << visibility); + } String fontFamily = element.getStringAttribute ("font-family"); if (fontFamily.isNotEmpty()) + { e.fontFamily = fontFamily; + YUP_DRAWABLE_LOG ("Parsed font-family: " << fontFamily); + } String fontSize = element.getStringAttribute ("font-size"); if (fontSize.isNotEmpty()) + { e.fontSize = parseUnit (fontSize, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); + YUP_DRAWABLE_LOG ("Parsed font-size: " << *e.fontSize << " from: " << fontSize); + } String letterSpacing = element.getStringAttribute ("letter-spacing"); if (letterSpacing.isNotEmpty() && letterSpacing != "normal") + { e.letterSpacing = parseUnit (letterSpacing, 0.0f, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); + YUP_DRAWABLE_LOG ("Parsed letter-spacing: " << *e.letterSpacing << " from: " << letterSpacing); + } String wordSpacing = element.getStringAttribute ("word-spacing"); if (wordSpacing.isNotEmpty() && wordSpacing != "normal") + { e.wordSpacing = parseUnit (wordSpacing, 0.0f, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); + YUP_DRAWABLE_LOG ("Parsed word-spacing: " << *e.wordSpacing << " from: " << wordSpacing); + } + + if (auto fontWeight = element.getStringAttribute ("font-weight"); fontWeight.isNotEmpty()) + YUP_DRAWABLE_LOG ("Font attribute currently not applied - font-weight: " << fontWeight); + + if (auto fontStyle = element.getStringAttribute ("font-style"); fontStyle.isNotEmpty()) + YUP_DRAWABLE_LOG ("Font attribute currently not applied - font-style: " << fontStyle); + + if (auto fontVariant = element.getStringAttribute ("font-variant"); fontVariant.isNotEmpty()) + YUP_DRAWABLE_LOG ("Font attribute currently not applied - font-variant: " << fontVariant); + + if (auto fontStretch = element.getStringAttribute ("font-stretch"); fontStretch.isNotEmpty()) + YUP_DRAWABLE_LOG ("Font attribute currently not applied - font-stretch: " << fontStretch); + + if (auto fontShorthand = element.getStringAttribute ("font"); fontShorthand.isNotEmpty()) + YUP_DRAWABLE_LOG ("Font shorthand currently not parsed - font: " << fontShorthand); + + if (auto dominantBaseline = element.getStringAttribute ("dominant-baseline"); dominantBaseline.isNotEmpty()) + YUP_DRAWABLE_LOG ("Text baseline attribute currently not applied - dominant-baseline: " << dominantBaseline); + + if (auto alignmentBaseline = element.getStringAttribute ("alignment-baseline"); alignmentBaseline.isNotEmpty()) + YUP_DRAWABLE_LOG ("Text baseline attribute currently not applied - alignment-baseline: " << alignmentBaseline); + + if (auto baselineShift = element.getStringAttribute ("baseline-shift"); baselineShift.isNotEmpty()) + YUP_DRAWABLE_LOG ("Text baseline attribute currently not applied - baseline-shift: " << baselineShift); applyStylesheetRules (element, e); if (styleAttr.isNotEmpty()) parseCSSStyle (styleAttr, e); + + YUP_DRAWABLE_LOG ("parseStyle result - tag: " << e.tagName + << " id: " << e.id.value_or (String ("none")) + << " hasFillColor: " << (e.fillColor ? "true" : "false") + << " fillUrl: " << e.fillUrl.value_or (String ("none")) + << " fillCurrentColor: " << (e.fillCurrentColor ? "true" : "false") + << " noFill: " << (e.noFill ? "true" : "false") + << " hasStrokeColor: " << (e.strokeColor ? "true" : "false") + << " strokeUrl: " << e.strokeUrl.value_or (String ("none")) + << " strokeCurrentColor: " << (e.strokeCurrentColor ? "true" : "false") + << " noStroke: " << (e.noStroke ? "true" : "false") + << " filterUrl: " << e.filterUrl.value_or (String ("none")) + << " hidden: " << (e.hidden ? "true" : "false")); } //============================================================================== @@ -1140,6 +1783,11 @@ AffineTransform Drawable::parseTransform (const XmlElement& element, const Affin if (auto transformString = element.getStringAttribute ("transform"); transformString.isNotEmpty()) { + YUP_DRAWABLE_LOG ("parseTransform(element) - tag: " << e.tagName + << " id: " << e.id.value_or (String ("none")) + << " raw: " << transformString + << " incoming: " << currentTransform.toString()); + result = parseTransform (transformString); if (auto transformOrigin = element.getStringAttribute ("transform-origin"); transformOrigin.isNotEmpty()) @@ -1158,6 +1806,11 @@ AffineTransform Drawable::parseTransform (const XmlElement& element, const Affin YUP_DRAWABLE_LOG ("Parsed element transform: " << result.toString()); } + else + { + YUP_DRAWABLE_LOG ("parseTransform(element) - no transform tag: " << e.tagName + << " id: " << e.id.value_or (String ("none"))); + } return currentTransform.followedBy (result); } @@ -1169,6 +1822,8 @@ AffineTransform Drawable::parseTransform (const String& transformString) if (transformString.isEmpty()) return AffineTransform::identity(); + YUP_DRAWABLE_LOG ("parseTransform(string) - raw: " << transformString); + AffineTransform result; auto data = transformString.getCharPointer(); @@ -1230,12 +1885,14 @@ AffineTransform Drawable::parseTransform (const String& transformString) const auto tx = params[0]; const auto ty = (params.size() == 2) ? params[1] : 0.0f; result = result.prependedBy (AffineTransform::translation (tx, ty)); + YUP_DRAWABLE_LOG ("Applied translate transform - tx: " << tx << " ty: " << ty << " result: " << result.toString()); } else if (type == "scale" && (params.size() == 1 || params.size() == 2)) { const auto sx = params[0]; const auto sy = (params.size() == 2) ? params[1] : params[0]; result = result.prependedBy (AffineTransform::scaling (sx, sy)); + YUP_DRAWABLE_LOG ("Applied scale transform - sx: " << sx << " sy: " << sy << " result: " << result.toString()); } else if (type == "rotate" && (params.size() == 1 || params.size() == 3)) { @@ -1243,22 +1900,34 @@ AffineTransform Drawable::parseTransform (const String& transformString) result = result.prependedBy (AffineTransform::rotation (degreesToRadians (params[0]))); else result = result.prependedBy (AffineTransform::rotation (degreesToRadians (params[0]), params[1], params[2])); + + YUP_DRAWABLE_LOG ("Applied rotate transform - degrees: " << params[0] + << " params: " << params.size() + << " result: " << result.toString()); } else if (type == "skewX" && params.size() == 1) { result = result.prependedBy (AffineTransform::shearing (tanf (degreesToRadians (params[0])), 0.0f)); + YUP_DRAWABLE_LOG ("Applied skewX transform - degrees: " << params[0] << " result: " << result.toString()); } else if (type == "skewY" && params.size() == 1) { result = result.prependedBy (AffineTransform::shearing (0.0f, tanf (degreesToRadians (params[0])))); + YUP_DRAWABLE_LOG ("Applied skewY transform - degrees: " << params[0] << " result: " << result.toString()); } else if (type == "matrix" && params.size() == 6) { result = result.prependedBy (AffineTransform ( params[0], params[2], params[4], params[1], params[3], params[5])); + YUP_DRAWABLE_LOG ("Applied matrix transform - result: " << result.toString()); + } + else + { + YUP_DRAWABLE_LOG ("Ignored transform operation - type: " << type << " paramCount: " << params.size()); } } + YUP_DRAWABLE_LOG ("parseTransform(string) result: " << result.toString()); return result; } @@ -1362,10 +2031,20 @@ void Drawable::parseGradient (const XmlElement& element) // Parse gradientUnits attribute String gradientUnits = element.getStringAttribute ("gradientUnits"); - if (gradientUnits == "userSpaceOnUse") + if (gradientUnits.isNotEmpty()) { - gradient->units = Gradient::UserSpaceOnUse; - YUP_DRAWABLE_LOG ("Gradient units: userSpaceOnUse"); + gradient->hasUnits = true; + + if (gradientUnits == "userSpaceOnUse") + { + gradient->units = Gradient::UserSpaceOnUse; + YUP_DRAWABLE_LOG ("Gradient units: userSpaceOnUse"); + } + else + { + gradient->units = Gradient::ObjectBoundingBox; + YUP_DRAWABLE_LOG ("Gradient units: objectBoundingBox"); + } } else { @@ -1382,6 +2061,14 @@ void Drawable::parseGradient (const XmlElement& element) YUP_DRAWABLE_LOG ("Gradient transform: " << gradient->transform.toString()); } + String spreadMethod = element.getStringAttribute ("spreadMethod"); + if (spreadMethod == "pad" || spreadMethod == "reflect" || spreadMethod == "repeat") + { + gradient->spreadMethod = spreadMethod; + gradient->hasSpreadMethod = true; + YUP_DRAWABLE_LOG ("Gradient spreadMethod: " << spreadMethod); + } + // Parse gradient stops for (auto* child = element.getFirstChildElement(); child != nullptr; child = child->getNextElement()) { @@ -1503,9 +2190,10 @@ Drawable::Gradient::Ptr Drawable::resolveGradient (Gradient::Ptr gradient) Gradient::Ptr resolvedGradient = new Gradient; // Copy properties from referenced gradient - resolvedGradient->type = referencedGradient->type; + resolvedGradient->type = gradient->type; resolvedGradient->id = gradient->id; // Keep the original ID resolvedGradient->units = referencedGradient->units; + resolvedGradient->spreadMethod = referencedGradient->spreadMethod; resolvedGradient->start = referencedGradient->start; resolvedGradient->end = referencedGradient->end; resolvedGradient->center = referencedGradient->center; @@ -1513,6 +2201,8 @@ Drawable::Gradient::Ptr Drawable::resolveGradient (Gradient::Ptr gradient) resolvedGradient->focal = referencedGradient->focal; resolvedGradient->transform = referencedGradient->transform; resolvedGradient->stops = referencedGradient->stops; + resolvedGradient->hasUnits = referencedGradient->hasUnits; + resolvedGradient->hasSpreadMethod = referencedGradient->hasSpreadMethod; // Override with properties from the current gradient (if specified) if (gradient->hasStart) @@ -1528,8 +2218,16 @@ Drawable::Gradient::Ptr Drawable::resolveGradient (Gradient::Ptr gradient) if (! gradient->transform.isIdentity()) resolvedGradient->transform = gradient->transform; - if (gradient->units != Gradient::ObjectBoundingBox) // Only override if explicitly set + if (gradient->hasUnits) + { resolvedGradient->units = gradient->units; + resolvedGradient->hasUnits = true; + } + if (gradient->hasSpreadMethod) + { + resolvedGradient->spreadMethod = gradient->spreadMethod; + resolvedGradient->hasSpreadMethod = true; + } if (! gradient->stops.empty()) // Use local stops if defined resolvedGradient->stops = gradient->stops; @@ -1594,22 +2292,99 @@ ColorGradient Drawable::createColorGradientFromSVG (const Gradient& gradient, co std::vector colorStops; colorStops.reserve (gradient.stops.size()); - for (const auto& stop : gradient.stops) + if (gradient.type == Gradient::Linear + && objectBounds != nullptr + && (gradient.spreadMethod == "reflect" || gradient.spreadMethod == "repeat")) { - Color color = stop.color.withMultipliedAlpha (stop.opacity); + const auto dx = end.getX() - start.getX(); + const auto dy = end.getY() - start.getY(); + const auto lengthSquared = dx * dx + dy * dy; - if (gradient.type == Gradient::Linear) + if (lengthSquared > 0.0f) { - const auto interpolated = Point (start.getX() + stop.offset * (end.getX() - start.getX()), - start.getY() + stop.offset * (end.getY() - start.getY())); + const Point corners[] = { + objectBounds->getTopLeft(), + objectBounds->getTopRight(), + objectBounds->getBottomLeft(), + objectBounds->getBottomRight() + }; + + float minT = 0.0f; + float maxT = 1.0f; + + for (const auto& corner : corners) + { + const auto t = ((corner.getX() - start.getX()) * dx + (corner.getY() - start.getY()) * dy) / lengthSquared; + minT = jmin (minT, t); + maxT = jmax (maxT, t); + } + + int firstRepeat = static_cast (std::floor (minT)); + int lastRepeat = static_cast (std::ceil (maxT)); + + constexpr int maxGradientRepeats = 32; + if (lastRepeat - firstRepeat > maxGradientRepeats) + { + const auto centerRepeat = (firstRepeat + lastRepeat) / 2; + firstRepeat = centerRepeat - (maxGradientRepeats / 2); + lastRepeat = firstRepeat + maxGradientRepeats; + } + + const auto repeatStart = static_cast (firstRepeat); + const auto repeatEnd = static_cast (jmax (lastRepeat, firstRepeat + 1)); + const auto repeatRange = repeatEnd - repeatStart; + const auto expandedStart = Point (start.getX() + dx * repeatStart, start.getY() + dy * repeatStart); + const auto expandedEnd = Point (start.getX() + dx * repeatEnd, start.getY() + dy * repeatEnd); - colorStops.emplace_back (color, interpolated, stop.offset); + colorStops.reserve (gradient.stops.size() * static_cast (repeatEnd - repeatStart)); + + for (int repeat = firstRepeat; repeat < lastRepeat; ++repeat) + { + const bool reflected = gradient.spreadMethod == "reflect" && (std::abs (repeat) % 2) == 1; + + for (const auto& stop : gradient.stops) + { + const auto repeatedOffset = static_cast (repeat) + (reflected ? (1.0f - stop.offset) : stop.offset); + const auto normalizedOffset = (repeatedOffset - repeatStart) / repeatRange; + const auto position = Point (expandedStart.getX() + (expandedEnd.getX() - expandedStart.getX()) * normalizedOffset, + expandedStart.getY() + (expandedEnd.getY() - expandedStart.getY()) * normalizedOffset); + + colorStops.emplace_back (stop.color.withMultipliedAlpha (stop.opacity), position, jlimit (0.0f, 1.0f, normalizedOffset)); + } + } + + std::sort (colorStops.begin(), colorStops.end(), [] (const auto& a, const auto& b) + { + return a.delta < b.delta; + }); + + YUP_DRAWABLE_LOG ("Expanded linear gradient spread - id: " << gradient.id + << " spreadMethod: " << gradient.spreadMethod + << " repeatStart: " << repeatStart + << " repeatEnd: " << repeatEnd + << " stopCount: " << colorStops.size()); } - else + } + + if (colorStops.empty()) + { + for (const auto& stop : gradient.stops) { - // Radial gradient: position lies along the radius vector to preserve radius computation - const auto radialPoint = Point (center.getX() + radius * stop.offset, center.getY()); - colorStops.emplace_back (color, radialPoint, stop.offset); + Color color = stop.color.withMultipliedAlpha (stop.opacity); + + if (gradient.type == Gradient::Linear) + { + const auto interpolated = Point (start.getX() + stop.offset * (end.getX() - start.getX()), + start.getY() + stop.offset * (end.getY() - start.getY())); + + colorStops.emplace_back (color, interpolated, stop.offset); + } + else + { + // Radial gradient: position lies along the radius vector to preserve radius computation + const auto radialPoint = Point (center.getX() + radius * stop.offset, center.getY()); + colorStops.emplace_back (color, radialPoint, stop.offset); + } } } @@ -1622,26 +2397,143 @@ ColorGradient Drawable::createColorGradientFromSVG (const Gradient& gradient, co //============================================================================== +void Drawable::parseFilter (const XmlElement& element) +{ + String id = element.getStringAttribute ("id"); + if (id.isEmpty()) + { + YUP_DRAWABLE_LOG ("parseFilter skipped - missing id"); + return; + } + + Filter::Ptr filter = new Filter; + filter->id = id; + + if (auto href = element.getStringAttribute ("xlink:href"); href.isNotEmpty() && href.startsWith ("#")) + { + filter->href = href.substring (1); + YUP_DRAWABLE_LOG ("Filter references: " << filter->href); + } + + YUP_DRAWABLE_LOG ("parseFilter - id: " << id); + + for (auto* child = element.getFirstChildElement(); child != nullptr; child = child->getNextElement()) + { + if (! child->hasTagName ("feGaussianBlur")) + { + YUP_DRAWABLE_LOG ("Unsupported filter primitive ignored - filter: " << id << " tag: " << child->getTagNameWithoutNamespace()); + continue; + } + + const auto stdDeviationString = child->getStringAttribute ("stdDeviation"); + if (stdDeviationString.isEmpty()) + { + YUP_DRAWABLE_LOG ("GaussianBlur primitive missing stdDeviation - filter: " << id); + continue; + } + + const auto values = StringArray::fromTokens (stdDeviationString, " ,", ""); + float stdDeviation = 0.0f; + + if (values.size() >= 2) + { + const auto stdDeviationX = parseUnit (values[0]); + const auto stdDeviationY = parseUnit (values[1]); + stdDeviation = jmax (stdDeviationX, stdDeviationY); + YUP_DRAWABLE_LOG ("Parsed anisotropic GaussianBlur - filter: " << id + << " stdDeviationX: " << stdDeviationX + << " stdDeviationY: " << stdDeviationY + << " featherApproximation: " << stdDeviation); + } + else if (values.size() == 1) + { + stdDeviation = parseUnit (values[0]); + YUP_DRAWABLE_LOG ("Parsed GaussianBlur - filter: " << id << " stdDeviation: " << stdDeviation); + } + + if (stdDeviation > 0.0f) + filter->gaussianBlurStdDeviation = jmax (filter->gaussianBlurStdDeviation.value_or (0.0f), stdDeviation); + } + + filters.push_back (filter); + filtersById.set (id, filter); + YUP_DRAWABLE_LOG ("parseFilter result - id: " << id + << " gaussianBlurStdDeviation: " << filter->gaussianBlurStdDeviation.value_or (0.0f)); +} + +//============================================================================== + +Drawable::Filter::Ptr Drawable::getFilterById (const String& id) +{ + return filtersById[id]; +} + +//============================================================================== + +Drawable::Filter::Ptr Drawable::resolveFilter (Filter::Ptr filter) +{ + if (filter == nullptr || filter->href.isEmpty()) + return filter; + + auto referencedFilter = resolveFilter (getFilterById (filter->href)); + if (referencedFilter == nullptr) + { + YUP_DRAWABLE_LOG ("Referenced filter not found: " << filter->href); + return filter; + } + + Filter::Ptr resolvedFilter = new Filter; + resolvedFilter->id = filter->id; + resolvedFilter->href = filter->href; + resolvedFilter->gaussianBlurStdDeviation = filter->gaussianBlurStdDeviation ? filter->gaussianBlurStdDeviation + : referencedFilter->gaussianBlurStdDeviation; + + YUP_DRAWABLE_LOG ("Resolved filter " << filter->id << " from reference " << filter->href); + return resolvedFilter; +} + +//============================================================================== + void Drawable::parseClipPath (const XmlElement& element) { String id = element.getStringAttribute ("id"); if (id.isEmpty()) + { + YUP_DRAWABLE_LOG ("parseClipPath skipped - missing id"); return; + } ClipPath::Ptr clipPath = new ClipPath; clipPath->id = id; + if (element.getStringAttribute ("clipPathUnits") == "objectBoundingBox") + clipPath->units = ClipPath::ObjectBoundingBox; + + YUP_DRAWABLE_LOG ("parseClipPath - id: " << id + << " units: " << (clipPath->units == ClipPath::ObjectBoundingBox ? "objectBoundingBox" : "userSpaceOnUse")); + // Parse child elements that make up the clipping path for (auto* child = element.getFirstChildElement(); child != nullptr; child = child->getNextElement()) { Element::Ptr clipElement = new Element; + clipElement->tagName = child->getTagNameWithoutNamespace(); + + if (auto childId = child->getStringAttribute ("id"); childId.isNotEmpty()) + clipElement->id = childId; if (child->hasTagName ("path")) { auto path = Path(); String pathData = child->getStringAttribute ("d"); if (pathData.isNotEmpty() && path.fromString (pathData)) + { clipElement->path = std::move (path); + YUP_DRAWABLE_LOG ("Parsed clip path child path - id: " << id << " bounds: " << clipElement->path->getBounds().toString()); + } + else + { + YUP_DRAWABLE_LOG ("Clip path child path failed/empty - id: " << id << " pathLength: " << pathData.length()); + } } else if (child->hasTagName ("rect")) { @@ -1649,10 +2541,27 @@ void Drawable::parseClipPath (const XmlElement& element) auto y = child->getFloatAttribute ("y"); auto width = child->getFloatAttribute ("width"); auto height = child->getFloatAttribute ("height"); + auto rx = child->getFloatAttribute ("rx"); + auto ry = child->getFloatAttribute ("ry"); auto path = Path(); - path.addRectangle (x, y, width, height); + if (rx > 0.0f || ry > 0.0f) + { + if (rx == 0.0f) + rx = ry; + if (ry == 0.0f) + ry = rx; + + path.addRoundedRectangle (x, y, width, height, rx, ry, rx, ry); + } + else + { + path.addRectangle (x, y, width, height); + } + clipElement->path = std::move (path); + YUP_DRAWABLE_LOG ("Parsed clip path child rect - id: " << id + << " bounds: " << clipElement->path->getBounds().toString()); } else if (child->hasTagName ("circle")) { @@ -1663,14 +2572,65 @@ void Drawable::parseClipPath (const XmlElement& element) auto path = Path(); path.addCenteredEllipse (cx, cy, r, r); clipElement->path = std::move (path); + YUP_DRAWABLE_LOG ("Parsed clip path child circle - id: " << id + << " bounds: " << clipElement->path->getBounds().toString()); + } + else if (child->hasTagName ("ellipse")) + { + auto cx = child->getFloatAttribute ("cx"); + auto cy = child->getFloatAttribute ("cy"); + auto rx = child->getFloatAttribute ("rx"); + auto ry = child->getFloatAttribute ("ry"); + + auto path = Path(); + path.addCenteredEllipse (cx, cy, rx, ry); + clipElement->path = std::move (path); + YUP_DRAWABLE_LOG ("Parsed clip path child ellipse - id: " << id + << " bounds: " << clipElement->path->getBounds().toString()); + } + else if (child->hasTagName ("polygon") || child->hasTagName ("polyline")) + { + String points = child->getStringAttribute ("points"); + auto coords = StringArray::fromTokens (points, " ,", ""); + auto path = Path(); + + if (coords.size() >= 4 && coords.size() % 2 == 0) + { + path.startNewSubPath (coords[0].getFloatValue(), coords[1].getFloatValue()); + + for (int i = 2; i < coords.size(); i += 2) + path.lineTo (coords[i].getFloatValue(), coords[i + 1].getFloatValue()); + + if (child->hasTagName ("polygon")) + path.closeSubPath(); + } + + clipElement->path = std::move (path); + YUP_DRAWABLE_LOG ("Parsed clip path child " << child->getTagNameWithoutNamespace() + << " - id: " << id + << " coordinateCount: " << coords.size() + << " bounds: " << clipElement->path->getBounds().toString()); + } + else + { + YUP_DRAWABLE_LOG ("Unsupported clip path child - id: " << id << " tag: " << child->getTagNameWithoutNamespace()); } if (clipElement->path) + { + auto clipChildTransform = parseTransform (*child, AffineTransform::identity(), *clipElement); + parseStyle (*child, clipChildTransform, *clipElement); + + if (clipElement->clipRule && *clipElement->clipRule == "evenodd") + clipElement->path->setUsingNonZeroWinding (false); + clipPath->elements.push_back (clipElement); + } } clipPaths.push_back (clipPath); clipPathsById.set (id, clipPath); + YUP_DRAWABLE_LOG ("parseClipPath result - id: " << id << " elementCount: " << clipPath->elements.size()); } //============================================================================== @@ -1684,6 +2644,10 @@ Drawable::ClipPath::Ptr Drawable::getClipPathById (const String& id) void Drawable::parseCSSStyle (const String& styleString, Element& e) { + YUP_DRAWABLE_LOG ("parseCSSStyle - tag: " << e.tagName + << " id: " << e.id.value_or (String ("none")) + << " style: " << styleString); + auto declarations = StringArray::fromTokens (styleString, ";", ""); for (const auto& declaration : declarations) @@ -1694,6 +2658,7 @@ void Drawable::parseCSSStyle (const String& styleString, Element& e) String property = declaration.substring (0, colonPos).trim(); String value = declaration.substring (colonPos + 1).trim(); + YUP_DRAWABLE_LOG ("Applying inline CSS declaration - property: " << property << " value: " << value); applyStyleProperty (property, value, e); } } @@ -1707,6 +2672,11 @@ void Drawable::applyStyleProperty (StringRef propertyRef, StringRef valueRef, El property = property.trim().toLowerCase(); value = value.trim(); + YUP_DRAWABLE_LOG ("applyStyleProperty - tag: " << e.tagName + << " id: " << e.id.value_or (String ("none")) + << " property: " << property + << " value: " << value); + if (property == "fill") { e.fillCurrentColor = false; @@ -1807,12 +2777,53 @@ void Drawable::applyStyleProperty (StringRef propertyRef, StringRef valueRef, El if (value != "normal") e.wordSpacing = parseUnit (value, 0.0f, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); } + else if (property == "font-weight") + { + YUP_DRAWABLE_LOG ("CSS font-weight currently not applied - value: " << value); + } + else if (property == "font-style") + { + YUP_DRAWABLE_LOG ("CSS font-style currently not applied - value: " << value); + } + else if (property == "font-variant") + { + YUP_DRAWABLE_LOG ("CSS font-variant currently not applied - value: " << value); + } + else if (property == "font-stretch") + { + YUP_DRAWABLE_LOG ("CSS font-stretch currently not applied - value: " << value); + } + else if (property == "font") + { + YUP_DRAWABLE_LOG ("CSS font shorthand currently not parsed - value: " << value); + } + else if (property == "dominant-baseline") + { + YUP_DRAWABLE_LOG ("CSS dominant-baseline currently not applied - value: " << value); + } + else if (property == "alignment-baseline") + { + YUP_DRAWABLE_LOG ("CSS alignment-baseline currently not applied - value: " << value); + } + else if (property == "baseline-shift") + { + YUP_DRAWABLE_LOG ("CSS baseline-shift currently not applied - value: " << value); + } else if (property == "clip-path") { String clipPathUrl = extractUrlId (value); if (clipPathUrl.isNotEmpty()) e.clipPathUrl = clipPathUrl; } + else if (property == "filter") + { + if (value == "none") + e.filterUrl.reset(); + else if (auto filterUrl = extractUrlId (value); filterUrl.isNotEmpty()) + e.filterUrl = filterUrl; + else + YUP_DRAWABLE_LOG ("CSS filter currently only supports url(...) - value: " << value); + } else if (property == "stroke-dasharray") { if (value == "none") @@ -1840,11 +2851,20 @@ void Drawable::applyStyleProperty (StringRef propertyRef, StringRef valueRef, El if (opacity >= 0.0f && opacity <= 1.0f) e.strokeOpacity = opacity; } - else if (property == "fill-rule" || property == "clip-rule") + else if (property == "fill-rule") { if (value == "evenodd" || value == "nonzero") e.fillRule = value; } + else if (property == "clip-rule") + { + if (value == "evenodd" || value == "nonzero") + e.clipRule = value; + } + else + { + YUP_DRAWABLE_LOG ("Unsupported CSS property ignored - property: " << property << " value: " << value); + } } //============================================================================== @@ -1867,8 +2887,20 @@ void Drawable::applyStylesheetRules (const XmlElement& xmlElement, Element& e) return a->order < b->order; }); + if (! matchedRules.empty()) + { + YUP_DRAWABLE_LOG ("applyStylesheetRules - tag: " << e.tagName + << " id: " << e.id.value_or (String ("none")) + << " matchedRules: " << matchedRules.size()); + } + for (const auto* rule : matchedRules) { + YUP_DRAWABLE_LOG ("Applying stylesheet rule - selector: " << rule->selector + << " specificity: " << rule->specificity + << " order: " << rule->order + << " declarationCount: " << rule->declarations.size()); + for (const auto& declaration : rule->declarations) { auto colonPos = declaration.indexOf (":"); @@ -1883,6 +2915,8 @@ void Drawable::parseStyleElement (const XmlElement& element) auto css = element.getAllSubText(); int ruleOrder = static_cast (cssRules.size()); + YUP_DRAWABLE_LOG ("parseStyleElement - cssLength: " << css.length() << " existingRules: " << cssRules.size()); + while (css.isNotEmpty()) { auto openBrace = css.indexOf ("{"); @@ -1920,9 +2954,15 @@ void Drawable::parseStyleElement (const XmlElement& element) else rule.specificity = 1; + YUP_DRAWABLE_LOG ("Parsed CSS rule - selector: " << rule.selector + << " specificity: " << rule.specificity + << " declarationCount: " << rule.declarations.size() + << " order: " << rule.order); cssRules.push_back (std::move (rule)); } } + + YUP_DRAWABLE_LOG ("parseStyleElement result - totalRules: " << cssRules.size()); } bool Drawable::matchesCssSelector (const XmlElement& xmlElement, const CssRule& rule) const @@ -2041,7 +3081,15 @@ float Drawable::parseLengthAttribute (const XmlElement& element, StringRef attri if (value.isEmpty()) return defaultValue; - return parseUnit (value, defaultValue, fontSize, viewportSize); + const auto result = parseUnit (value, defaultValue, fontSize, viewportSize); + YUP_DRAWABLE_LOG ("parseLengthAttribute - tag: " << element.getTagNameWithoutNamespace() + << " attribute: " << String (attributeName.text) + << " raw: " << value + << " result: " << result + << " default: " << defaultValue + << " fontSize: " << fontSize + << " viewportSize: " << viewportSize); + return result; } Array Drawable::parseLengthList (const String& value, float fontSize, float viewportSize) @@ -2055,30 +3103,54 @@ Array Drawable::parseLengthList (const String& value, float fontSize, flo result.add (parseUnit (token, 0.0f, fontSize, viewportSize)); } + YUP_DRAWABLE_LOG ("parseLengthList - raw: " << value + << " count: " << result.size() + << " fontSize: " << fontSize + << " viewportSize: " << viewportSize); return result; } std::optional Drawable::loadImageFromHref (const String& href) const { + YUP_DRAWABLE_LOG ("loadImageFromHref - href: " << href + << " baseDirectory: " << parseOptions.baseDirectory.getFullPathName() + << " allowDataImages: " << (parseOptions.allowDataImages ? "true" : "false") + << " allowLocalImages: " << (parseOptions.allowLocalImages ? "true" : "false") + << " hasResolver: " << (parseOptions.imageResolver ? "true" : "false")); + if (parseOptions.imageResolver) { if (auto resolved = parseOptions.imageResolver (href, parseOptions.baseDirectory)) + { + YUP_DRAWABLE_LOG ("loadImageFromHref - resolved by custom resolver"); return resolved; + } + + YUP_DRAWABLE_LOG ("loadImageFromHref - custom resolver returned no image"); } if (href.startsWithIgnoreCase ("http:") || href.startsWithIgnoreCase ("https:")) + { + YUP_DRAWABLE_LOG ("loadImageFromHref skipped - network URLs are disabled"); return std::nullopt; + } MemoryBlock imageData; if (href.startsWithIgnoreCase ("data:")) { if (! parseOptions.allowDataImages) + { + YUP_DRAWABLE_LOG ("loadImageFromHref skipped - data images disabled"); return std::nullopt; + } auto comma = href.indexOfChar (','); if (comma < 0) + { + YUP_DRAWABLE_LOG ("loadImageFromHref failed - malformed data URI"); return std::nullopt; + } auto metadata = href.substring (0, comma).toLowerCase(); auto payload = href.substring (comma + 1); @@ -2087,32 +3159,53 @@ std::optional Drawable::loadImageFromHref (const String& href) const { MemoryOutputStream decoded; if (! Base64::convertFromBase64 (decoded, payload)) + { + YUP_DRAWABLE_LOG ("loadImageFromHref failed - base64 decode failed"); return std::nullopt; + } imageData = decoded.getMemoryBlock(); + YUP_DRAWABLE_LOG ("loadImageFromHref - decoded base64 data URI bytes: " << imageData.getSize()); } else { imageData = MemoryBlock (payload.toRawUTF8(), static_cast (payload.getNumBytesAsUTF8())); + YUP_DRAWABLE_LOG ("loadImageFromHref - copied non-base64 data URI bytes: " << imageData.getSize()); } } else { if (! parseOptions.allowLocalImages || parseOptions.baseDirectory.getFullPathName().isEmpty()) + { + YUP_DRAWABLE_LOG ("loadImageFromHref skipped - local images disabled or base directory empty"); return std::nullopt; + } auto imageFile = parseOptions.baseDirectory.getChildFile (href); if (! imageFile.existsAsFile() || ! imageFile.loadFileAsData (imageData)) + { + YUP_DRAWABLE_LOG ("loadImageFromHref failed - local file missing or unreadable: " << imageFile.getFullPathName()); return std::nullopt; + } + + YUP_DRAWABLE_LOG ("loadImageFromHref - loaded local file: " << imageFile.getFullPathName() + << " bytes: " << imageData.getSize()); } if (imageData.isEmpty()) + { + YUP_DRAWABLE_LOG ("loadImageFromHref failed - image data empty"); return std::nullopt; + } auto result = Image::loadFromData (imageData.asBytes()); if (result.failed()) + { + YUP_DRAWABLE_LOG ("loadImageFromHref failed - Image::loadFromData failed: " << result.getErrorMessage()); return std::nullopt; + } + YUP_DRAWABLE_LOG ("loadImageFromHref result - image decoded"); return result.getValue(); } @@ -2120,67 +3213,176 @@ Font Drawable::resolveFont (const Element& element) const { const auto fontSize = element.fontSize.value_or (12.0f); + YUP_DRAWABLE_LOG ("resolveFont - tag: " << element.tagName + << " id: " << element.id.value_or (String ("none")) + << " family: " << element.fontFamily.value_or (String ("none")) + << " size: " << fontSize + << " hasResolver: " << (parseOptions.fontResolver ? "true" : "false")); + if (parseOptions.fontResolver) { if (auto resolved = parseOptions.fontResolver (element.fontFamily.value_or (String()), fontSize)) - return resolved->withHeight (fontSize); + { + auto result = resolved->withHeight (fontSize); + YUP_DRAWABLE_LOG ("resolveFont - resolved by custom resolver" + << " requestedHeight: " << fontSize + << " resolvedHeightBefore: " << resolved->getHeight() + << " resultHeight: " << result.getHeight() + << " ascent: " << result.getAscent() + << " descent: " << result.getDescent() + << " weight: " << result.getWeight() + << " italic: " << (result.isItalic() ? "true" : "false") + << " axisCount: " << result.getNumAxis()); + return result; + } + + YUP_DRAWABLE_LOG ("resolveFont - custom resolver returned no font"); } - return Font().withHeight (fontSize); + auto result = Font().withHeight (fontSize); + YUP_DRAWABLE_LOG ("resolveFont - using default font" + << " resultHeight: " << result.getHeight() + << " ascent: " << result.getAscent() + << " descent: " << result.getDescent() + << " weight: " << result.getWeight() + << " italic: " << (result.isItalic() ? "true" : "false") + << " axisCount: " << result.getNumAxis()); + return result; } void Drawable::renderTextElement (Graphics& g, const Element& element) { if (! element.text || ! element.textPosition || element.text->isEmpty()) + { + YUP_DRAWABLE_LOG ("renderTextElement skipped - missing text/position" + << " tag: " << element.tagName + << " hasText: " << (element.text ? "true" : "false") + << " hasPosition: " << (element.textPosition ? "true" : "false")); return; + } auto position = *element.textPosition; + YUP_DRAWABLE_LOG ("renderTextElement position inputs - tag: " << element.tagName + << " id: " << element.id.value_or (String ("none")) + << " basePosition: " << position.toString() + << " hasXList: " << (element.textX ? "true" : "false") + << " hasYList: " << (element.textY ? "true" : "false") + << " hasDxList: " << (element.textDx ? "true" : "false") + << " hasDyList: " << (element.textDy ? "true" : "false")); if (element.textX && ! element.textX->isEmpty()) + { + YUP_DRAWABLE_LOG ("renderTextElement applying x list first value: " << element.textX->getFirst()); position.setX (element.textX->getFirst()); + } if (element.textY && ! element.textY->isEmpty()) + { + YUP_DRAWABLE_LOG ("renderTextElement applying y list first value: " << element.textY->getFirst()); position.setY (element.textY->getFirst()); + } if (element.textDx && ! element.textDx->isEmpty()) + { + YUP_DRAWABLE_LOG ("renderTextElement applying dx first value: " << element.textDx->getFirst()); position.setX (position.getX() + element.textDx->getFirst()); + } if (element.textDy && ! element.textDy->isEmpty()) + { + YUP_DRAWABLE_LOG ("renderTextElement applying dy first value: " << element.textDy->getFirst()); position.setY (position.getY() + element.textDy->getFirst()); + } const auto font = resolveFont (element); const auto fontSize = element.fontSize.value_or (12.0f); - const auto textWidth = jmax (fontSize, static_cast (element.text->length()) * fontSize * 0.85f); - const auto textHeight = fontSize * 2.25f; - const auto textPadding = fontSize * 0.5f; - - auto textX = position.getX(); - if (element.textAnchor == "middle") - textX -= textWidth * 0.5f; - else if (element.textAnchor == "end") - textX -= textWidth; - - Rectangle textBounds (textX - textPadding, - position.getY() - (fontSize * 1.5f), - textWidth + (textPadding * 2.0f), - textHeight); StyledText styledText; { auto modifier = styledText.startUpdate(); - modifier.setMaxSize (Size (textBounds.getWidth(), textBounds.getHeight())); + modifier.setMaxSize (Size (jmax (fontSize, static_cast (element.text->length()) * fontSize * 2.0f), + fontSize * 4.0f)); modifier.setWrap (StyledText::noWrap); modifier.setHorizontalAlign (StyledText::left); + modifier.setVerticalAlign (StyledText::top); modifier.appendText (*element.text, font, -1.0f, element.letterSpacing.value_or (0.0f)); - } + YUP_DRAWABLE_LOG ("renderTextElement StyledText setup - maxSize: " << jmax (fontSize, static_cast (element.text->length()) * fontSize * 2.0f) + << "x" << (fontSize * 4.0f) + << " wrap: noWrap" + << " horizontalAlign: left" + << " verticalAlign: top" + << " appendedLength: " << element.text->length() + << " appendedLetterSpacing: " << element.letterSpacing.value_or (0.0f)); + } + + const auto computedTextBounds = styledText.getComputedTextBounds(); + const auto fontAscent = font.getAscent(); + const auto fontDescent = font.getDescent(); + const auto hasUsableFontMetrics = fontAscent < 0.0f && fontDescent > fontAscent; + const auto ascent = hasUsableFontMetrics ? fontAscent : -0.8f; + const auto descent = hasUsableFontMetrics ? fontDescent : 0.2f; + const auto metricsHeight = (descent - ascent) * fontSize; + const auto textWidth = jmax (fontSize, computedTextBounds.getWidth()); + const auto textHeight = jmax (computedTextBounds.getHeight(), metricsHeight); + const auto bottomPadding = fontSize * 0.25f; + + auto textX = position.getX(); + if (element.textAnchor == "middle") + { + YUP_DRAWABLE_LOG ("renderTextElement applying middle anchor - textWidth: " << textWidth); + textX -= textWidth * 0.5f; + } + else if (element.textAnchor == "end") + { + YUP_DRAWABLE_LOG ("renderTextElement applying end anchor - textWidth: " << textWidth); + textX -= textWidth; + } + else if (element.textAnchor) + { + YUP_DRAWABLE_LOG ("renderTextElement anchor not specially handled - anchor: " << *element.textAnchor); + } + + Rectangle textBounds (textX, + position.getY() + (ascent * fontSize), + textWidth, + textHeight + bottomPadding); + + YUP_DRAWABLE_LOG ("renderTextElement - text: " << *element.text + << " position: " << position.toString() + << " bounds: " << textBounds.toString() + << " computedTextBounds: " << computedTextBounds.toString() + << " fontSize: " << fontSize + << " fontHeight: " << font.getHeight() + << " fontAscent: " << fontAscent + << " fontDescent: " << fontDescent + << " metricsAscentUsed: " << ascent + << " metricsDescentUsed: " << descent + << " fontWeight: " << font.getWeight() + << " fontItalic: " << (font.isItalic() ? "true" : "false") + << " fontAxisCount: " << font.getNumAxis() + << " anchor: " << element.textAnchor.value_or (String ("none")) + << " letterSpacing: " << element.letterSpacing.value_or (0.0f) + << " wordSpacing: " << element.wordSpacing.value_or (0.0f) + << " textWidth: " << textWidth + << " textHeight: " << textHeight + << " bottomPadding: " << bottomPadding + << " graphicsTransform: " << g.getTransform().toString()); + + YUP_DRAWABLE_LOG ("renderTextElement drawing fitted text - bounds: " << textBounds.toString()); g.fillFittedText (styledText, textBounds); } void Drawable::renderImageElement (Graphics& g, const Element& element) { if (! element.imageBounds) + { + YUP_DRAWABLE_LOG ("renderImageElement skipped - missing bounds" + << " tag: " << element.tagName + << " href: " << element.imageHref.value_or (String ("none"))); return; + } if (element.image) { + YUP_DRAWABLE_LOG ("renderImageElement - drawing cached image bounds: " << element.imageBounds->toString()); g.drawImage (*element.image, *element.imageBounds); return; } @@ -2188,14 +3390,29 @@ void Drawable::renderImageElement (Graphics& g, const Element& element) if (element.imageHref) { if (auto image = loadImageFromHref (*element.imageHref)) + { + YUP_DRAWABLE_LOG ("renderImageElement - drawing resolved image href: " << *element.imageHref + << " bounds: " << element.imageBounds->toString()); g.drawImage (*image, *element.imageBounds); + } + else + { + YUP_DRAWABLE_LOG ("renderImageElement skipped - image href did not resolve: " << *element.imageHref); + } + } + else + { + YUP_DRAWABLE_LOG ("renderImageElement skipped - missing image and href"); } } Path Drawable::createDashedPath (const Path& source, const Array& dashArray, float dashOffset) const { if (dashArray.isEmpty()) + { + YUP_DRAWABLE_LOG ("createDashedPath - dash array empty, returning source"); return source; + } Array positiveDashes; for (auto dash : dashArray) @@ -2205,7 +3422,10 @@ Path Drawable::createDashedPath (const Path& source, const Array& dashArr } if (positiveDashes.isEmpty()) + { + YUP_DRAWABLE_LOG ("createDashedPath - no positive dash values, returning source"); return source; + } Path result; float totalPatternLength = 0.0f; @@ -2213,7 +3433,15 @@ Path Drawable::createDashedPath (const Path& source, const Array& dashArr totalPatternLength += dash; if (totalPatternLength <= 0.0f) + { + YUP_DRAWABLE_LOG ("createDashedPath - total pattern length invalid, returning source"); return source; + } + + YUP_DRAWABLE_LOG ("createDashedPath - sourceBounds: " << source.getBounds().toString() + << " dashCount: " << positiveDashes.size() + << " dashOffset: " << dashOffset + << " totalPatternLength: " << totalPatternLength); int dashIndex = 0; float patternPosition = std::fmod (jmax (0.0f, dashOffset), totalPatternLength); @@ -2286,6 +3514,8 @@ Path Drawable::createDashedPath (const Path& source, const Array& dashArr } } + YUP_DRAWABLE_LOG ("createDashedPath result - bounds: " << result.getBounds().toString() + << " empty: " << (result.isEmpty() ? "true" : "false")); return result; } @@ -2295,10 +3525,17 @@ Rectangle Drawable::calculateBounds() const { // Use viewBox if available, otherwise use size if (! viewBox.isEmpty()) + { + YUP_DRAWABLE_LOG ("calculateBounds - using viewBox: " << viewBox.toString()); return viewBox; + } if (size.getWidth() > 0 && size.getHeight() > 0) + { + auto sizeBounds = Rectangle (0, 0, size.getWidth(), size.getHeight()); + YUP_DRAWABLE_LOG ("calculateBounds - using size: " << sizeBounds.toString()); return Rectangle (0, 0, size.getWidth(), size.getHeight()); + } // Fallback: calculate bounds from all elements with their transforms applied // This gives us the actual visual bounds of the rendered content @@ -2313,6 +3550,11 @@ Rectangle Drawable::calculateBounds() const if (element->transform) pathBounds = element->path->getBoundsTransformed (*element->transform); + YUP_DRAWABLE_LOG ("calculateBounds - element tag: " << element->tagName + << " id: " << element->id.value_or (String ("none")) + << " bounds: " << pathBounds.toString() + << " hasTransform: " << (element->transform ? "true" : "false")); + if (hasValidBounds) bounds = bounds.unionWith (pathBounds); else @@ -2321,9 +3563,17 @@ Rectangle Drawable::calculateBounds() const hasValidBounds = true; } } + else + { + YUP_DRAWABLE_LOG ("calculateBounds - skipping element without path tag: " << element->tagName + << " id: " << element->id.value_or (String ("none"))); + } } - return hasValidBounds ? bounds : Rectangle (0, 0, 100, 100); + auto result = hasValidBounds ? bounds : Rectangle (0, 0, 100, 100); + YUP_DRAWABLE_LOG ("calculateBounds result - hasValidBounds: " << (hasValidBounds ? "true" : "false") + << " bounds: " << result.toString()); + return result; } //============================================================================== @@ -2331,10 +3581,21 @@ Rectangle Drawable::calculateBounds() const AffineTransform Drawable::calculateTransformForTarget (const Rectangle& sourceBounds, const Rectangle& targetArea, Fitting fitting, Justification justification) const { if (sourceBounds.isEmpty() || targetArea.isEmpty()) + { + YUP_DRAWABLE_LOG ("calculateTransformForTarget - empty source or target" + << " source: " << sourceBounds.toString() + << " target: " << targetArea.toString()); return AffineTransform::identity(); + } float scaleX = targetArea.getWidth() / sourceBounds.getWidth(); float scaleY = targetArea.getHeight() / sourceBounds.getHeight(); + YUP_DRAWABLE_LOG ("calculateTransformForTarget - source: " << sourceBounds.toString() + << " target: " << targetArea.toString() + << " initialScaleX: " << scaleX + << " initialScaleY: " << scaleY + << " fitting: " << static_cast (fitting) + << " justificationFlags: " << static_cast (justification.getFlags())); // Apply scaling based on fitting mode switch (fitting) @@ -2404,9 +3665,18 @@ AffineTransform Drawable::calculateTransformForTarget (const Rectangle& s offsetY += targetArea.getHeight() - scaledHeight; // Create transform: translate to origin, scale, then translate to target position - return AffineTransform::translation (-sourceBounds.getX(), -sourceBounds.getY()) - .scaled (scaleX, scaleY) - .translated (offsetX, offsetY); + auto result = AffineTransform::translation (-sourceBounds.getX(), -sourceBounds.getY()) + .scaled (scaleX, scaleY) + .translated (offsetX, offsetY); + + YUP_DRAWABLE_LOG ("calculateTransformForTarget result - scaleX: " << scaleX + << " scaleY: " << scaleY + << " scaledWidth: " << scaledWidth + << " scaledHeight: " << scaledHeight + << " offsetX: " << offsetX + << " offsetY: " << offsetY + << " transform: " << result.toString()); + return result; } //============================================================================== diff --git a/modules/yup_graphics/drawables/yup_Drawable.h b/modules/yup_graphics/drawables/yup_Drawable.h index 45933ba21..f3718b67c 100644 --- a/modules/yup_graphics/drawables/yup_Drawable.h +++ b/modules/yup_graphics/drawables/yup_Drawable.h @@ -136,6 +136,7 @@ class YUP_API Drawable std::optional> strokeDashArray; std::optional strokeDashOffset; std::optional fillRule; // "evenodd" or "nonzero" + std::optional clipRule; // "evenodd" or "nonzero" bool noFill = false; bool noStroke = false; @@ -157,6 +158,7 @@ class YUP_API Drawable // Gradient properties std::optional fillUrl; std::optional strokeUrl; + std::optional filterUrl; // Image properties std::optional imageHref; @@ -202,6 +204,7 @@ class YUP_API Drawable String id; Units units = ObjectBoundingBox; // Default per SVG spec String href; // xlink:href reference to another gradient + String spreadMethod = "pad"; // Linear gradient properties Point start; @@ -220,16 +223,34 @@ class YUP_API Drawable bool hasCenter = false; bool hasRadius = false; bool hasFocal = false; + bool hasUnits = false; + bool hasSpreadMethod = false; }; struct ClipPath : public ReferenceCountedObject { using Ptr = ReferenceCountedObjectPtr; + enum Units + { + UserSpaceOnUse, + ObjectBoundingBox + }; + String id; + Units units = UserSpaceOnUse; std::vector elements; }; + struct Filter : public ReferenceCountedObject + { + using Ptr = ReferenceCountedObjectPtr; + + String id; + String href; + std::optional gaussianBlurStdDeviation; + }; + struct CssRule { String selector; @@ -247,6 +268,9 @@ class YUP_API Drawable Gradient::Ptr getGradientById (const String& id); Gradient::Ptr resolveGradient (Gradient::Ptr gradient); ColorGradient createColorGradientFromSVG (const Gradient& gradient, const Rectangle* objectBounds = nullptr); + void parseFilter (const XmlElement& element); + Filter::Ptr getFilterById (const String& id); + Filter::Ptr resolveFilter (Filter::Ptr filter); void parseClipPath (const XmlElement& element); ClipPath::Ptr getClipPathById (const String& id); void parseCSSStyle (const String& styleString, Element& e); @@ -283,6 +307,8 @@ class YUP_API Drawable HashMap elementsById; std::vector gradients; HashMap gradientsById; + std::vector filters; + HashMap filtersById; std::vector clipPaths; HashMap clipPathsById; std::vector cssRules; diff --git a/modules/yup_graphics/graphics/yup_Graphics.cpp b/modules/yup_graphics/graphics/yup_Graphics.cpp index 5905f05fa..a28744f79 100644 --- a/modules/yup_graphics/graphics/yup_Graphics.cpp +++ b/modules/yup_graphics/graphics/yup_Graphics.cpp @@ -458,7 +458,7 @@ void Graphics::setClipPath (const Path& clipPath) options.clipPath = clipPath; auto renderPath = rive::make_rcp(); - renderPath->fillRule (rive::FillRule::nonZero); + renderPath->fillRule (clipPath.isUsingNonZeroWinding() ? rive::FillRule::nonZero : rive::FillRule::evenOdd); renderPath->addRenderPath (clipPath.getRenderPath(), options.getLocalTransform().toMat2D()); renderer.clipPath (renderPath.get()); @@ -642,6 +642,7 @@ void Graphics::renderStrokePath (const Path& path, const RenderOptions& options, paint.thickness (options.getStrokeWidth()); paint.join (toStrokeJoin (options.join)); paint.cap (toStrokeCap (options.cap)); + paint.feather (options.feather); if (options.isStrokeColor()) paint.color ((rive::ColorInt) options.getStrokeColor()); @@ -805,13 +806,16 @@ void Graphics::renderFittedText (const StyledText& text, const Rectangle& renderer.save(); - rive::RawPath path; - path.addRect (rect.toAABB()); - path.transformInPlace (options.getTransform().toMat2D()); - auto renderPath = rive::make_rcp (rive::FillRule::clockwise, path); - renderer.clipPath (renderPath.get()); + if (text.getOverflow() != StyledText::visible) + { + rive::RawPath path; + path.addRect (rect.toAABB()); + path.transformInPlace (options.getTransform().toMat2D()); + auto renderPath = rive::make_rcp (rive::FillRule::clockwise, path); + renderer.clipPath (renderPath.get()); + } - auto offset = text.getOffset (rect); // We will just use vertical offset + auto offset = text.getOffset (rect); // Horizontal alignment is already baked into the shaped glyph paths. auto transform = options.getTransform (rect.getX(), rect.getY() + offset.getY()); renderer.transform (transform.toMat2D()); diff --git a/modules/yup_graphics/graphics/yup_Graphics.h b/modules/yup_graphics/graphics/yup_Graphics.h index 389820477..6360b1b33 100644 --- a/modules/yup_graphics/graphics/yup_Graphics.h +++ b/modules/yup_graphics/graphics/yup_Graphics.h @@ -545,7 +545,7 @@ class YUP_API Graphics bool isStrokeColorGradient() const noexcept { - return ! isCurrentFillColor; + return ! isCurrentStrokeColor; } ColorGradient getFillColorGradient() const noexcept @@ -595,8 +595,9 @@ class YUP_API Graphics AffineTransform getTransform (float offsetX, float offsetY) const noexcept { - return transform - .translated (drawingArea.getX() + offsetX, drawingArea.getY() + offsetY) + return AffineTransform::translation (offsetX, offsetY) + .followedBy (transform) + .translated (drawingArea.getX(), drawingArea.getY()) .scaled (scale); } From 1ecf1a641de232e7e554eab286da8af1f08608d7 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 12:54:19 +0200 Subject: [PATCH 03/18] More fixes --- .../yup_graphics/drawables/yup_Drawable.cpp | 47 ++++--- modules/yup_graphics/primitives/yup_Path.cpp | 119 ++++++++++-------- modules/yup_graphics/primitives/yup_Path.h | 27 ++-- tests/yup_graphics/yup_Drawable.cpp | 96 +++++++++++++- tests/yup_graphics/yup_Path.cpp | 115 +++++++++++++++++ 5 files changed, 322 insertions(+), 82 deletions(-) diff --git a/modules/yup_graphics/drawables/yup_Drawable.cpp b/modules/yup_graphics/drawables/yup_Drawable.cpp index c28898181..664224927 100644 --- a/modules/yup_graphics/drawables/yup_Drawable.cpp +++ b/modules/yup_graphics/drawables/yup_Drawable.cpp @@ -317,8 +317,6 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent if (element.color) currentColor = *element.color; - const auto transformBeforeElement = g.getTransform(); - YUP_DRAWABLE_LOG ("paintElement - tag: " << element.tagName << " id: " << (element.id ? *element.id : "none") << " depth: " << recursionDepth @@ -363,9 +361,14 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent { if (filter->gaussianBlurStdDeviation) { - const auto feather = jmax (g.getFeather(), *filter->gaussianBlurStdDeviation); + // SVG feGaussianBlur stdDeviation is sigma. Rive feather follows design-tool + // blur sizing as a two-standard-deviation width, then expands that internally + // to cover the gaussian support. + const auto svgStdDeviationToFeather = 2.0f; + const auto feather = jmax (g.getFeather(), *filter->gaussianBlurStdDeviation * svgStdDeviationToFeather); YUP_DRAWABLE_LOG ("Applying GaussianBlur filter as feather - id: " << *element.filterUrl << " stdDeviation: " << *filter->gaussianBlurStdDeviation + << " featherScale: " << svgStdDeviationToFeather << " previousFeather: " << g.getFeather() << " appliedFeather: " << feather); g.setFeather (feather); @@ -459,26 +462,22 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent { combinedClipPath.setUsingNonZeroWinding (clipUsesNonZeroWinding); - const auto transformDuringClip = g.getTransform(); - if (element.transform) - { - YUP_DRAWABLE_LOG ("Applying clip path in pre-element transform space - id: " << *element.clipPathUrl - << " clipTransform: " << transformBeforeElement.toString() - << " elementTransform: " << element.transform->toString() - << " drawTransform: " << transformDuringClip.toString()); - g.setTransform (transformBeforeElement); - } - - g.setClipPath (combinedClipPath); + // SVG clip paths are in the drawable's current user space. Graphics::setClipPath() + // expects drawing-area coordinates, so resolve the active SVG transform here. + auto clipTransform = g.getTransform().translated (g.getDrawingArea().getTopLeft()); + auto transformedClipPath = combinedClipPath.transformed (clipTransform); - if (element.transform) - g.setTransform (transformDuringClip); + const auto savedClipTransform = g.getTransform(); + g.setTransform (AffineTransform::identity()); + g.setClipPath (transformedClipPath); + g.setTransform (savedClipTransform); hasClipping = true; YUP_DRAWABLE_LOG ("Applied clip path - id: " << *element.clipPathUrl << " elements: " << clipPath->elements.size() << " fillRule: " << (clipUsesNonZeroWinding ? "nonzero" : "evenodd") - << " bounds: " << combinedClipPath.getBounds().toString()); + << " bounds: " << combinedClipPath.getBounds().toString() + << " transformedBounds: " << transformedClipPath.getBounds().toString()); } else { @@ -2282,8 +2281,18 @@ ColorGradient Drawable::createColorGradientFromSVG (const Gradient& gradient, co if (gradient.radius <= 0.0f) return 0.0f; - const auto edgePoint = transformPoint (Point (gradient.center.getX() + gradient.radius, gradient.center.getY())); - return Line (center, edgePoint).length(); + const Point edgePoints[] = { + transformPoint (Point (gradient.center.getX() + gradient.radius, gradient.center.getY())), + transformPoint (Point (gradient.center.getX() - gradient.radius, gradient.center.getY())), + transformPoint (Point (gradient.center.getX(), gradient.center.getY() + gradient.radius)), + transformPoint (Point (gradient.center.getX(), gradient.center.getY() - gradient.radius)) + }; + + float maxRadius = 0.0f; + for (const auto& edgePoint : edgePoints) + maxRadius = jmax (maxRadius, Line (center, edgePoint).length()); + + return maxRadius; }; const auto radius = gradient.type == Gradient::Radial ? computeRadius() : 0.0f; diff --git a/modules/yup_graphics/primitives/yup_Path.cpp b/modules/yup_graphics/primitives/yup_Path.cpp index d092b215a..b87566e0b 100644 --- a/modules/yup_graphics/primitives/yup_Path.cpp +++ b/modules/yup_graphics/primitives/yup_Path.cpp @@ -31,7 +31,7 @@ namespace bool isControlMarker (String::CharPointerType data) { - return ! data.isEmpty() && String ("MmLlHhVvQqCcSsZz").containsChar (*data); + return ! data.isEmpty() && String ("MmLlHhVvQqTtCcSsAaZz").containsChar (*data); } void skipWhitespace (String::CharPointerType& data) @@ -50,80 +50,98 @@ void skipWhitespaceOrComma (String::CharPointerType& data) bool parseFlag (String::CharPointerType& data, int& flag) { - skipWhitespace (data); + skipWhitespaceOrComma (data); - String number; - - while (! data.isEmpty()) - { - if (data.isWhitespace() || *data == '.' || *data == ',' || *data == '-' || isControlMarker (data)) - break; - - if (! (*data >= '0' && *data <= '9')) - break; - - number += *data; - ++data; - } - - if (number.isNotEmpty()) - { - flag = number.getIntValue(); - - skipWhitespaceOrComma (data); - return true; - } + if (data.isEmpty() || (*data != '0' && *data != '1')) + return false; - return false; + flag = *data - '0'; + ++data; + skipWhitespaceOrComma (data); + return true; } //============================================================================== bool parseCoordinate (String::CharPointerType& data, float& coord) { - skipWhitespace (data); + skipWhitespaceOrComma (data); + + auto start = data; + + if (data.isEmpty() || isControlMarker (data)) + return false; String number; - bool isNegative = false; - bool pointFound = false; + bool hasDigits = false; - if (*data == '-') + if (*data == '-' || *data == '+') { - isNegative = true; + number += *data; ++data; } - while (! data.isEmpty()) + while (! data.isEmpty() && *data >= '0' && *data <= '9') { - if (data.isWhitespace() || *data == ',' || *data == '-' || isControlMarker (data)) - break; + hasDigits = true; + number += *data; + ++data; + } - if (*data == '.') - { - if (pointFound) - break; - pointFound = true; - } - else if (! (*data >= '0' && *data <= '9')) + if (! data.isEmpty() && *data == '.') + { + number += *data; + ++data; + + while (! data.isEmpty() && *data >= '0' && *data <= '9') { - break; + hasDigits = true; + number += *data; + ++data; } + } - number += *data; - ++data; + if (! hasDigits) + { + data = start; + return false; } - if (number.isNotEmpty()) + if (! data.isEmpty() && (*data == 'e' || *data == 'E')) { - coord = number.getFloatValue(); - if (isNegative) - coord = -coord; + auto exponentStart = data; + String exponent; - skipWhitespaceOrComma (data); - return true; + exponent += *data; + ++data; + + if (! data.isEmpty() && (*data == '-' || *data == '+')) + { + exponent += *data; + ++data; + } + + bool hasExponentDigits = false; + while (! data.isEmpty() && *data >= '0' && *data <= '9') + { + hasExponentDigits = true; + exponent += *data; + ++data; + } + + if (hasExponentDigits) + { + number += exponent; + } + else + { + data = exponentStart; + } } - return false; + coord = number.getFloatValue(); + skipWhitespaceOrComma (data); + return true; } //============================================================================== @@ -421,6 +439,9 @@ void handleEllipticalArc (String::CharPointerType& data, Path& path, float& curr && parseFlag (data, sweep) && parseCoordinates (data, x, y)) { + rx = std::abs (rx); + ry = std::abs (ry); + if (relative) { x += currentX; diff --git a/modules/yup_graphics/primitives/yup_Path.h b/modules/yup_graphics/primitives/yup_Path.h index eaf63a650..d9065a766 100644 --- a/modules/yup_graphics/primitives/yup_Path.h +++ b/modules/yup_graphics/primitives/yup_Path.h @@ -217,25 +217,26 @@ class YUP_API Path Path& quadTo (const Point& p, float x1, float y1); //============================================================================== - /** Draws a cubic Bezier curve to specified coordinates with two control points. + /** Draws a cubic Bezier curve with two control points and an endpoint. - This method draws a cubic Bezier curve from the current point to the point (x, y), using - (x1, y1) and (x2, y2) as the control points. The current point is then updated to (x, y). + This method draws a cubic Bezier curve from the current point using (x, y) as the first + control point, (x1, y1) as the second control point, and (x2, y2) as the endpoint. The + current point is then updated to (x2, y2). - @param x The x-coordinate of the endpoint. - @param y The y-coordinate of the endpoint. - @param x1 The x-coordinate of the first control point. - @param y1 The y-coordinate of the first control point. - @param x2 The x-coordinate of the second control point. - @param y2 The y-coordinate of the second control point. + @param x The x-coordinate of the first control point. + @param y The y-coordinate of the first control point. + @param x1 The x-coordinate of the second control point. + @param y1 The y-coordinate of the second control point. + @param x2 The x-coordinate of the endpoint. + @param y2 The y-coordinate of the endpoint. */ Path& cubicTo (float x, float y, float x1, float y1, float x2, float y2); - /** Draws a cubic Bezier curve to specified coordinates with two control points. + /** Draws a cubic Bezier curve with two control points and an endpoint. - This method draws a cubic Bezier curve from the current point to the endpoint (x1, y1), - using the given point p and (x2, y2) as the control points. The current point is then updated - to (x1, y1). + This method draws a cubic Bezier curve from the current point using p as the first control + point, (x1, y1) as the second control point, and (x2, y2) as the endpoint. The current point + is then updated to (x2, y2). @param p The first control point. @param x1 The x-coordinate of the second control point. diff --git a/tests/yup_graphics/yup_Drawable.cpp b/tests/yup_graphics/yup_Drawable.cpp index 9c1ca39c0..ffdcc6cd3 100644 --- a/tests/yup_graphics/yup_Drawable.cpp +++ b/tests/yup_graphics/yup_Drawable.cpp @@ -199,7 +199,7 @@ TEST (DrawableTests, ParseSVGWithParseOptionsImageResolver) Drawable::ParseOptions options; options.imageResolver = [] (StringRef href, const File&) -> std::optional { - if (href == "custom-image") + if (href == StringRef ("custom-image")) { Image image (2, 2, PixelFormat::RGBA); image.fill (0xffff0000); @@ -836,6 +836,100 @@ TEST (DrawableTests, ParseSVGWithRadialGradient) tempFile.deleteFile(); } +TEST (DrawableTests, PaintSVGWithTransformedClipPath) +{ + Drawable drawable; + + bool result = drawable.parseSVG ( + "" + "" + "" + "" + "" + ""); + + EXPECT_TRUE (result); + + auto context = GraphicsContext::createContext (GraphicsContext::Headless, {}); + auto renderer = context->makeRenderer (64, 64); + Graphics graphics (*context, *renderer); + + EXPECT_NO_THROW ({ + drawable.paint (graphics, Rectangle (0.0f, 0.0f, 64.0f, 64.0f)); + }); +} + +TEST (DrawableTests, PaintSVGWithScimitarClipPathAndGradientStroke) +{ + Drawable drawable; + + const String scimitarPath = "M 171.59375,-167.8125 L 153.4375,-131.09375 C 153.4375,-131.09375 240.05975,-44.592207 260.53125,61.53125 " + "C 263.78902,59.713413 267.53809,58.6875 271.53125,58.6875 C 283.99674,58.687502 294.11733,68.78455 294.15625,81.25 " + "L 294.15625,81.3125 C 294.15624,93.802829 284.02158,103.9375 271.53125,103.9375 " + "C 269.20004,103.9375 266.9604,103.59314 264.84375,102.9375 C 265.00283,118.53432 263.43644,134.33614 259.71875,150.1875 " + "C 279.93177,155.71176 336.35552,161.63753 367.0625,234.84375 C 388.95186,159.67792 354.15709,-29.134107 171.59375,-167.8125 z "; + + String svg; + svg << "" + << "" + << "" + << "" + << "" + << "" + << "" + << "" + << "" + << "" + << "" + << "" + << "" + << "" + << ""; + + bool result = drawable.parseSVG (svg); + + EXPECT_TRUE (result); + + auto context = GraphicsContext::createContext (GraphicsContext::Headless, {}); + auto renderer = context->makeRenderer (96, 96); + Graphics graphics (*context, *renderer); + graphics.setDrawingArea (Rectangle (23.0f, 17.0f, 96.0f, 96.0f)); + + EXPECT_NO_THROW ({ + drawable.paint (graphics, Rectangle (0.0f, 0.0f, 96.0f, 96.0f)); + }); +} + +TEST (DrawableTests, PaintSVGWithTransformedRadialGradient) +{ + Drawable drawable; + + bool result = drawable.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + ""); + + EXPECT_TRUE (result); + + auto context = GraphicsContext::createContext (GraphicsContext::Headless, {}); + auto renderer = context->makeRenderer (64, 64); + Graphics graphics (*context, *renderer); + + EXPECT_NO_THROW ({ + drawable.paint (graphics, Rectangle (0.0f, 0.0f, 64.0f, 64.0f)); + }); +} + // ============================================================================== // Edge Cases and Error Handling // ============================================================================== diff --git a/tests/yup_graphics/yup_Path.cpp b/tests/yup_graphics/yup_Path.cpp index c9d0dc6ca..f422a5121 100644 --- a/tests/yup_graphics/yup_Path.cpp +++ b/tests/yup_graphics/yup_Path.cpp @@ -718,6 +718,103 @@ TEST (PathTests, FromStringCubicBezierRelative) EXPECT_FALSE (p.getBounds().isEmpty()); } +TEST (PathTests, FromStringCubicBezierPreservesSVGCoordinateOrder) +{ + Path p; + bool ok = p.fromString ("M 0 0 C 10 0, 20 10, 30 0"); + EXPECT_TRUE (ok); + + auto it = p.begin(); + ASSERT_NE (it, p.end()); + EXPECT_EQ ((*it).verb, Path::Verb::MoveTo); + + ++it; + ASSERT_NE (it, p.end()); + + const auto segment = *it; + EXPECT_EQ (segment.verb, Path::Verb::CubicTo); + expectPointNear (segment.controlPoint1, Point (10.0f, 0.0f)); + expectPointNear (segment.controlPoint2, Point (20.0f, 10.0f)); + expectPointNear (segment.point, Point (30.0f, 0.0f)); +} + +TEST (PathTests, FromStringParsesScimitarCubicPath) +{ + Path p; + bool ok = p.fromString ( + "M 171.59375,-167.8125 L 153.4375,-131.09375 C 153.4375,-131.09375 240.05975,-44.592207 260.53125,61.53125 " + "C 263.78902,59.713413 267.53809,58.6875 271.53125,58.6875 C 283.99674,58.687502 294.11733,68.78455 294.15625,81.25 " + "L 294.15625,81.3125 C 294.15624,93.802829 284.02158,103.9375 271.53125,103.9375 " + "C 269.20004,103.9375 266.9604,103.59314 264.84375,102.9375 C 265.00283,118.53432 263.43644,134.33614 259.71875,150.1875 " + "C 279.93177,155.71176 336.35552,161.63753 367.0625,234.84375 C 388.95186,159.67792 354.15709,-29.134107 171.59375,-167.8125 z "); + + EXPECT_TRUE (ok); + EXPECT_EQ (12, p.size()); + + auto it = p.begin(); + ASSERT_NE (it, p.end()); + EXPECT_EQ ((*it).verb, Path::Verb::MoveTo); + expectPointNear ((*it).point, Point (171.59375f, -167.8125f)); + + ++it; + ASSERT_NE (it, p.end()); + EXPECT_EQ ((*it).verb, Path::Verb::LineTo); + expectPointNear ((*it).point, Point (153.4375f, -131.09375f)); + + ++it; + ASSERT_NE (it, p.end()); + + const auto segment = *it; + EXPECT_EQ (segment.verb, Path::Verb::CubicTo); + expectPointNear (segment.controlPoint1, Point (153.4375f, -131.09375f)); + expectPointNear (segment.controlPoint2, Point (240.05975f, -44.592207f)); + expectPointNear (segment.point, Point (260.53125f, 61.53125f)); +} + +TEST (PathTests, FromStringQuadraticBezierConvertsUsingSVGCoordinateOrder) +{ + Path p; + bool ok = p.fromString ("M 0 0 Q 30 30, 60 0"); + EXPECT_TRUE (ok); + + auto it = p.begin(); + ASSERT_NE (it, p.end()); + EXPECT_EQ ((*it).verb, Path::Verb::MoveTo); + + ++it; + ASSERT_NE (it, p.end()); + + const auto segment = *it; + EXPECT_EQ (segment.verb, Path::Verb::CubicTo); + expectPointNear (segment.controlPoint1, Point (20.0f, 20.0f)); + expectPointNear (segment.controlPoint2, Point (40.0f, 20.0f)); + expectPointNear (segment.point, Point (60.0f, 0.0f)); +} + +TEST (PathTests, FromStringParsesSignedExponentCoordinates) +{ + Path p; + bool ok = p.fromString ("M +1e1 -2e1 L 2.5e1,+3.5e1 C +3e1 -4e1 4e1 -5e1 5e1 -6e1"); + EXPECT_TRUE (ok); + + auto it = p.begin(); + ASSERT_NE (it, p.end()); + expectPointNear ((*it).point, Point (10.0f, -20.0f)); + + ++it; + ASSERT_NE (it, p.end()); + expectPointNear ((*it).point, Point (25.0f, 35.0f)); + + ++it; + ASSERT_NE (it, p.end()); + + const auto segment = *it; + EXPECT_EQ (segment.verb, Path::Verb::CubicTo); + expectPointNear (segment.controlPoint1, Point (30.0f, -40.0f)); + expectPointNear (segment.controlPoint2, Point (40.0f, -50.0f)); + expectPointNear (segment.point, Point (50.0f, -60.0f)); +} + TEST (PathTests, FromStringSmoothCubicAbsolute) { Path p; @@ -742,6 +839,24 @@ TEST (PathTests, FromStringEllipticalArcAbsolute) EXPECT_FALSE (p.getBounds().isEmpty()); } +TEST (PathTests, FromStringEllipticalArcParsesCompactFlags) +{ + Path p; + bool ok = p.fromString ("M 0 0 A 10 10 0 0150 0"); + EXPECT_TRUE (ok); + EXPECT_GT (p.size(), 1); + EXPECT_FALSE (p.getBounds().isEmpty()); +} + +TEST (PathTests, FromStringEllipticalArcUsesAbsoluteRadii) +{ + Path p; + bool ok = p.fromString ("M 0 0 A -10 -10 0 0 1 50 0"); + EXPECT_TRUE (ok); + EXPECT_GT (p.size(), 1); + EXPECT_FALSE (p.getBounds().isEmpty()); +} + TEST (PathTests, FromStringEllipticalArcRelative) { Path p; From e8a765d844821a922e1d67f17f8ec53eaf7f51f2 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 13:17:12 +0200 Subject: [PATCH 04/18] A new parser --- examples/graphics/source/examples/Svg.h | 3 + modules/yup_core/containers/yup_HashMap.h | 25 +- .../yup_graphics/drawables/yup_Drawable.cpp | 3449 ++--------------- modules/yup_graphics/drawables/yup_Drawable.h | 244 +- modules/yup_graphics/svg/yup_SVGClipPath.h | 41 + modules/yup_graphics/svg/yup_SVGCssParser.cpp | 414 ++ modules/yup_graphics/svg/yup_SVGCssParser.h | 45 + modules/yup_graphics/svg/yup_SVGCssRule.h | 34 + modules/yup_graphics/svg/yup_SVGDocument.cpp | 92 + modules/yup_graphics/svg/yup_SVGDocument.h | 108 + modules/yup_graphics/svg/yup_SVGElement.h | 94 + modules/yup_graphics/svg/yup_SVGFilter.h | 35 + modules/yup_graphics/svg/yup_SVGGradient.h | 74 + modules/yup_graphics/svg/yup_SVGParser.cpp | 1640 ++++++++ modules/yup_graphics/svg/yup_SVGParser.h | 90 + modules/yup_graphics/yup_graphics.cpp | 18 + modules/yup_graphics/yup_graphics.h | 8 + 17 files changed, 3116 insertions(+), 3298 deletions(-) create mode 100644 modules/yup_graphics/svg/yup_SVGClipPath.h create mode 100644 modules/yup_graphics/svg/yup_SVGCssParser.cpp create mode 100644 modules/yup_graphics/svg/yup_SVGCssParser.h create mode 100644 modules/yup_graphics/svg/yup_SVGCssRule.h create mode 100644 modules/yup_graphics/svg/yup_SVGDocument.cpp create mode 100644 modules/yup_graphics/svg/yup_SVGDocument.h create mode 100644 modules/yup_graphics/svg/yup_SVGElement.h create mode 100644 modules/yup_graphics/svg/yup_SVGFilter.h create mode 100644 modules/yup_graphics/svg/yup_SVGGradient.h create mode 100644 modules/yup_graphics/svg/yup_SVGParser.cpp create mode 100644 modules/yup_graphics/svg/yup_SVGParser.h diff --git a/examples/graphics/source/examples/Svg.h b/examples/graphics/source/examples/Svg.h index edc88123e..8712973a4 100644 --- a/examples/graphics/source/examples/Svg.h +++ b/examples/graphics/source/examples/Svg.h @@ -62,7 +62,10 @@ class SvgDemo : public yup::Component return; for (const auto& svgFile : files) + { + //if (svgFile.getFileName() == "mozilla2.svg") svgFiles.add (svgFile); + } } void parseSvgFile (int index) diff --git a/modules/yup_core/containers/yup_HashMap.h b/modules/yup_core/containers/yup_HashMap.h index 85b93ac80..8cef8c552 100644 --- a/modules/yup_core/containers/yup_HashMap.h +++ b/modules/yup_core/containers/yup_HashMap.h @@ -134,6 +134,25 @@ class HashMap public: //============================================================================== + /** Creates an empty hash-map. + */ + HashMap() + : HashMap (defaultHashTableSize, HashFunctionType()) + { + } + + /** Creates an empty hash-map. + + @param numberOfSlots Specifies the number of hash entries the map will use. This will be + the "upperLimit" parameter that is passed to your generateHash() + function. The number of hash slots will grow automatically if necessary, + or it can be remapped manually using remapTable(). + */ + explicit HashMap (int numberOfSlots) + : HashMap (numberOfSlots, HashFunctionType()) + { + } + /** Creates an empty hash-map. @param numberOfSlots Specifies the number of hash entries the map will use. This will be @@ -141,11 +160,9 @@ class HashMap function. The number of hash slots will grow automatically if necessary, or it can be remapped manually using remapTable(). @param hashFunction An instance of HashFunctionType, which will be copied and - stored to use with the HashMap. This parameter can be omitted - if HashFunctionType has a default constructor. + stored to use with the HashMap. */ - explicit HashMap (int numberOfSlots = defaultHashTableSize, - HashFunctionType hashFunction = HashFunctionType()) + HashMap (int numberOfSlots, HashFunctionType hashFunction) : hashFunctionToUse (hashFunction) { hashSlots.insertMultiple (0, nullptr, numberOfSlots); diff --git a/modules/yup_graphics/drawables/yup_Drawable.cpp b/modules/yup_graphics/drawables/yup_Drawable.cpp index 664224927..1d91bce6f 100644 --- a/modules/yup_graphics/drawables/yup_Drawable.cpp +++ b/modules/yup_graphics/drawables/yup_Drawable.cpp @@ -2,7 +2,7 @@ ============================================================================== This file is part of the YUP library. - Copyright (c) 2025 - kunitoki@gmail.com + Copyright (c) 2026 - kunitoki@gmail.com YUP is an open source library subject to open-source licensing. @@ -23,17 +23,97 @@ namespace yup { //============================================================================== -#ifndef YUP_DRAWABLE_LOGGING -#define YUP_DRAWABLE_LOGGING 1 -#endif - -#if YUP_DRAWABLE_LOGGING -#define YUP_DRAWABLE_LOG(textToWrite) YUP_DBG (textToWrite) -#else -#define YUP_DRAWABLE_LOG(textToWrite) \ - { \ + +namespace +{ +SVGGradient::Ptr getGradientById (const SVGData& data, const String& id) +{ + return data.gradientsById[id]; +} + +SVGGradient::Ptr resolveGradient (const SVGData& data, SVGGradient::Ptr gradient) +{ + if (gradient == nullptr || gradient->href.isEmpty()) + return gradient; + + auto referencedGradient = getGradientById (data, gradient->href); + if (referencedGradient == nullptr) + return gradient; + + referencedGradient = resolveGradient (data, referencedGradient); + + SVGGradient::Ptr resolved = new SVGGradient; + resolved->type = gradient->type; + resolved->id = gradient->id; + resolved->units = referencedGradient->units; + resolved->spreadMethod = referencedGradient->spreadMethod; + resolved->start = referencedGradient->start; + resolved->end = referencedGradient->end; + resolved->center = referencedGradient->center; + resolved->radius = referencedGradient->radius; + resolved->focal = referencedGradient->focal; + resolved->transform = referencedGradient->transform; + resolved->stops = referencedGradient->stops; + resolved->hasUnits = referencedGradient->hasUnits; + resolved->hasSpreadMethod = referencedGradient->hasSpreadMethod; + + if (gradient->hasStart) + resolved->start = gradient->start; + if (gradient->hasEnd) + resolved->end = gradient->end; + if (gradient->hasCenter) + resolved->center = gradient->center; + if (gradient->hasRadius) + resolved->radius = gradient->radius; + if (gradient->hasFocal) + resolved->focal = gradient->focal; + + if (! gradient->transform.isIdentity()) + resolved->transform = gradient->transform; + if (gradient->hasUnits) + { + resolved->units = gradient->units; + resolved->hasUnits = true; + } + if (gradient->hasSpreadMethod) + { + resolved->spreadMethod = gradient->spreadMethod; + resolved->hasSpreadMethod = true; } -#endif + if (! gradient->stops.empty()) + resolved->stops = gradient->stops; + + return resolved; +} + +SVGFilter::Ptr getFilterById (const SVGData& data, const String& id) +{ + return data.filtersById[id]; +} + +SVGFilter::Ptr resolveFilter (const SVGData& data, SVGFilter::Ptr filter) +{ + if (filter == nullptr || filter->href.isEmpty()) + return filter; + + auto referencedFilter = resolveFilter (data, getFilterById (data, filter->href)); + if (referencedFilter == nullptr) + return filter; + + SVGFilter::Ptr resolved = new SVGFilter; + resolved->id = filter->id; + resolved->href = filter->href; + resolved->gaussianBlurStdDeviation = filter->gaussianBlurStdDeviation + ? filter->gaussianBlurStdDeviation + : referencedFilter->gaussianBlurStdDeviation; + return resolved; +} + +SVGClipPath::Ptr getClipPathById (const SVGData& data, const String& id) +{ + return data.clipPathsById[id]; +} +} // namespace //============================================================================== @@ -62,237 +142,112 @@ bool Drawable::parseSVG (StringRef svgText) bool Drawable::parseSVG (const File& svgFile, const ParseOptions& options) { - YUP_DRAWABLE_LOG ("parseSVG(file, options) - file: " << svgFile.getFullPathName() - << " baseDirectory: " << options.baseDirectory.getFullPathName() - << " allowDataImages: " << (options.allowDataImages ? "true" : "false") - << " allowLocalImages: " << (options.allowLocalImages ? "true" : "false") - << " hasImageResolver: " << (options.imageResolver ? "true" : "false") - << " hasFontResolver: " << (options.fontResolver ? "true" : "false")); - - clear(); + YUP_DRAWABLE_LOG ("parseSVG(file, options) - file: " << svgFile.getFullPathName()); - parseOptions = options; - if (parseOptions.baseDirectory.getFullPathName().isEmpty()) - parseOptions.baseDirectory = svgFile.getParentDirectory(); - - XmlDocument svgDoc (svgFile); - std::unique_ptr svgRoot (svgDoc.getDocumentElement()); - - return parseDocument (std::move (svgRoot)); + document = SVGParser::parse (svgFile, options); + return document != nullptr; } bool Drawable::parseSVG (StringRef svgText, const ParseOptions& options) { - YUP_DRAWABLE_LOG ("parseSVG(text, options) - length: " << String (svgText.text).length() - << " baseDirectory: " << options.baseDirectory.getFullPathName() - << " allowDataImages: " << (options.allowDataImages ? "true" : "false") - << " allowLocalImages: " << (options.allowLocalImages ? "true" : "false") - << " hasImageResolver: " << (options.imageResolver ? "true" : "false") - << " hasFontResolver: " << (options.fontResolver ? "true" : "false")); - - clear(); - - parseOptions = options; - - XmlDocument svgDoc (String (svgText.text)); - std::unique_ptr svgRoot (svgDoc.getDocumentElement()); - - return parseDocument (std::move (svgRoot)); -} - -bool Drawable::parseDocument (std::unique_ptr svgRoot) -{ - if (svgRoot == nullptr || ! svgRoot->hasTagName ("svg")) - { - YUP_DRAWABLE_LOG ("parseDocument failed - root is " << (svgRoot == nullptr ? "null" : svgRoot->getTagName())); - return false; - } - - YUP_DRAWABLE_LOG ("parseDocument - root attributes width: " << svgRoot->getStringAttribute ("width") - << " height: " << svgRoot->getStringAttribute ("height") - << " viewBox: " << svgRoot->getStringAttribute ("viewBox")); - - if (auto view = svgRoot->getStringAttribute ("viewBox"); view.isNotEmpty()) - { - auto coords = StringArray::fromTokens (view, " ,", ""); - if (coords.size() == 4) - { - viewBox.setX (coords.getReference (0).getFloatValue()); - viewBox.setY (coords.getReference (1).getFloatValue()); - viewBox.setWidth (coords.getReference (2).getFloatValue()); - viewBox.setHeight (coords.getReference (3).getFloatValue()); - } - else - { - YUP_DRAWABLE_LOG ("Invalid root viewBox - expected 4 coordinates, got: " << coords.size() << " value: " << view); - } - } + YUP_DRAWABLE_LOG ("parseSVG(text, options) - length: " << String (svgText.text).length()); - auto width = svgRoot->getFloatAttribute ("width"); - size.setWidth (width == 0.0f ? viewBox.getWidth() : width); - - auto height = svgRoot->getFloatAttribute ("height"); - size.setHeight (height == 0.0f ? viewBox.getHeight() : height); - - // ViewBox transform is now calculated at render-time based on actual target area - YUP_DRAWABLE_LOG ("Parse complete - viewBox: " << viewBox.toString() << " size: " << size.getWidth() << "x" << size.getHeight()); - - std::function collectStyleElements = [&] (const XmlElement& xml) - { - if (xml.hasTagName ("style")) - parseStyleElement (xml); - - for (auto* child = xml.getFirstChildElement(); child != nullptr; child = child->getNextElement()) - collectStyleElements (*child); - }; - - collectStyleElements (*svgRoot); - - auto result = parseElement (*svgRoot, true, {}); - - if (result) - { - bounds = calculateBounds(); - YUP_DRAWABLE_LOG ("parseDocument result - success: true" - << " topLevelElements: " << elements.size() - << " ids: " << elementsById.size() - << " gradients: " << gradients.size() - << " filters: " << filters.size() - << " clipPaths: " << clipPaths.size() - << " cssRules: " << cssRules.size() - << " bounds: " << bounds.toString() - << " rootHasFill: " << (rootHasFill ? "true" : "false") - << " rootHasStroke: " << (rootHasStroke ? "true" : "false") - << " rootFillColor: " << (rootFillColor ? rootFillColor->toString() : "none") - << " rootStrokeColor: " << (rootStrokeColor ? rootStrokeColor->toString() : "none")); - } - else - { - YUP_DRAWABLE_LOG ("parseDocument result - success: false"); - } - - return result; + document = SVGParser::parse (svgText, options); + return document != nullptr; } //============================================================================== void Drawable::clear() { - YUP_DRAWABLE_LOG ("clear - previous topLevelElements: " << elements.size() - << " ids: " << elementsById.size() - << " gradients: " << gradients.size() - << " filters: " << filters.size() - << " clipPaths: " << clipPaths.size() - << " cssRules: " << cssRules.size()); - - viewBox = { 0.0f, 0.0f, 0.0f, 0.0f }; - size = { 0.0f, 0.0f }; - bounds = { 0.0f, 0.0f, 0.0f, 0.0f }; - transform = AffineTransform::identity(); - - elements.clear(); - elementsById.clear(); - gradients.clear(); - gradientsById.clear(); - filters.clear(); - filtersById.clear(); - clipPaths.clear(); - clipPathsById.clear(); - cssRules.clear(); - - // Reset root element's default presentation attributes to SVG defaults - rootHasFill = true; // SVG default fill is black - rootHasStroke = false; // SVG default stroke is none - rootFillColor = std::nullopt; - rootStrokeColor = std::nullopt; + document = nullptr; } //============================================================================== Rectangle Drawable::getBounds() const { - return bounds; + if (document == nullptr) + return {}; + + return document->getBounds(); } //============================================================================== void Drawable::paint (Graphics& g) { - YUP_DRAWABLE_LOG ("paint - bounds: " << bounds.toString() - << " viewBox: " << viewBox.toString() - << " size: " << size.getWidth() << "x" << size.getHeight() - << " topLevelElements: " << elements.size() - << " drawableTransform: " << transform.toString() - << " graphicsTransform: " << g.getTransform().toString() - << " rootHasFill: " << (rootHasFill ? "true" : "false") - << " rootHasStroke: " << (rootHasStroke ? "true" : "false")); + if (document == nullptr) + return; - const auto savedState = g.saveState(); + document->visit ([&] (const SVGData& data) + { + YUP_DRAWABLE_LOG ("paint - bounds: " << data.bounds.toString() + << " topLevelElements: " << data.elements.size() + << " rootHasFill: " << (data.rootHasFill ? "true" : "false") + << " rootHasStroke: " << (data.rootHasStroke ? "true" : "false")); + + const auto savedState = g.saveState(); - g.setStrokeWidth (1.0f); + g.setStrokeWidth (1.0f); - // Set default fill color based on root SVG element or SVG default (black) - if (rootFillColor) - g.setFillColor (*rootFillColor); - else - g.setFillColor (Colors::black); + if (data.rootFillColor) + g.setFillColor (*data.rootFillColor); + else + g.setFillColor (Colors::black); - // Set default stroke color if root SVG element specified one - if (rootStrokeColor) - g.setStrokeColor (*rootStrokeColor); + if (data.rootStrokeColor) + g.setStrokeColor (*data.rootStrokeColor); - if (! transform.isIdentity()) - { - YUP_DRAWABLE_LOG ("paint - applying drawable transform: " << transform.toString()); - g.addTransform (transform); - } + if (! data.transform.isIdentity()) + g.addTransform (data.transform); - // Pass root element's fill/stroke state to top-level elements - for (const auto& element : elements) - paintElement (g, *element, rootHasFill, rootHasStroke, rootFillColor.value_or (Colors::black)); + for (const auto& element : data.elements) + paintElement (g, data, *element, data.rootHasFill, data.rootHasStroke, data.rootFillColor.value_or (Colors::black)); + }); } void Drawable::paint (Graphics& g, const Rectangle& targetArea, Fitting fitting, Justification justification) { - YUP_DRAWABLE_LOG ("Fitted paint called - bounds: " << bounds.toString() << " targetArea: " << targetArea.toString()); + if (document == nullptr) + return; - if (bounds.isEmpty()) + document->visit ([&] (const SVGData& data) { - YUP_DRAWABLE_LOG ("Fitted paint skipped - drawable bounds are empty"); - return; - } + YUP_DRAWABLE_LOG ("Fitted paint called - bounds: " << data.bounds.toString() << " targetArea: " << targetArea.toString()); - const auto savedState = g.saveState(); + if (data.bounds.isEmpty()) + { + YUP_DRAWABLE_LOG ("Fitted paint skipped - drawable bounds are empty"); + return; + } + + const auto savedState = g.saveState(); - auto finalBounds = viewBox.isEmpty() ? bounds : viewBox; - auto finalTransform = calculateTransformForTarget (finalBounds, targetArea, fitting, justification); - YUP_DRAWABLE_LOG ("Fitted paint transform - sourceBounds: " << finalBounds.toString() - << " transform: " << finalTransform.toString() - << " graphicsTransformBefore: " << g.getTransform().toString() - << " topLevelElements: " << elements.size()); - if (! finalTransform.isIdentity()) - g.addTransform (finalTransform); - - g.setStrokeWidth (1.0f); - - // Set default fill color based on root SVG element or SVG default (black) - if (rootFillColor) - g.setFillColor (*rootFillColor); - else - g.setFillColor (Colors::black); - - // Set default stroke color if root SVG element specified one - if (rootStrokeColor) - g.setStrokeColor (*rootStrokeColor); - - // Pass root element's fill/stroke state to top-level elements - for (const auto& element : elements) - paintElement (g, *element, rootHasFill, rootHasStroke, rootFillColor.value_or (Colors::black)); + auto finalBounds = data.viewBox.isEmpty() ? data.bounds : data.viewBox; + auto finalTransform = calculateTransformForTarget (finalBounds, targetArea, fitting, justification); + + if (! finalTransform.isIdentity()) + g.addTransform (finalTransform); + + g.setStrokeWidth (1.0f); + + if (data.rootFillColor) + g.setFillColor (*data.rootFillColor); + else + g.setFillColor (Colors::black); + + if (data.rootStrokeColor) + g.setStrokeColor (*data.rootStrokeColor); + + for (const auto& element : data.elements) + paintElement (g, data, *element, data.rootHasFill, data.rootHasStroke, data.rootFillColor.value_or (Colors::black)); + }); } //============================================================================== -void Drawable::paintElement (Graphics& g, const Element& element, bool hasParentFillEnabled, bool hasParentStrokeEnabled, Color currentColor, int recursionDepth) +void Drawable::paintElement (Graphics& g, const SVGData& data, const SVGElement& element, bool hasParentFillEnabled, bool hasParentStrokeEnabled, Color currentColor, int recursionDepth) { if (element.hidden) { @@ -317,70 +272,22 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent if (element.color) currentColor = *element.color; - YUP_DRAWABLE_LOG ("paintElement - tag: " << element.tagName - << " id: " << (element.id ? *element.id : "none") - << " depth: " << recursionDepth - << " hasPath: " << (element.path ? "true" : "false") - << " pathBounds: " << (element.path ? element.path->getBounds().toString() : "none") - << " hasTransform: " << (element.transform ? "true" : "false") - << " hasReference: " << (element.reference ? "true" : "false") - << " reference: " << (element.reference ? *element.reference : "none") - << " filter: " << element.filterUrl.value_or (String ("none")) - << " children: " << element.children.size() - << " parentFill: " << (hasParentFillEnabled ? "true" : "false") - << " parentStroke: " << (hasParentStrokeEnabled ? "true" : "false") - << " noFill: " << (element.noFill ? "true" : "false") - << " noStroke: " << (element.noStroke ? "true" : "false") - << " graphicsTransform: " << g.getTransform().toString() - << " opacity: " << g.getOpacity()); - - // Apply element transform if present - use proper composition for coordinate systems if (element.transform) - { - YUP_DRAWABLE_LOG ("Applying element transform - before: " << g.getTransform().toString() << " adding: " << element.transform->toString()); - // For proper coordinate system handling, we need to apply element transform - // in the element's local space, then transform to viewport space g.setTransform (element.transform->followedBy (g.getTransform())); - YUP_DRAWABLE_LOG ("After transform: " << g.getTransform().toString()); - } if (element.opacity) - { - YUP_DRAWABLE_LOG ("Applying opacity - tag: " << element.tagName - << " elementOpacity: " << *element.opacity - << " graphicsOpacityBefore: " << g.getOpacity()); g.setOpacity (g.getOpacity() * (*element.opacity)); - YUP_DRAWABLE_LOG ("After opacity - graphicsOpacity: " << g.getOpacity()); - } if (element.filterUrl) { - YUP_DRAWABLE_LOG ("Resolving filter - tag: " << element.tagName << " filter: " << *element.filterUrl); - - if (auto filter = resolveFilter (getFilterById (*element.filterUrl))) + if (auto filter = resolveFilter (data, getFilterById (data, *element.filterUrl))) { if (filter->gaussianBlurStdDeviation) { - // SVG feGaussianBlur stdDeviation is sigma. Rive feather follows design-tool - // blur sizing as a two-standard-deviation width, then expands that internally - // to cover the gaussian support. const auto svgStdDeviationToFeather = 2.0f; const auto feather = jmax (g.getFeather(), *filter->gaussianBlurStdDeviation * svgStdDeviationToFeather); - YUP_DRAWABLE_LOG ("Applying GaussianBlur filter as feather - id: " << *element.filterUrl - << " stdDeviation: " << *filter->gaussianBlurStdDeviation - << " featherScale: " << svgStdDeviationToFeather - << " previousFeather: " << g.getFeather() - << " appliedFeather: " << feather); g.setFeather (feather); } - else - { - YUP_DRAWABLE_LOG ("Filter resolved without supported primitives - id: " << *element.filterUrl); - } - } - else - { - YUP_DRAWABLE_LOG ("Filter not found - id: " << *element.filterUrl); } } @@ -388,44 +295,32 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent { Rectangle viewport (0.0f, 0.0f, element.viewportSize->getWidth(), element.viewportSize->getHeight()); auto viewBoxTransform = calculateTransformForTarget (*element.viewBox, viewport, element.preserveAspectRatioFitting, element.preserveAspectRatioJustification); - YUP_DRAWABLE_LOG ("Applying nested viewBox - tag: " << element.tagName - << " viewBox: " << element.viewBox->toString() - << " viewport: " << viewport.toString() - << " transform: " << viewBoxTransform.toString()); if (! viewBoxTransform.isIdentity()) g.addTransform (viewBoxTransform); } - // Apply clipping path if specified bool hasClipping = false; if (element.clipPathUrl) { - YUP_DRAWABLE_LOG ("Resolving clip path - tag: " << element.tagName << " clipPath: " << *element.clipPathUrl); - - if (auto clipPath = getClipPathById (*element.clipPathUrl)) + if (auto clipPath = getClipPathById (data, *element.clipPathUrl)) { std::optional> clipObjectBounds; - if (clipPath->units == ClipPath::ObjectBoundingBox) + if (clipPath->units == SVGClipPath::ObjectBoundingBox) { if (element.path) clipObjectBounds = element.path->getBounds(); else if (element.reference) { - if (auto refElement = elementsById[*element.reference]; refElement != nullptr && refElement->path) + if (auto refElement = data.elementsById[*element.reference]; refElement != nullptr && refElement->path) clipObjectBounds = refElement->path->getBounds(); } else if (element.imageBounds) { clipObjectBounds = *element.imageBounds; } - - YUP_DRAWABLE_LOG ("Clip path uses objectBoundingBox units - id: " << *element.clipPathUrl - << " hasObjectBounds: " << (clipObjectBounds ? "true" : "false") - << " objectBounds: " << (clipObjectBounds ? clipObjectBounds->toString() : String ("none"))); } - // Create a combined path from all clip path elements Path combinedClipPath; bool clipUsesNonZeroWinding = true; @@ -438,13 +333,10 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent AffineTransform clipTransform = clipElement->transform.value_or (AffineTransform::identity()); - if (clipPath->units == ClipPath::ObjectBoundingBox) + if (clipPath->units == SVGClipPath::ObjectBoundingBox) { if (! clipObjectBounds || clipObjectBounds->isEmpty()) - { - YUP_DRAWABLE_LOG ("Skipping objectBoundingBox clip path without bounds - id: " << *element.clipPathUrl); continue; - } auto unitsTransform = AffineTransform::translation (clipObjectBounds->getX(), clipObjectBounds->getY()) .scaled (clipObjectBounds->getWidth(), clipObjectBounds->getHeight()); @@ -462,8 +354,6 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent { combinedClipPath.setUsingNonZeroWinding (clipUsesNonZeroWinding); - // SVG clip paths are in the drawable's current user space. Graphics::setClipPath() - // expects drawing-area coordinates, so resolve the active SVG transform here. auto clipTransform = g.getTransform().translated (g.getDrawingArea().getTopLeft()); auto transformedClipPath = combinedClipPath.transformed (clipTransform); @@ -473,25 +363,11 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent g.setTransform (savedClipTransform); hasClipping = true; - YUP_DRAWABLE_LOG ("Applied clip path - id: " << *element.clipPathUrl - << " elements: " << clipPath->elements.size() - << " fillRule: " << (clipUsesNonZeroWinding ? "nonzero" : "evenodd") - << " bounds: " << combinedClipPath.getBounds().toString() - << " transformedBounds: " << transformedClipPath.getBounds().toString()); - } - else - { - YUP_DRAWABLE_LOG ("Clip path resolved but empty - id: " << *element.clipPathUrl - << " elements: " << clipPath->elements.size()); } } - else - { - YUP_DRAWABLE_LOG ("Clip path not found - id: " << *element.clipPathUrl); - } } - // Setup fill + // Fill setup if (element.fillColor) { Color fillColor = *element.fillColor; @@ -499,7 +375,6 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent fillColor = fillColor.withMultipliedAlpha (*element.fillOpacity); g.setFillColor (fillColor); isFillDefined = true; - YUP_DRAWABLE_LOG ("Fill color selected - tag: " << element.tagName << " color: " << fillColor.toString()); } else if (element.fillCurrentColor) { @@ -508,22 +383,19 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent fillColor = fillColor.withMultipliedAlpha (*element.fillOpacity); g.setFillColor (fillColor); isFillDefined = true; - YUP_DRAWABLE_LOG ("Fill currentColor selected - tag: " << element.tagName << " color: " << fillColor.toString()); } else if (element.fillUrl) { - YUP_DRAWABLE_LOG ("Looking for gradient with ID: " << *element.fillUrl); - if (auto gradient = getGradientById (*element.fillUrl)) + if (auto gradient = getGradientById (data, *element.fillUrl)) { - YUP_DRAWABLE_LOG ("Found gradient, resolving references..."); - auto resolvedGradient = resolveGradient (gradient); + auto resolvedGradient = resolveGradient (data, gradient); std::optional> gradientBounds; if (element.path) gradientBounds = element.path->getBounds(); else if (element.reference) { - if (auto refElement = elementsById[*element.reference]; refElement != nullptr && refElement->path) + if (auto refElement = data.elementsById[*element.reference]; refElement != nullptr && refElement->path) gradientBounds = refElement->path->getBounds(); } @@ -531,33 +403,17 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent gradientBounds ? std::addressof (*gradientBounds) : nullptr); g.setFillColorGradient (colorGradient); isFillDefined = true; - YUP_DRAWABLE_LOG ("Applied gradient to fill"); - } - else - { - YUP_DRAWABLE_LOG ("Gradient not found for ID: " << *element.fillUrl); } } else if (hasParentFillEnabled) { - // Inherit parent fill - don't change graphics state, just mark as defined isFillDefined = true; - YUP_DRAWABLE_LOG ("Fill inherited - tag: " << element.tagName); - } - else - { - YUP_DRAWABLE_LOG ("No fill selected - tag: " << element.tagName); } if (isFillDefined && ! element.noFill) { if (element.path) { - // SVG spec: fill is applied to both closed and unclosed paths - // For unclosed paths, an implicit line connects the last point to the first point - // TODO: Apply fill-rule when Graphics class supports it - // if (element.fillRule) - // g.setFillRule (*element.fillRule == "evenodd" ? FillRule::EvenOdd : FillRule::NonZero); YUP_DRAWABLE_LOG ("Filling path - tag: " << element.tagName << " id: " << (element.id ? *element.id : "none") << " bounds: " << element.path->getBounds().toString() @@ -566,7 +422,7 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent } else if (element.reference) { - if (auto refElement = elementsById[*element.reference]; refElement != nullptr && refElement->path) + if (auto refElement = data.elementsById[*element.reference]; refElement != nullptr && refElement->path) { const bool useDefinesFill = element.fillColor || element.fillCurrentColor || element.fillUrl || element.noFill; if (useDefinesFill || ! refElement->noFill) @@ -580,7 +436,6 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent Color fillColor = *refElement->fillColor; if (refElement->fillOpacity) fillColor = fillColor.withMultipliedAlpha (*refElement->fillOpacity); - g.setFillColor (fillColor); } else if (refElement->fillCurrentColor) @@ -588,67 +443,32 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent Color fillColor = currentColor; if (refElement->fillOpacity) fillColor = fillColor.withMultipliedAlpha (*refElement->fillOpacity); - g.setFillColor (fillColor); } } - YUP_DRAWABLE_LOG ("Rendering use element - reference: " << *element.reference); - YUP_DRAWABLE_LOG ("Use element transform: " << (element.transform ? element.transform->toString() : "none")); - YUP_DRAWABLE_LOG ("Referenced element local transform: " << (refElement->localTransform ? refElement->localTransform->toString() : "none")); - YUP_DRAWABLE_LOG ("Graphics transform during use fill: " << g.getTransform().toString()); - - // For elements, apply only the referenced element's own transform (not inherited parents) const auto savedTransform = g.getTransform(); if (refElement->localTransform) g.setTransform (refElement->localTransform->followedBy (savedTransform)); - // SVG spec: fill is applied to both closed and unclosed paths g.fillPath (*refElement->path); if (refElement->localTransform) g.setTransform (savedTransform); } - else - { - YUP_DRAWABLE_LOG ("Use fill skipped - reference disables fill: " << *element.reference); - } - } - else - { - YUP_DRAWABLE_LOG ("Use fill skipped - missing reference/path: " << *element.reference); } } else if (element.text && element.textPosition) { - YUP_DRAWABLE_LOG ("Rendering text - tag: " << element.tagName - << " textLength: " << element.text->length() - << " position: " << element.textPosition->toString()); renderTextElement (g, element); } else if ((element.imageHref || element.image) && element.imageBounds) { - YUP_DRAWABLE_LOG ("Rendering image - tag: " << element.tagName - << " href: " << (element.imageHref ? *element.imageHref : "embedded") - << " bounds: " << element.imageBounds->toString() - << " alreadyLoaded: " << (element.image ? "true" : "false")); renderImageElement (g, element); } - else - { - YUP_DRAWABLE_LOG ("Fill branch had nothing to render - tag: " << element.tagName - << " hasText: " << (element.text ? "true" : "false") - << " hasImage: " << ((element.imageHref || element.image) ? "true" : "false")); - } - } - else - { - YUP_DRAWABLE_LOG ("Fill skipped - tag: " << element.tagName - << " isFillDefined: " << (isFillDefined ? "true" : "false") - << " noFill: " << (element.noFill ? "true" : "false")); } - // Setup stroke + // Stroke setup if (element.strokeColor) { Color strokeColor = *element.strokeColor; @@ -656,7 +476,6 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent strokeColor = strokeColor.withMultipliedAlpha (*element.strokeOpacity); g.setStrokeColor (strokeColor); isStrokeDefined = true; - YUP_DRAWABLE_LOG ("Stroke color selected - tag: " << element.tagName << " color: " << strokeColor.toString()); } else if (element.strokeCurrentColor) { @@ -665,21 +484,19 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent strokeColor = strokeColor.withMultipliedAlpha (*element.strokeOpacity); g.setStrokeColor (strokeColor); isStrokeDefined = true; - YUP_DRAWABLE_LOG ("Stroke currentColor selected - tag: " << element.tagName << " color: " << strokeColor.toString()); } else if (element.strokeUrl) { - YUP_DRAWABLE_LOG ("Looking for stroke gradient with ID: " << *element.strokeUrl); - if (auto gradient = getGradientById (*element.strokeUrl)) + if (auto gradient = getGradientById (data, *element.strokeUrl)) { - auto resolvedGradient = resolveGradient (gradient); + auto resolvedGradient = resolveGradient (data, gradient); std::optional> gradientBounds; if (element.path) gradientBounds = element.path->getBounds(); else if (element.reference) { - if (auto refElement = elementsById[*element.reference]; refElement != nullptr && refElement->path) + if (auto refElement = data.elementsById[*element.reference]; refElement != nullptr && refElement->path) gradientBounds = refElement->path->getBounds(); } @@ -687,64 +504,24 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent gradientBounds ? std::addressof (*gradientBounds) : nullptr); g.setStrokeColorGradient (colorGradient); isStrokeDefined = true; - YUP_DRAWABLE_LOG ("Applied gradient to stroke"); - } - else - { - YUP_DRAWABLE_LOG ("Stroke gradient not found for ID: " << *element.strokeUrl); } } else if (hasParentStrokeEnabled) { - // Inherit parent stroke - don't change graphics state, just mark as defined isStrokeDefined = true; - YUP_DRAWABLE_LOG ("Stroke inherited - tag: " << element.tagName); - } - else - { - YUP_DRAWABLE_LOG ("No stroke selected - tag: " << element.tagName); } if (element.strokeJoin) - { - YUP_DRAWABLE_LOG ("Applying stroke join - tag: " << element.tagName); g.setStrokeJoin (*element.strokeJoin); - } - if (element.strokeCap) - { - YUP_DRAWABLE_LOG ("Applying stroke cap - tag: " << element.tagName); g.setStrokeCap (*element.strokeCap); - } - if (element.strokeWidth) - { - YUP_DRAWABLE_LOG ("Applying stroke width - tag: " << element.tagName << " width: " << *element.strokeWidth); g.setStrokeWidth (*element.strokeWidth); - } - - // Apply stroke dash patterns - if (element.strokeDashArray) - { - // Convert Array to what Graphics expects - const auto& dashArray = *element.strokeDashArray; - if (! dashArray.isEmpty()) - { - // TODO: Graphics class needs stroke dash pattern support - // For now, this is prepared for when Graphics supports it - // g.setStrokeDashPattern (dashArray.getRawDataPointer(), dashArray.size()); - // if (element.strokeDashOffset) - // g.setStrokeDashOffset (*element.strokeDashOffset); - YUP_DRAWABLE_LOG ("Stroke dash array parsed - tag: " << element.tagName - << " count: " << dashArray.size() - << " offset: " << element.strokeDashOffset.value_or (0.0f)); - } - } bool referenceDefinesStroke = false; if (! isStrokeDefined && element.reference) { - if (auto refElement = elementsById[*element.reference]; refElement != nullptr) + if (auto refElement = data.elementsById[*element.reference]; refElement != nullptr) referenceDefinesStroke = (refElement->strokeColor || refElement->strokeCurrentColor || refElement->strokeUrl) && ! refElement->noStroke; } @@ -757,22 +534,18 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent { dashedPath = createDashedPath (*pathToStroke, *element.strokeDashArray, element.strokeDashOffset.value_or (0.0f)); pathToStroke = std::addressof (*dashedPath); - YUP_DRAWABLE_LOG ("Created dashed stroke path - tag: " << element.tagName - << " sourceBounds: " << element.path->getBounds().toString() - << " dashedBounds: " << dashedPath->getBounds().toString()); } if (pathToStroke != nullptr) { YUP_DRAWABLE_LOG ("Stroking path - tag: " << element.tagName << " id: " << (element.id ? *element.id : "none") - << " bounds: " << pathToStroke->getBounds().toString() - << " referenceDefinesStroke: " << (referenceDefinesStroke ? "true" : "false")); + << " bounds: " << pathToStroke->getBounds().toString()); g.strokePath (*pathToStroke); } else if (element.reference) { - if (auto refElement = elementsById[*element.reference]; refElement != nullptr && refElement->path) + if (auto refElement = data.elementsById[*element.reference]; refElement != nullptr && refElement->path) { const bool useDefinesStroke = element.strokeColor || element.strokeCurrentColor || element.strokeUrl || element.noStroke; const auto savedReferenceState = g.saveState(); @@ -784,7 +557,6 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent Color strokeColor = *refElement->strokeColor; if (refElement->strokeOpacity) strokeColor = strokeColor.withMultipliedAlpha (*refElement->strokeOpacity); - g.setStrokeColor (strokeColor); } else if (refElement->strokeCurrentColor) @@ -792,24 +564,17 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent Color strokeColor = currentColor; if (refElement->strokeOpacity) strokeColor = strokeColor.withMultipliedAlpha (*refElement->strokeOpacity); - g.setStrokeColor (strokeColor); } if (refElement->strokeWidth) g.setStrokeWidth (*refElement->strokeWidth); - if (refElement->strokeJoin) g.setStrokeJoin (*refElement->strokeJoin); - if (refElement->strokeCap) g.setStrokeCap (*refElement->strokeCap); } - YUP_DRAWABLE_LOG ("Stroking use element - reference: " << *element.reference); - YUP_DRAWABLE_LOG ("Graphics transform during stroke: " << g.getTransform().toString()); - - // For elements, apply only the referenced element's own transform (not inherited parents) const auto savedTransform = g.getTransform(); if (refElement->localTransform) g.setTransform (refElement->localTransform->followedBy (savedTransform)); @@ -819,27 +584,12 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent if (refElement->localTransform) g.setTransform (savedTransform); } - else - { - YUP_DRAWABLE_LOG ("Use stroke skipped - missing reference/path: " << *element.reference); - } } - else - { - YUP_DRAWABLE_LOG ("Stroke branch had no path/reference - tag: " << element.tagName); - } - } - else - { - YUP_DRAWABLE_LOG ("Stroke skipped - tag: " << element.tagName - << " isStrokeDefined: " << (isStrokeDefined ? "true" : "false") - << " referenceDefinesStroke: " << (referenceDefinesStroke ? "true" : "false") - << " noStroke: " << (element.noStroke ? "true" : "false")); } if (element.reference) { - if (auto refElement = elementsById[*element.reference]; refElement != nullptr && ! refElement->children.empty()) + if (auto refElement = data.elementsById[*element.reference]; refElement != nullptr && ! refElement->children.empty()) { const auto savedTransform = g.getTransform(); if (refElement->localTransform) @@ -850,2458 +600,91 @@ void Drawable::paintElement (Graphics& g, const Element& element, bool hasParent auto viewportSizeToUse = element.viewportSize.value_or (refElement->viewportSize.value_or (Size (refElement->viewBox->getWidth(), refElement->viewBox->getHeight()))); Rectangle viewport (0.0f, 0.0f, viewportSizeToUse.getWidth(), viewportSizeToUse.getHeight()); auto viewBoxTransform = calculateTransformForTarget (*refElement->viewBox, viewport, refElement->preserveAspectRatioFitting, refElement->preserveAspectRatioJustification); - YUP_DRAWABLE_LOG ("Applying referenced viewBox - reference: " << *element.reference - << " viewBox: " << refElement->viewBox->toString() - << " viewport: " << viewport.toString() - << " transform: " << viewBoxTransform.toString()); if (! viewBoxTransform.isIdentity()) g.addTransform (viewBoxTransform); } - YUP_DRAWABLE_LOG ("Rendering referenced children - reference: " << *element.reference - << " childCount: " << refElement->children.size() - << " transform: " << g.getTransform().toString()); - for (const auto& childElement : refElement->children) - paintElement (g, *childElement, isFillDefined && ! element.noFill, isStrokeDefined && ! element.noStroke, currentColor, recursionDepth + 1); + paintElement (g, data, *childElement, isFillDefined && ! element.noFill, isStrokeDefined && ! element.noStroke, currentColor, recursionDepth + 1); g.setTransform (savedTransform); } - else - { - YUP_DRAWABLE_LOG ("No referenced children rendered - reference: " << *element.reference); - } } for (const auto& childElement : element.children) - { - YUP_DRAWABLE_LOG ("Rendering child element - current graphics transform: " << g.getTransform().toString()); - // Pass fill/stroke state to children, but respect explicit "none" values - // If this element has fill="none", children should not inherit fill - paintElement (g, *childElement, isFillDefined && ! element.noFill, isStrokeDefined && ! element.noStroke, currentColor, recursionDepth + 1); - } + paintElement (g, data, *childElement, isFillDefined && ! element.noFill, isStrokeDefined && ! element.noStroke, currentColor, recursionDepth + 1); // paintDebugElement (g, element); } //============================================================================== -bool Drawable::parseElement (const XmlElement& element, bool parentIsRoot, AffineTransform currentTransform, Element* parent) +void Drawable::paintDebugElement (Graphics& g, const SVGElement& element) { - Element::Ptr e = new Element; - bool isRootElement = element.hasTagName ("svg"); - e->tagName = element.getTagNameWithoutNamespace(); - - YUP_DRAWABLE_LOG ("parseElement - tag: " << e->tagName - << " id: " << element.getStringAttribute ("id", "none") - << " parent: " << (parent != nullptr ? parent->tagName : "none") - << " parentIsRoot: " << (parentIsRoot ? "true" : "false") - << " currentTransform: " << currentTransform.toString()); - - if (auto classes = element.getStringAttribute ("class"); classes.isNotEmpty()) - e->classNames = StringArray::fromTokens (classes, " \t\r\n", ""); + if (! element.path) + return; - if (auto id = element.getStringAttribute ("id"); id.isNotEmpty()) + for (const auto& segment : *element.path) { - e->id = id; - elementsById.set (id, e); - } - - const float inheritedFontSize = parent != nullptr && parent->fontSize ? *parent->fontSize : 12.0f; - const float viewportWidth = viewBox.getWidth() > 0.0f ? viewBox.getWidth() : (size.getWidth() > 0.0f ? size.getWidth() : 100.0f); - const float viewportHeight = viewBox.getHeight() > 0.0f ? viewBox.getHeight() : (size.getHeight() > 0.0f ? size.getHeight() : 100.0f); - const float viewportDiagonal = std::sqrt ((viewportWidth * viewportWidth + viewportHeight * viewportHeight) * 0.5f); + auto color = Color::opaqueRandom(); - if (element.hasTagName ("path")) - { - auto path = Path(); + g.setFillColor (color); + g.fillRect (segment.point.getX() - 4, segment.point.getY() - 4, 8, 8); - String pathData = element.getStringAttribute ("d"); - auto trimmedPathData = pathData.trimStart(); - if (trimmedPathData.isNotEmpty() && ! String ("MmZzLlHhVvCcSsQqTtAa").containsChar (trimmedPathData[0])) - { - YUP_DRAWABLE_LOG ("parseElement failed - invalid path command start: " << trimmedPathData[0] - << " id: " << element.getStringAttribute ("id", "none")); - return false; - } + g.setStrokeColor (Colors::white); + g.setStrokeWidth (2.0f); + g.strokeRect (segment.point.getX() - 4, segment.point.getY() - 4, 8, 8); - if (! pathData.isEmpty() && ! path.fromString (pathData)) + if (segment.verb == Path::Verb::CubicTo) { - YUP_DRAWABLE_LOG ("parseElement failed - path.fromString failed" - << " id: " << element.getStringAttribute ("id", "none") - << " pathLength: " << pathData.length()); - return false; - } - - e->path = std::move (path); - YUP_DRAWABLE_LOG ("Parsed path - id: " << element.getStringAttribute ("id", "none") - << " pathLength: " << pathData.length() - << " bounds: " << e->path->getBounds().toString() - << " empty: " << (e->path->isEmpty() ? "true" : "false")); - - currentTransform = parseTransform (element, currentTransform, *e); - parseStyle (element, currentTransform, *e); + g.setFillColor (color.brighter (0.05f)); + g.fillRect (segment.controlPoint1.getX() - 4, segment.controlPoint1.getY() - 4, 8, 8); - // Apply fill-rule after parsing style - if (e->fillRule && *e->fillRule == "evenodd") - e->path->setUsingNonZeroWinding (false); - } - else if (element.hasTagName ("g")) - { - currentTransform = parseTransform (element, currentTransform, *e); - parseStyle (element, currentTransform, *e); - } - else if (element.hasTagName ("use")) - { - String href = element.getStringAttribute ("href"); - if (href.isEmpty()) - href = element.getStringAttribute ("xlink:href"); - - if (href.isNotEmpty() && href.startsWith ("#")) - e->reference = href.substring (1); - - YUP_DRAWABLE_LOG ("Parsed use attributes - id: " << element.getStringAttribute ("id", "none") - << " href: " << href - << " reference: " << (e->reference ? *e->reference : "none")); - - // Handle x,y positioning for use elements (SVG spec requirement) - auto x = parseLengthAttribute (element, "x", 0.0f, inheritedFontSize, viewportWidth); - auto y = parseLengthAttribute (element, "y", 0.0f, inheritedFontSize, viewportHeight); - auto width = parseLengthAttribute (element, "width", 0.0f, inheritedFontSize, viewportWidth); - auto height = parseLengthAttribute (element, "height", 0.0f, inheritedFontSize, viewportHeight); - if (width > 0.0f && height > 0.0f) - e->viewportSize = Size (width, height); - - YUP_DRAWABLE_LOG ("Parsed use geometry - reference: " << (e->reference ? *e->reference : "none") - << " x: " << x - << " y: " << y - << " width: " << width - << " height: " << height - << " hasViewportSize: " << (e->viewportSize ? "true" : "false")); - - AffineTransform useTransform; - if (x != 0.0f || y != 0.0f) - useTransform = AffineTransform::translation (x, y); - - currentTransform = parseTransform (element, currentTransform, *e); - - // Combine use element positioning with any explicit transform - if (! useTransform.isIdentity()) - { - if (e->transform.has_value()) - e->transform = useTransform.followedBy (*e->transform); - else - e->transform = useTransform; + g.setFillColor (color.brighter (0.1f)); + g.fillRect (segment.controlPoint2.getX() - 4, segment.controlPoint2.getY() - 4, 8, 8); } - - parseStyle (element, currentTransform, *e); - } - else if (element.hasTagName ("ellipse")) - { - auto cx = parseLengthAttribute (element, "cx", 0.0f, inheritedFontSize, viewportWidth); - auto cy = parseLengthAttribute (element, "cy", 0.0f, inheritedFontSize, viewportHeight); - auto rx = parseLengthAttribute (element, "rx", 0.0f, inheritedFontSize, viewportDiagonal); - auto ry = parseLengthAttribute (element, "ry", 0.0f, inheritedFontSize, viewportDiagonal); - - auto path = Path(); - path.addCenteredEllipse (cx, cy, rx, ry); - e->path = std::move (path); - - YUP_DRAWABLE_LOG ("Parsed ellipse - id: " << element.getStringAttribute ("id", "none") - << " cx: " << cx - << " cy: " << cy - << " rx: " << rx - << " ry: " << ry - << " bounds: " << e->path->getBounds().toString()); - - currentTransform = parseTransform (element, currentTransform, *e); - parseStyle (element, currentTransform, *e); } - else if (element.hasTagName ("circle")) - { - auto cx = parseLengthAttribute (element, "cx", 0.0f, inheritedFontSize, viewportWidth); - auto cy = parseLengthAttribute (element, "cy", 0.0f, inheritedFontSize, viewportHeight); - auto r = parseLengthAttribute (element, "r", 0.0f, inheritedFontSize, viewportDiagonal); - - auto path = Path(); - path.addCenteredEllipse (cx, cy, r, r); - e->path = std::move (path); - - YUP_DRAWABLE_LOG ("Parsed circle - id: " << element.getStringAttribute ("id", "none") - << " cx: " << cx - << " cy: " << cy - << " r: " << r - << " bounds: " << e->path->getBounds().toString()); - - currentTransform = parseTransform (element, currentTransform, *e); - parseStyle (element, currentTransform, *e); - } - else if (element.hasTagName ("rect")) - { - auto x = parseLengthAttribute (element, "x", 0.0f, inheritedFontSize, viewportWidth); - auto y = parseLengthAttribute (element, "y", 0.0f, inheritedFontSize, viewportHeight); - auto width = parseLengthAttribute (element, "width", 0.0f, inheritedFontSize, viewportWidth); - auto height = parseLengthAttribute (element, "height", 0.0f, inheritedFontSize, viewportHeight); - auto rx = parseLengthAttribute (element, "rx", 0.0f, inheritedFontSize, viewportWidth); - auto ry = parseLengthAttribute (element, "ry", 0.0f, inheritedFontSize, viewportHeight); - - auto path = Path(); - if (rx > 0.0f || ry > 0.0f) - { - if (rx == 0.0f) - rx = ry; - if (ry == 0.0f) - ry = rx; +} - path.addRoundedRectangle (x, y, width, height, rx, ry, rx, ry); - } - else - { - path.addRectangle (x, y, width, height); - } +//============================================================================== - e->path = std::move (path); +Font Drawable::resolveFont (const SVGElement& element) const +{ + if (document == nullptr) + return Font().withHeight (element.fontSize.value_or (12.0f)); - YUP_DRAWABLE_LOG ("Parsed rect - id: " << element.getStringAttribute ("id", "none") - << " x: " << x - << " y: " << y - << " width: " << width - << " height: " << height - << " rx: " << rx - << " ry: " << ry - << " bounds: " << e->path->getBounds().toString()); + const auto& options = document->getParseOptions(); + const auto fontSize = element.fontSize.value_or (12.0f); - currentTransform = parseTransform (element, currentTransform, *e); - parseStyle (element, currentTransform, *e); - } - else if (element.hasTagName ("line")) + if (options.fontResolver) { - auto x1 = parseLengthAttribute (element, "x1", 0.0f, inheritedFontSize, viewportWidth); - auto y1 = parseLengthAttribute (element, "y1", 0.0f, inheritedFontSize, viewportHeight); - auto x2 = parseLengthAttribute (element, "x2", 0.0f, inheritedFontSize, viewportWidth); - auto y2 = parseLengthAttribute (element, "y2", 0.0f, inheritedFontSize, viewportHeight); - - auto path = Path(); - path.startNewSubPath (x1, y1); - path.lineTo (x2, y2); - e->path = std::move (path); - - YUP_DRAWABLE_LOG ("Parsed line - id: " << element.getStringAttribute ("id", "none") - << " x1: " << x1 - << " y1: " << y1 - << " x2: " << x2 - << " y2: " << y2 - << " bounds: " << e->path->getBounds().toString()); - - currentTransform = parseTransform (element, currentTransform, *e); - parseStyle (element, currentTransform, *e); + if (auto resolved = options.fontResolver (element.fontFamily.value_or (String()), fontSize)) + return resolved->withHeight (fontSize); } - else if (element.hasTagName ("polygon")) - { - String points = element.getStringAttribute ("points"); - if (points.isNotEmpty()) - { - auto path = Path(); - auto coords = StringArray::fromTokens (points, " ,", ""); - - if (coords.size() >= 4 && coords.size() % 2 == 0) - { - path.startNewSubPath (coords[0].getFloatValue(), coords[1].getFloatValue()); - for (int i = 2; i < coords.size(); i += 2) - path.lineTo (coords[i].getFloatValue(), coords[i + 1].getFloatValue()); + return Font().withHeight (fontSize); +} - path.closeSubPath(); - } +//============================================================================== - e->path = std::move (path); - YUP_DRAWABLE_LOG ("Parsed polygon - id: " << element.getStringAttribute ("id", "none") - << " coordinateCount: " << coords.size() - << " bounds: " << e->path->getBounds().toString() - << " empty: " << (e->path->isEmpty() ? "true" : "false")); - } - else - { - YUP_DRAWABLE_LOG ("Parsed polygon with empty points - id: " << element.getStringAttribute ("id", "none")); - } +void Drawable::renderTextElement (Graphics& g, const SVGElement& element) +{ + if (! element.text || ! element.textPosition || element.text->isEmpty()) + return; - currentTransform = parseTransform (element, currentTransform, *e); - parseStyle (element, currentTransform, *e); - } - else if (element.hasTagName ("polyline")) - { - String points = element.getStringAttribute ("points"); - if (points.isNotEmpty()) - { - auto path = Path(); - auto coords = StringArray::fromTokens (points, " ,", ""); + auto position = *element.textPosition; - if (coords.size() >= 4 && coords.size() % 2 == 0) - { - path.startNewSubPath (coords[0].getFloatValue(), coords[1].getFloatValue()); + if (element.textX && ! element.textX->isEmpty()) + position.setX (element.textX->getFirst()); + if (element.textY && ! element.textY->isEmpty()) + position.setY (element.textY->getFirst()); + if (element.textDx && ! element.textDx->isEmpty()) + position.setX (position.getX() + element.textDx->getFirst()); + if (element.textDy && ! element.textDy->isEmpty()) + position.setY (position.getY() + element.textDy->getFirst()); - for (int i = 2; i < coords.size(); i += 2) - path.lineTo (coords[i].getFloatValue(), coords[i + 1].getFloatValue()); - } - - e->path = std::move (path); - YUP_DRAWABLE_LOG ("Parsed polyline - id: " << element.getStringAttribute ("id", "none") - << " coordinateCount: " << coords.size() - << " bounds: " << e->path->getBounds().toString() - << " empty: " << (e->path->isEmpty() ? "true" : "false")); - } - else - { - YUP_DRAWABLE_LOG ("Parsed polyline with empty points - id: " << element.getStringAttribute ("id", "none")); - } - - currentTransform = parseTransform (element, currentTransform, *e); - parseStyle (element, currentTransform, *e); - } - else if (element.hasTagName ("text") || element.hasTagName ("tspan")) - { - const auto defaultTextPosition = parent != nullptr && parent->textPosition ? *parent->textPosition : Point (0.0f, 0.0f); - YUP_DRAWABLE_LOG ("Parsing text font inputs - tag: " << e->tagName - << " id: " << element.getStringAttribute ("id", "none") - << " inheritedFontSize: " << inheritedFontSize - << " parentFontFamily: " << (parent != nullptr && parent->fontFamily ? *parent->fontFamily : "none") - << " parentFontSize: " << (parent != nullptr && parent->fontSize ? String (*parent->fontSize) : String ("none")) - << " rawFontFamily: " << element.getStringAttribute ("font-family") - << " rawFontSize: " << element.getStringAttribute ("font-size") - << " rawFontWeight: " << element.getStringAttribute ("font-weight") - << " rawFontStyle: " << element.getStringAttribute ("font-style") - << " rawFontStretch: " << element.getStringAttribute ("font-stretch") - << " rawTextAnchor: " << element.getStringAttribute ("text-anchor") - << " rawDominantBaseline: " << element.getStringAttribute ("dominant-baseline") - << " rawAlignmentBaseline: " << element.getStringAttribute ("alignment-baseline") - << " rawBaselineShift: " << element.getStringAttribute ("baseline-shift")); - - float x = parseLengthAttribute (element, "x", defaultTextPosition.getX(), inheritedFontSize, viewportWidth); - float y = parseLengthAttribute (element, "y", defaultTextPosition.getY(), inheritedFontSize, viewportHeight); - e->textPosition = Point (x, y); - - String directText; - for (auto* child : element.getChildIterator()) - { - if (child->isTextElement()) - directText += child->getText(); - } - - auto text = directText.isNotEmpty() ? directText : (element.hasTagName ("tspan") ? element.getAllSubText() : String()); - auto normalizedText = text.replaceCharacters ("\t\r\n", " ").trim(); - while (normalizedText.contains (" ")) - normalizedText = normalizedText.replace (" ", " "); - - e->text = normalizedText; - - YUP_DRAWABLE_LOG ("Parsed text - tag: " << e->tagName - << " id: " << element.getStringAttribute ("id", "none") - << " rawTextLength: " << text.length() - << " rawText: " << text - << " textLength: " << (e->text ? e->text->length() : 0) - << " text: " << (e->text ? *e->text : String()) - << " position: " << e->textPosition->toString()); - - if (auto xList = element.getStringAttribute ("x"); xList.isNotEmpty()) - { - e->textX = parseLengthList (xList, inheritedFontSize, viewportWidth); - YUP_DRAWABLE_LOG ("Parsed text x list - tag: " << e->tagName << " count: " << e->textX->size() << " first: " << (e->textX->isEmpty() ? 0.0f : e->textX->getFirst())); - } - if (auto yList = element.getStringAttribute ("y"); yList.isNotEmpty()) - { - e->textY = parseLengthList (yList, inheritedFontSize, viewportHeight); - YUP_DRAWABLE_LOG ("Parsed text y list - tag: " << e->tagName << " count: " << e->textY->size() << " first: " << (e->textY->isEmpty() ? 0.0f : e->textY->getFirst())); - } - if (auto dxList = element.getStringAttribute ("dx"); dxList.isNotEmpty()) - { - e->textDx = parseLengthList (dxList, inheritedFontSize, viewportWidth); - YUP_DRAWABLE_LOG ("Parsed text dx list - tag: " << e->tagName << " count: " << e->textDx->size() << " first: " << (e->textDx->isEmpty() ? 0.0f : e->textDx->getFirst())); - } - if (auto dyList = element.getStringAttribute ("dy"); dyList.isNotEmpty()) - { - e->textDy = parseLengthList (dyList, inheritedFontSize, viewportHeight); - YUP_DRAWABLE_LOG ("Parsed text dy list - tag: " << e->tagName << " count: " << e->textDy->size() << " first: " << (e->textDy->isEmpty() ? 0.0f : e->textDy->getFirst())); - } - - String fontFamily = element.getStringAttribute ("font-family"); - if (fontFamily.isNotEmpty()) - { - e->fontFamily = fontFamily; - YUP_DRAWABLE_LOG ("Parsed text font-family attribute: " << fontFamily); - } - - float fontSize = parseLengthAttribute (element, "font-size", 0.0f, inheritedFontSize, inheritedFontSize); - if (fontSize > 0.0f) - { - e->fontSize = fontSize; - YUP_DRAWABLE_LOG ("Parsed text font-size attribute: " << fontSize); - } - else if (element.getStringAttribute ("font-size").isNotEmpty()) - { - YUP_DRAWABLE_LOG ("Text font-size attribute ignored - parsed non-positive value: " << fontSize); - } - - String textAnchor = element.getStringAttribute ("text-anchor"); - if (textAnchor.isNotEmpty()) - { - e->textAnchor = textAnchor; - YUP_DRAWABLE_LOG ("Parsed text-anchor attribute: " << textAnchor); - } - - currentTransform = parseTransform (element, currentTransform, *e); - parseStyle (element, currentTransform, *e); - - YUP_DRAWABLE_LOG ("Parsed text font result - tag: " << e->tagName - << " id: " << element.getStringAttribute ("id", "none") - << " fontFamily: " << e->fontFamily.value_or (String ("none")) - << " fontSize: " << e->fontSize.value_or (0.0f) - << " textAnchor: " << e->textAnchor.value_or (String ("none")) - << " letterSpacing: " << e->letterSpacing.value_or (0.0f) - << " wordSpacing: " << e->wordSpacing.value_or (0.0f) - << " unsupportedFontWeight: " << element.getStringAttribute ("font-weight") - << " unsupportedFontStyle: " << element.getStringAttribute ("font-style")); - } - else if (element.hasTagName ("image")) - { - auto x = parseLengthAttribute (element, "x", 0.0f, inheritedFontSize, viewportWidth); - auto y = parseLengthAttribute (element, "y", 0.0f, inheritedFontSize, viewportHeight); - auto width = parseLengthAttribute (element, "width", 0.0f, inheritedFontSize, viewportWidth); - auto height = parseLengthAttribute (element, "height", 0.0f, inheritedFontSize, viewportHeight); - - e->imageBounds = Rectangle (x, y, width, height); - - String href = element.getStringAttribute ("href"); - if (href.isEmpty()) - href = element.getStringAttribute ("xlink:href"); - - if (href.isNotEmpty()) - { - e->imageHref = href; - e->image = loadImageFromHref (href); - } - - YUP_DRAWABLE_LOG ("Parsed image - id: " << element.getStringAttribute ("id", "none") - << " href: " << href - << " bounds: " << e->imageBounds->toString() - << " loaded: " << (e->image ? "true" : "false")); - - currentTransform = parseTransform (element, currentTransform, *e); - parseStyle (element, currentTransform, *e); - } - else if (element.hasTagName ("svg") || element.hasTagName ("symbol")) - { - e->isSymbol = element.hasTagName ("symbol"); - if (e->isSymbol) - e->hidden = true; - - YUP_DRAWABLE_LOG ("Parsed container start - tag: " << e->tagName - << " id: " << element.getStringAttribute ("id", "none") - << " hidden: " << (e->hidden ? "true" : "false") - << " viewBoxAttr: " << element.getStringAttribute ("viewBox")); - - if (auto view = element.getStringAttribute ("viewBox"); view.isNotEmpty()) - { - auto coords = StringArray::fromTokens (view, " ,", ""); - if (coords.size() == 4) - e->viewBox = Rectangle (coords[0].getFloatValue(), coords[1].getFloatValue(), coords[2].getFloatValue(), coords[3].getFloatValue()); - else - YUP_DRAWABLE_LOG ("Invalid nested viewBox - tag: " << e->tagName << " value: " << view << " coordinateCount: " << coords.size()); - } - - auto width = parseLengthAttribute (element, "width", e->viewBox ? e->viewBox->getWidth() : viewportWidth, inheritedFontSize, viewportWidth); - auto height = parseLengthAttribute (element, "height", e->viewBox ? e->viewBox->getHeight() : viewportHeight, inheritedFontSize, viewportHeight); - if (width > 0.0f && height > 0.0f) - e->viewportSize = Size (width, height); - - if (auto preserveAspectRatio = element.getStringAttribute ("preserveAspectRatio"); preserveAspectRatio.isNotEmpty()) - { - e->preserveAspectRatioFitting = parsePreserveAspectRatio (preserveAspectRatio); - e->preserveAspectRatioJustification = parseAspectRatioAlignment (preserveAspectRatio); - } - - currentTransform = parseTransform (element, currentTransform, *e); - parseStyle (element, currentTransform, *e); - - YUP_DRAWABLE_LOG ("Parsed container - tag: " << e->tagName - << " id: " << element.getStringAttribute ("id", "none") - << " viewBox: " << (e->viewBox ? e->viewBox->toString() : "none") - << " viewportSize: " << (e->viewportSize ? String (e->viewportSize->getWidth()) + "x" + String (e->viewportSize->getHeight()) : String ("none")) - << " hidden: " << (e->hidden ? "true" : "false")); - } - else if (element.hasTagName ("defs")) - { - e->hidden = true; - - YUP_DRAWABLE_LOG ("Parsing defs - id: " << element.getStringAttribute ("id", "none")); - - // Parse definitions like gradients and clip paths - for (auto* child = element.getFirstChildElement(); child != nullptr; child = child->getNextElement()) - { - if (child->hasTagName ("linearGradient") || child->hasTagName ("radialGradient")) - parseGradient (*child); - else if (child->hasTagName ("filter")) - parseFilter (*child); - else if (child->hasTagName ("clipPath")) - parseClipPath (*child); - else if (child->hasTagName ("style")) - parseStyleElement (*child); - } - } - else if (element.hasTagName ("style")) - { - YUP_DRAWABLE_LOG ("Parsing style element"); - parseStyleElement (element); - return true; - } - else - { - YUP_DRAWABLE_LOG ("Unsupported SVG element parsed as container only - tag: " << e->tagName - << " id: " << element.getStringAttribute ("id", "none")); - } - - if (parent != nullptr) - { - if (! e->fontFamily && parent->fontFamily) - e->fontFamily = parent->fontFamily; - if (! e->fontSize && parent->fontSize) - e->fontSize = parent->fontSize; - if (! e->textAnchor && parent->textAnchor) - e->textAnchor = parent->textAnchor; - if (! e->letterSpacing && parent->letterSpacing) - e->letterSpacing = parent->letterSpacing; - if (! e->wordSpacing && parent->wordSpacing) - e->wordSpacing = parent->wordSpacing; - if (! e->color && parent->color) - e->color = parent->color; - - YUP_DRAWABLE_LOG ("Inherited text/color state - tag: " << e->tagName - << " id: " << (e->id ? *e->id : "none") - << " fontFamily: " << (e->fontFamily ? *e->fontFamily : "none") - << " fontSize: " << e->fontSize.value_or (0.0f) - << " textAnchor: " << (e->textAnchor ? *e->textAnchor : "none") - << " hasColor: " << (e->color ? "true" : "false")); - } - - for (auto* child = element.getFirstChildElement(); child != nullptr; child = child->getNextElement()) - { - if (child->isTextElement()) - { - YUP_DRAWABLE_LOG ("Skipping XML text child - parent: " << e->tagName); - continue; - } - - // Parse gradients and clip paths regardless of whether they're in or not - if (child->hasTagName ("linearGradient") || child->hasTagName ("radialGradient")) - parseGradient (*child); - else if (child->hasTagName ("filter")) - parseFilter (*child); - else if (child->hasTagName ("clipPath")) - parseClipPath (*child); - else if (child->hasTagName ("style")) - parseStyleElement (*child); - else - { - const auto childResult = parseElement (*child, isRootElement, currentTransform, e.get()); - YUP_DRAWABLE_LOG ("Child parse result - parent: " << e->tagName - << " child: " << child->getTagNameWithoutNamespace() - << " result: " << (childResult ? "true" : "false")); - } - } - - if (e->tagName == "text" || e->tagName == "tspan") - { - auto cursor = e->textPosition.value_or (Point (0.0f, 0.0f)); - - for (auto& childElement : e->children) - { - if (childElement->tagName != "tspan") - continue; - - auto position = cursor; - - if (childElement->textX && ! childElement->textX->isEmpty()) - position.setX (childElement->textX->getFirst()); - - if (childElement->textY && ! childElement->textY->isEmpty()) - position.setY (childElement->textY->getFirst()); - - if (childElement->textDx && ! childElement->textDx->isEmpty()) - position.setX (position.getX() + childElement->textDx->getFirst()); - - if (childElement->textDy && ! childElement->textDy->isEmpty()) - position.setY (position.getY() + childElement->textDy->getFirst()); - - childElement->textPosition = position; - childElement->textX.reset(); - childElement->textY.reset(); - childElement->textDx.reset(); - childElement->textDy.reset(); - - cursor = position; - YUP_DRAWABLE_LOG ("Resolved tspan position - text: " << childElement->text.value_or (String()) - << " position: " << childElement->textPosition->toString()); - } - } - - if (isRootElement) - { - // Store root SVG element's default fill/stroke for inheritance by top-level elements - if (e->fillColor) - { - rootFillColor = e->fillColor; - rootHasFill = true; - } - else if (e->noFill) - { - rootHasFill = false; - } - - if (e->strokeColor) - { - rootStrokeColor = e->strokeColor; - rootHasStroke = true; - } - else if (e->noStroke) - { - rootHasStroke = false; - } - - YUP_DRAWABLE_LOG ("Root element parsed - rootHasFill: " << (rootHasFill ? "true" : "false") - << " rootHasStroke: " << (rootHasStroke ? "true" : "false") - << " rootFillColor: " << (rootFillColor ? rootFillColor->toString() : "none") - << " rootStrokeColor: " << (rootStrokeColor ? rootStrokeColor->toString() : "none")); - - return true; - } - - if (parent != nullptr && ! parentIsRoot) - { - YUP_DRAWABLE_LOG ("Adding element to parent - tag: " << e->tagName - << " id: " << (e->id ? *e->id : "none") - << " parent: " << parent->tagName - << " parentChildCountBefore: " << parent->children.size()); - parent->children.push_back (std::move (e)); - } - else - { - YUP_DRAWABLE_LOG ("Adding top-level element - tag: " << e->tagName - << " id: " << (e->id ? *e->id : "none") - << " topLevelCountBefore: " << elements.size()); - elements.push_back (std::move (e)); - } - - return true; -} - -//============================================================================== - -void Drawable::parseStyle (const XmlElement& element, const AffineTransform& currentTransform, Element& e) -{ - String styleAttr = element.getStringAttribute ("style"); - - YUP_DRAWABLE_LOG ("parseStyle - tag: " << e.tagName - << " id: " << e.id.value_or (String ("none")) - << " fillAttr: " << element.getStringAttribute ("fill") - << " strokeAttr: " << element.getStringAttribute ("stroke") - << " style: " << styleAttr - << " currentTransform: " << currentTransform.toString()); - - // Parse presentation attributes first. Author CSS and inline style are applied after - // this block so they can override presentation attributes. - String fill = element.getStringAttribute ("fill"); - if (fill.isNotEmpty()) - { - if (fill != "none") - { - String gradientUrl = extractGradientUrl (fill); - if (gradientUrl.isNotEmpty()) - { - e.fillUrl = gradientUrl; - YUP_DRAWABLE_LOG ("Parsed fill gradient URL: " << gradientUrl); - } - else if (fill == "currentColor") - { - e.fillCurrentColor = true; - YUP_DRAWABLE_LOG ("Parsed fill currentColor"); - } - else - { - e.fillColor = Color::fromString (fill); - YUP_DRAWABLE_LOG ("Parsed fill color: " << fill << " -> " << e.fillColor->toString()); - } - } - else - { - e.noFill = true; - YUP_DRAWABLE_LOG ("Parsed fill none"); - } - } - - String stroke = element.getStringAttribute ("stroke"); - if (stroke.isNotEmpty()) - { - if (stroke != "none") - { - String gradientUrl = extractGradientUrl (stroke); - if (gradientUrl.isNotEmpty()) - { - e.strokeUrl = gradientUrl; - YUP_DRAWABLE_LOG ("Parsed stroke gradient URL: " << gradientUrl); - } - else if (stroke == "currentColor") - { - e.strokeCurrentColor = true; - YUP_DRAWABLE_LOG ("Parsed stroke currentColor"); - } - else - { - e.strokeColor = Color::fromString (stroke); - YUP_DRAWABLE_LOG ("Parsed stroke color: " << stroke << " -> " << e.strokeColor->toString()); - } - } - else - { - e.noStroke = true; - YUP_DRAWABLE_LOG ("Parsed stroke none"); - } - } - - String strokeJoin = element.getStringAttribute ("stroke-linejoin"); - if (strokeJoin == "round") - e.strokeJoin = StrokeJoin::Round; - else if (strokeJoin == "miter") - e.strokeJoin = StrokeJoin::Miter; - else if (strokeJoin == "bevel") - e.strokeJoin = StrokeJoin::Bevel; - - String strokeCap = element.getStringAttribute ("stroke-linecap"); - if (strokeCap == "round") - e.strokeCap = StrokeCap::Round; - else if (strokeCap == "square") - e.strokeCap = StrokeCap::Square; - else if (strokeCap == "butt") - e.strokeCap = StrokeCap::Butt; - - float strokeWidth = element.getFloatAttribute ("stroke-width", -1.0f); - if (strokeWidth > 0.0f) - { - e.strokeWidth = strokeWidth; - YUP_DRAWABLE_LOG ("Parsed stroke-width: " << strokeWidth); - } - - float opacity = element.getFloatAttribute ("opacity", -1.0f); - if (opacity >= 0.0f && opacity <= 1.0f) - { - e.opacity = opacity; - YUP_DRAWABLE_LOG ("Parsed opacity: " << opacity); - } - - String clipPath = element.getStringAttribute ("clip-path"); - if (clipPath.isNotEmpty()) - { - String clipPathUrl = extractGradientUrl (clipPath); - if (clipPathUrl.isNotEmpty()) - { - e.clipPathUrl = clipPathUrl; - YUP_DRAWABLE_LOG ("Parsed clip-path URL: " << clipPathUrl); - } - } - - String filter = element.getStringAttribute ("filter"); - if (filter.isNotEmpty()) - { - if (filter == "none") - { - e.filterUrl.reset(); - YUP_DRAWABLE_LOG ("Parsed filter none"); - } - else if (auto filterUrl = extractUrlId (filter); filterUrl.isNotEmpty()) - { - e.filterUrl = filterUrl; - YUP_DRAWABLE_LOG ("Parsed filter URL: " << filterUrl); - } - else - { - YUP_DRAWABLE_LOG ("Unsupported filter value ignored: " << filter); - } - } - - // Parse stroke-dasharray - String dashArray = element.getStringAttribute ("stroke-dasharray"); - if (dashArray.isNotEmpty() && dashArray != "none") - { - auto dashValues = StringArray::fromTokens (dashArray, " ,", ""); - if (! dashValues.isEmpty()) - { - Array dashes; - for (const auto& dash : dashValues) - { - float value = parseUnit (dash); - if (value >= 0.0f) - dashes.add (value); - } - - if (! dashes.isEmpty()) - { - e.strokeDashArray = dashes; - YUP_DRAWABLE_LOG ("Parsed stroke-dasharray count: " << dashes.size()); - } - } - } - - // Parse stroke-dashoffset - String dashOffset = element.getStringAttribute ("stroke-dashoffset"); - if (dashOffset.isNotEmpty()) - { - e.strokeDashOffset = parseUnit (dashOffset); - YUP_DRAWABLE_LOG ("Parsed stroke-dashoffset: " << *e.strokeDashOffset << " from: " << dashOffset); - } - - // Parse fill-opacity - float fillOpacity = element.getFloatAttribute ("fill-opacity", -1.0f); - if (fillOpacity >= 0.0f && fillOpacity <= 1.0f) - { - e.fillOpacity = fillOpacity; - YUP_DRAWABLE_LOG ("Parsed fill-opacity: " << fillOpacity); - } - - // Parse stroke-opacity - float strokeOpacity = element.getFloatAttribute ("stroke-opacity", -1.0f); - if (strokeOpacity >= 0.0f && strokeOpacity <= 1.0f) - { - e.strokeOpacity = strokeOpacity; - YUP_DRAWABLE_LOG ("Parsed stroke-opacity: " << strokeOpacity); - } - - // Parse fill-rule - String fillRule = element.getStringAttribute ("fill-rule"); - if (fillRule == "evenodd" || fillRule == "nonzero") - { - e.fillRule = fillRule; - YUP_DRAWABLE_LOG ("Parsed fill-rule: " << fillRule); - } - - String clipRule = element.getStringAttribute ("clip-rule"); - if (clipRule == "evenodd" || clipRule == "nonzero") - { - e.clipRule = clipRule; - YUP_DRAWABLE_LOG ("Parsed clip-rule: " << clipRule); - } - - String color = element.getStringAttribute ("color"); - if (color.isNotEmpty() && color != "currentColor") - { - e.color = Color::fromString (color); - YUP_DRAWABLE_LOG ("Parsed color: " << color << " -> " << e.color->toString()); - } - - String display = element.getStringAttribute ("display"); - String visibility = element.getStringAttribute ("visibility"); - if (display == "none" || visibility == "hidden" || visibility == "collapse") - { - e.hidden = true; - YUP_DRAWABLE_LOG ("Parsed hidden state - display: " << display << " visibility: " << visibility); - } - - String fontFamily = element.getStringAttribute ("font-family"); - if (fontFamily.isNotEmpty()) - { - e.fontFamily = fontFamily; - YUP_DRAWABLE_LOG ("Parsed font-family: " << fontFamily); - } - - String fontSize = element.getStringAttribute ("font-size"); - if (fontSize.isNotEmpty()) - { - e.fontSize = parseUnit (fontSize, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); - YUP_DRAWABLE_LOG ("Parsed font-size: " << *e.fontSize << " from: " << fontSize); - } - - String letterSpacing = element.getStringAttribute ("letter-spacing"); - if (letterSpacing.isNotEmpty() && letterSpacing != "normal") - { - e.letterSpacing = parseUnit (letterSpacing, 0.0f, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); - YUP_DRAWABLE_LOG ("Parsed letter-spacing: " << *e.letterSpacing << " from: " << letterSpacing); - } - - String wordSpacing = element.getStringAttribute ("word-spacing"); - if (wordSpacing.isNotEmpty() && wordSpacing != "normal") - { - e.wordSpacing = parseUnit (wordSpacing, 0.0f, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); - YUP_DRAWABLE_LOG ("Parsed word-spacing: " << *e.wordSpacing << " from: " << wordSpacing); - } - - if (auto fontWeight = element.getStringAttribute ("font-weight"); fontWeight.isNotEmpty()) - YUP_DRAWABLE_LOG ("Font attribute currently not applied - font-weight: " << fontWeight); - - if (auto fontStyle = element.getStringAttribute ("font-style"); fontStyle.isNotEmpty()) - YUP_DRAWABLE_LOG ("Font attribute currently not applied - font-style: " << fontStyle); - - if (auto fontVariant = element.getStringAttribute ("font-variant"); fontVariant.isNotEmpty()) - YUP_DRAWABLE_LOG ("Font attribute currently not applied - font-variant: " << fontVariant); - - if (auto fontStretch = element.getStringAttribute ("font-stretch"); fontStretch.isNotEmpty()) - YUP_DRAWABLE_LOG ("Font attribute currently not applied - font-stretch: " << fontStretch); - - if (auto fontShorthand = element.getStringAttribute ("font"); fontShorthand.isNotEmpty()) - YUP_DRAWABLE_LOG ("Font shorthand currently not parsed - font: " << fontShorthand); - - if (auto dominantBaseline = element.getStringAttribute ("dominant-baseline"); dominantBaseline.isNotEmpty()) - YUP_DRAWABLE_LOG ("Text baseline attribute currently not applied - dominant-baseline: " << dominantBaseline); - - if (auto alignmentBaseline = element.getStringAttribute ("alignment-baseline"); alignmentBaseline.isNotEmpty()) - YUP_DRAWABLE_LOG ("Text baseline attribute currently not applied - alignment-baseline: " << alignmentBaseline); - - if (auto baselineShift = element.getStringAttribute ("baseline-shift"); baselineShift.isNotEmpty()) - YUP_DRAWABLE_LOG ("Text baseline attribute currently not applied - baseline-shift: " << baselineShift); - - applyStylesheetRules (element, e); - - if (styleAttr.isNotEmpty()) - parseCSSStyle (styleAttr, e); - - YUP_DRAWABLE_LOG ("parseStyle result - tag: " << e.tagName - << " id: " << e.id.value_or (String ("none")) - << " hasFillColor: " << (e.fillColor ? "true" : "false") - << " fillUrl: " << e.fillUrl.value_or (String ("none")) - << " fillCurrentColor: " << (e.fillCurrentColor ? "true" : "false") - << " noFill: " << (e.noFill ? "true" : "false") - << " hasStrokeColor: " << (e.strokeColor ? "true" : "false") - << " strokeUrl: " << e.strokeUrl.value_or (String ("none")) - << " strokeCurrentColor: " << (e.strokeCurrentColor ? "true" : "false") - << " noStroke: " << (e.noStroke ? "true" : "false") - << " filterUrl: " << e.filterUrl.value_or (String ("none")) - << " hidden: " << (e.hidden ? "true" : "false")); -} - -//============================================================================== - -AffineTransform Drawable::parseTransform (const XmlElement& element, const AffineTransform& currentTransform, Element& e) -{ - AffineTransform result; - - if (auto transformString = element.getStringAttribute ("transform"); transformString.isNotEmpty()) - { - YUP_DRAWABLE_LOG ("parseTransform(element) - tag: " << e.tagName - << " id: " << e.id.value_or (String ("none")) - << " raw: " << transformString - << " incoming: " << currentTransform.toString()); - - result = parseTransform (transformString); - - if (auto transformOrigin = element.getStringAttribute ("transform-origin"); transformOrigin.isNotEmpty()) - { - auto origin = parseLengthList (transformOrigin, 12.0f, 100.0f); - if (origin.size() >= 2) - { - result = AffineTransform::translation (-origin[0], -origin[1]) - .followedBy (result) - .followedBy (AffineTransform::translation (origin[0], origin[1])); - } - } - - e.transform = result; - e.localTransform = result; // Store the local transform separately for use by elements - - YUP_DRAWABLE_LOG ("Parsed element transform: " << result.toString()); - } - else - { - YUP_DRAWABLE_LOG ("parseTransform(element) - no transform tag: " << e.tagName - << " id: " << e.id.value_or (String ("none"))); - } - - return currentTransform.followedBy (result); -} - -//============================================================================== - -AffineTransform Drawable::parseTransform (const String& transformString) -{ - if (transformString.isEmpty()) - return AffineTransform::identity(); - - YUP_DRAWABLE_LOG ("parseTransform(string) - raw: " << transformString); - - AffineTransform result; - auto data = transformString.getCharPointer(); - - while (! data.isEmpty()) - { - // Skip whitespace - while (data.isWhitespace()) - ++data; - - if (data.isEmpty()) - break; - - // Parse transform type - String type; - while (! data.isEmpty() && CharacterFunctions::isLetter (*data)) - { - type += *data; - ++data; - } - - // Skip whitespace and the opening parenthesis - while (data.isWhitespace() || *data == '(') - ++data; - - // Parse parameters - Array params; - while (! data.isEmpty() && *data != ')') - { - if (*data == ',' || data.isWhitespace()) - { - ++data; - continue; - } - - String number; - while (! data.isEmpty() && (*data == '-' || *data == '+' || *data == '.' || *data == 'e' || *data == 'E' || (*data >= '0' && *data <= '9'))) - { - number += *data; - ++data; - } - - if (! number.isEmpty()) - params.add (number.getFloatValue()); - - // Skip whitespace or commas - while (data.isWhitespace() || *data == ',') - ++data; - } - - // Skip the closing parenthesis - if (*data == ')') - ++data; - - // Apply the parsed transform - // SVG transforms are post-multiplied (applied left-to-right), so we use prependedBy - // to append each new transform on the right: result = result * newTransform - if (type == "translate" && (params.size() == 1 || params.size() == 2)) - { - const auto tx = params[0]; - const auto ty = (params.size() == 2) ? params[1] : 0.0f; - result = result.prependedBy (AffineTransform::translation (tx, ty)); - YUP_DRAWABLE_LOG ("Applied translate transform - tx: " << tx << " ty: " << ty << " result: " << result.toString()); - } - else if (type == "scale" && (params.size() == 1 || params.size() == 2)) - { - const auto sx = params[0]; - const auto sy = (params.size() == 2) ? params[1] : params[0]; - result = result.prependedBy (AffineTransform::scaling (sx, sy)); - YUP_DRAWABLE_LOG ("Applied scale transform - sx: " << sx << " sy: " << sy << " result: " << result.toString()); - } - else if (type == "rotate" && (params.size() == 1 || params.size() == 3)) - { - if (params.size() == 1) - result = result.prependedBy (AffineTransform::rotation (degreesToRadians (params[0]))); - else - result = result.prependedBy (AffineTransform::rotation (degreesToRadians (params[0]), params[1], params[2])); - - YUP_DRAWABLE_LOG ("Applied rotate transform - degrees: " << params[0] - << " params: " << params.size() - << " result: " << result.toString()); - } - else if (type == "skewX" && params.size() == 1) - { - result = result.prependedBy (AffineTransform::shearing (tanf (degreesToRadians (params[0])), 0.0f)); - YUP_DRAWABLE_LOG ("Applied skewX transform - degrees: " << params[0] << " result: " << result.toString()); - } - else if (type == "skewY" && params.size() == 1) - { - result = result.prependedBy (AffineTransform::shearing (0.0f, tanf (degreesToRadians (params[0])))); - YUP_DRAWABLE_LOG ("Applied skewY transform - degrees: " << params[0] << " result: " << result.toString()); - } - else if (type == "matrix" && params.size() == 6) - { - result = result.prependedBy (AffineTransform ( - params[0], params[2], params[4], params[1], params[3], params[5])); - YUP_DRAWABLE_LOG ("Applied matrix transform - result: " << result.toString()); - } - else - { - YUP_DRAWABLE_LOG ("Ignored transform operation - type: " << type << " paramCount: " << params.size()); - } - } - - YUP_DRAWABLE_LOG ("parseTransform(string) result: " << result.toString()); - return result; -} - -//============================================================================== - -void Drawable::paintDebugElement (Graphics& g, const Element& element) -{ - if (! element.path) - return; - - for (const auto& segment : *element.path) - { - auto color = Color::opaqueRandom(); - - g.setFillColor (color); - g.fillRect (segment.point.getX() - 4, segment.point.getY() - 4, 8, 8); - - g.setStrokeColor (Colors::white); - g.setStrokeWidth (2.0f); - g.strokeRect (segment.point.getX() - 4, segment.point.getY() - 4, 8, 8); - - if (segment.verb == Path::Verb::CubicTo) - { - g.setFillColor (color.brighter (0.05f)); - g.fillRect (segment.controlPoint1.getX() - 4, segment.controlPoint1.getY() - 4, 8, 8); - - g.setFillColor (color.brighter (0.1f)); - g.fillRect (segment.controlPoint2.getX() - 4, segment.controlPoint2.getY() - 4, 8, 8); - } - } -} - -//============================================================================== - -void Drawable::parseGradient (const XmlElement& element) -{ - String id = element.getStringAttribute ("id"); - if (id.isEmpty()) - return; - - YUP_DRAWABLE_LOG ("Parsing gradient with ID: " << id); - - Gradient::Ptr gradient = new Gradient; - gradient->id = id; - gradient->start = { 0.0f, 0.0f }; - gradient->end = { 1.0f, 0.0f }; - gradient->center = { 0.5f, 0.5f }; - gradient->radius = 0.5f; - gradient->focal = gradient->center; - - auto parseCoordinate = [&element] (const String& name, float defaultValue, bool& hasValue) -> float - { - const auto value = element.getStringAttribute (name); - if (value.isEmpty()) - return defaultValue; - - hasValue = true; - - if (value.containsChar ('%')) - return value.upToFirstOccurrenceOf ("%", false, false).getFloatValue() / 100.0f; - - return value.getFloatValue(); - }; - - // Parse xlink:href reference - String href = element.getStringAttribute ("xlink:href"); - if (href.isNotEmpty() && href.startsWith ("#")) - { - gradient->href = href.substring (1); // Remove the # prefix - YUP_DRAWABLE_LOG ("Gradient references: " << gradient->href); - } - - if (element.hasTagName ("linearGradient")) - { - gradient->type = Gradient::Linear; - bool hasX1 = false, hasY1 = false, hasX2 = false, hasY2 = false; - gradient->start = { parseCoordinate ("x1", gradient->start.getX(), hasX1), parseCoordinate ("y1", gradient->start.getY(), hasY1) }; - gradient->end = { parseCoordinate ("x2", gradient->end.getX(), hasX2), parseCoordinate ("y2", gradient->end.getY(), hasY2) }; - gradient->hasStart = hasX1 || hasY1; - gradient->hasEnd = hasX2 || hasY2; - - YUP_DRAWABLE_LOG ("Linear gradient - start: (" << gradient->start.getX() << ", " << gradient->start.getY() << ") end: (" << gradient->end.getX() << ", " << gradient->end.getY() << ")"); - } - else if (element.hasTagName ("radialGradient")) - { - gradient->type = Gradient::Radial; - bool hasCx = false, hasCy = false, hasR = false; - bool hasFx = false, hasFy = false; - gradient->center = { parseCoordinate ("cx", gradient->center.getX(), hasCx), parseCoordinate ("cy", gradient->center.getY(), hasCy) }; - gradient->radius = parseCoordinate ("r", gradient->radius, hasR); - - auto fx = parseCoordinate ("fx", gradient->center.getX(), hasFx); - auto fy = parseCoordinate ("fy", gradient->center.getY(), hasFy); - gradient->focal = { fx, fy }; - gradient->hasCenter = hasCx || hasCy; - gradient->hasRadius = hasR; - gradient->hasFocal = hasFx || hasFy; - - YUP_DRAWABLE_LOG ("Radial gradient - center: (" << gradient->center.getX() << ", " << gradient->center.getY() << ") radius: " << gradient->radius); - } - - // Parse gradientUnits attribute - String gradientUnits = element.getStringAttribute ("gradientUnits"); - if (gradientUnits.isNotEmpty()) - { - gradient->hasUnits = true; - - if (gradientUnits == "userSpaceOnUse") - { - gradient->units = Gradient::UserSpaceOnUse; - YUP_DRAWABLE_LOG ("Gradient units: userSpaceOnUse"); - } - else - { - gradient->units = Gradient::ObjectBoundingBox; - YUP_DRAWABLE_LOG ("Gradient units: objectBoundingBox"); - } - } - else - { - gradient->units = Gradient::ObjectBoundingBox; - YUP_DRAWABLE_LOG ("Gradient units: objectBoundingBox (default)"); - } - - // Parse gradientTransform attribute - String gradientTransform = element.getStringAttribute ("gradientTransform"); - if (gradientTransform.isNotEmpty()) - { - YUP_DRAWABLE_LOG ("Parsing gradientTransform: " << gradientTransform); - gradient->transform = parseTransform (gradientTransform); - YUP_DRAWABLE_LOG ("Gradient transform: " << gradient->transform.toString()); - } - - String spreadMethod = element.getStringAttribute ("spreadMethod"); - if (spreadMethod == "pad" || spreadMethod == "reflect" || spreadMethod == "repeat") - { - gradient->spreadMethod = spreadMethod; - gradient->hasSpreadMethod = true; - YUP_DRAWABLE_LOG ("Gradient spreadMethod: " << spreadMethod); - } - - // Parse gradient stops - for (auto* child = element.getFirstChildElement(); child != nullptr; child = child->getNextElement()) - { - if (child->hasTagName ("stop")) - { - GradientStop stop; - const auto offsetString = child->getStringAttribute ("offset"); - if (offsetString.containsChar ('%')) - stop.offset = offsetString.upToFirstOccurrenceOf ("%", false, false).getFloatValue() * 0.01f; - else - stop.offset = child->getFloatAttribute ("offset"); - - stop.offset = jlimit (0.0f, 1.0f, stop.offset); - - // First try to get stop-color from attributes - String stopColor = child->getStringAttribute ("stop-color"); - float stopOpacity = child->getFloatAttribute ("stop-opacity", 1.0f); - - // If not found in attributes, parse from CSS style - if (stopColor.isEmpty()) - { - String styleAttr = child->getStringAttribute ("style"); - if (styleAttr.isNotEmpty()) - { - YUP_DRAWABLE_LOG ("Parsing CSS style for gradient stop: " << styleAttr); - - // Parse CSS-style stop-color - auto declarations = StringArray::fromTokens (styleAttr, ";", ""); - for (const auto& declaration : declarations) - { - auto colonPos = declaration.indexOf (":"); - if (colonPos > 0) - { - String property = declaration.substring (0, colonPos).trim(); - String value = declaration.substring (colonPos + 1).trim(); - - if (property == "stop-color") - { - stopColor = value; - YUP_DRAWABLE_LOG ("Found stop-color in CSS: " << stopColor); - } - else if (property == "stop-opacity") - { - stopOpacity = value.getFloatValue(); - YUP_DRAWABLE_LOG ("Found stop-opacity in CSS: " << stopOpacity); - } - } - } - } - } - - if (stopColor.isNotEmpty()) - { - YUP_DRAWABLE_LOG ("Parsing color string: '" << stopColor << "' (length: " << stopColor.length() << ")"); - stop.color = Color::fromString (stopColor); - YUP_DRAWABLE_LOG ("Gradient stop - offset: " << stop.offset << " color: " << stopColor << " parsed: " << stop.color.toString()); - } - - stop.opacity = stopOpacity; - - gradient->stops.push_back (stop); - } - } - - if (! gradient->stops.empty()) - { - std::sort (gradient->stops.begin(), gradient->stops.end(), [] (const GradientStop& a, const GradientStop& b) - { - return a.offset < b.offset; - }); - - // Ensure implicit first/last stops per SVG spec - if (gradient->stops.front().offset > 0.0f) - { - auto first = gradient->stops.front(); - first.offset = 0.0f; - gradient->stops.insert (gradient->stops.begin(), first); - } - - if (gradient->stops.back().offset < 1.0f) - { - auto last = gradient->stops.back(); - last.offset = 1.0f; - gradient->stops.push_back (last); - } - } - - YUP_DRAWABLE_LOG ("Gradient parsed with " << gradient->stops.size() << " stops"); - - gradients.push_back (gradient); - gradientsById.set (id, gradient); -} - -//============================================================================== - -Drawable::Gradient::Ptr Drawable::getGradientById (const String& id) -{ - return gradientsById[id]; -} - -//============================================================================== - -Drawable::Gradient::Ptr Drawable::resolveGradient (Gradient::Ptr gradient) -{ - if (gradient == nullptr || gradient->href.isEmpty()) - return gradient; - - auto referencedGradient = getGradientById (gradient->href); - if (referencedGradient == nullptr) - { - YUP_DRAWABLE_LOG ("Referenced gradient not found: " << gradient->href); - return gradient; - } - - // Recursively resolve the referenced gradient first - referencedGradient = resolveGradient (referencedGradient); - - // Create a new gradient that inherits from the referenced gradient - Gradient::Ptr resolvedGradient = new Gradient; - - // Copy properties from referenced gradient - resolvedGradient->type = gradient->type; - resolvedGradient->id = gradient->id; // Keep the original ID - resolvedGradient->units = referencedGradient->units; - resolvedGradient->spreadMethod = referencedGradient->spreadMethod; - resolvedGradient->start = referencedGradient->start; - resolvedGradient->end = referencedGradient->end; - resolvedGradient->center = referencedGradient->center; - resolvedGradient->radius = referencedGradient->radius; - resolvedGradient->focal = referencedGradient->focal; - resolvedGradient->transform = referencedGradient->transform; - resolvedGradient->stops = referencedGradient->stops; - resolvedGradient->hasUnits = referencedGradient->hasUnits; - resolvedGradient->hasSpreadMethod = referencedGradient->hasSpreadMethod; - - // Override with properties from the current gradient (if specified) - if (gradient->hasStart) - resolvedGradient->start = gradient->start; - if (gradient->hasEnd) - resolvedGradient->end = gradient->end; - if (gradient->hasCenter) - resolvedGradient->center = gradient->center; - if (gradient->hasRadius) - resolvedGradient->radius = gradient->radius; - if (gradient->hasFocal) - resolvedGradient->focal = gradient->focal; - - if (! gradient->transform.isIdentity()) - resolvedGradient->transform = gradient->transform; - if (gradient->hasUnits) - { - resolvedGradient->units = gradient->units; - resolvedGradient->hasUnits = true; - } - if (gradient->hasSpreadMethod) - { - resolvedGradient->spreadMethod = gradient->spreadMethod; - resolvedGradient->hasSpreadMethod = true; - } - if (! gradient->stops.empty()) // Use local stops if defined - resolvedGradient->stops = gradient->stops; - - YUP_DRAWABLE_LOG ("Resolved gradient " << gradient->id << " from reference " << gradient->href); - return resolvedGradient; -} - -//============================================================================== - -ColorGradient Drawable::createColorGradientFromSVG (const Gradient& gradient, const Rectangle* objectBounds) -{ - YUP_DRAWABLE_LOG ("Creating ColorGradient from SVG gradient ID: " << gradient.id << " type: " << (gradient.type == Gradient::Linear ? "Linear" : "Radial") << " units: " << (gradient.units == Gradient::UserSpaceOnUse ? "userSpaceOnUse" : "objectBoundingBox")); - - if (gradient.stops.empty()) - { - YUP_DRAWABLE_LOG ("No stops in gradient, returning empty"); - return ColorGradient(); - } - - // Ensure we always have a valid bounds transform for objectBoundingBox gradients - const bool hasBounds = objectBounds != nullptr && objectBounds->getWidth() > 0.0f && objectBounds->getHeight() > 0.0f; - AffineTransform unitsTransform = AffineTransform::identity(); - - if (gradient.units == Gradient::ObjectBoundingBox && hasBounds) - { - // Normalize gradient space to the element bounds (0..1 -> bounds) - unitsTransform = AffineTransform::translation (objectBounds->getX(), objectBounds->getY()) - .scaled (objectBounds->getWidth(), objectBounds->getHeight()); - } - - // Per SVG spec, gradientUnits are applied first, then gradientTransform - const AffineTransform gradientSpaceTransform = unitsTransform.followedBy (gradient.transform); - - // Helper to apply transforms to a point - auto transformPoint = [&gradientSpaceTransform] (Point p) - { - float x = p.getX(); - float y = p.getY(); - if (! gradientSpaceTransform.isIdentity()) - gradientSpaceTransform.transformPoint (x, y); - return Point (x, y); - }; - - // Prepare start/end/center after transforms (in local space; Graphics will apply viewport transforms) - const Point start = transformPoint (gradient.start); - const Point end = transformPoint (gradient.end); - const Point center = transformPoint (gradient.center); - - // Compute radial radius in transformed space - auto computeRadius = [&]() -> float - { - if (gradient.radius <= 0.0f) - return 0.0f; - - const Point edgePoints[] = { - transformPoint (Point (gradient.center.getX() + gradient.radius, gradient.center.getY())), - transformPoint (Point (gradient.center.getX() - gradient.radius, gradient.center.getY())), - transformPoint (Point (gradient.center.getX(), gradient.center.getY() + gradient.radius)), - transformPoint (Point (gradient.center.getX(), gradient.center.getY() - gradient.radius)) - }; - - float maxRadius = 0.0f; - for (const auto& edgePoint : edgePoints) - maxRadius = jmax (maxRadius, Line (center, edgePoint).length()); - - return maxRadius; - }; - - const auto radius = gradient.type == Gradient::Radial ? computeRadius() : 0.0f; - - // Build color stops in gradient space - std::vector colorStops; - colorStops.reserve (gradient.stops.size()); - - if (gradient.type == Gradient::Linear - && objectBounds != nullptr - && (gradient.spreadMethod == "reflect" || gradient.spreadMethod == "repeat")) - { - const auto dx = end.getX() - start.getX(); - const auto dy = end.getY() - start.getY(); - const auto lengthSquared = dx * dx + dy * dy; - - if (lengthSquared > 0.0f) - { - const Point corners[] = { - objectBounds->getTopLeft(), - objectBounds->getTopRight(), - objectBounds->getBottomLeft(), - objectBounds->getBottomRight() - }; - - float minT = 0.0f; - float maxT = 1.0f; - - for (const auto& corner : corners) - { - const auto t = ((corner.getX() - start.getX()) * dx + (corner.getY() - start.getY()) * dy) / lengthSquared; - minT = jmin (minT, t); - maxT = jmax (maxT, t); - } - - int firstRepeat = static_cast (std::floor (minT)); - int lastRepeat = static_cast (std::ceil (maxT)); - - constexpr int maxGradientRepeats = 32; - if (lastRepeat - firstRepeat > maxGradientRepeats) - { - const auto centerRepeat = (firstRepeat + lastRepeat) / 2; - firstRepeat = centerRepeat - (maxGradientRepeats / 2); - lastRepeat = firstRepeat + maxGradientRepeats; - } - - const auto repeatStart = static_cast (firstRepeat); - const auto repeatEnd = static_cast (jmax (lastRepeat, firstRepeat + 1)); - const auto repeatRange = repeatEnd - repeatStart; - const auto expandedStart = Point (start.getX() + dx * repeatStart, start.getY() + dy * repeatStart); - const auto expandedEnd = Point (start.getX() + dx * repeatEnd, start.getY() + dy * repeatEnd); - - colorStops.reserve (gradient.stops.size() * static_cast (repeatEnd - repeatStart)); - - for (int repeat = firstRepeat; repeat < lastRepeat; ++repeat) - { - const bool reflected = gradient.spreadMethod == "reflect" && (std::abs (repeat) % 2) == 1; - - for (const auto& stop : gradient.stops) - { - const auto repeatedOffset = static_cast (repeat) + (reflected ? (1.0f - stop.offset) : stop.offset); - const auto normalizedOffset = (repeatedOffset - repeatStart) / repeatRange; - const auto position = Point (expandedStart.getX() + (expandedEnd.getX() - expandedStart.getX()) * normalizedOffset, - expandedStart.getY() + (expandedEnd.getY() - expandedStart.getY()) * normalizedOffset); - - colorStops.emplace_back (stop.color.withMultipliedAlpha (stop.opacity), position, jlimit (0.0f, 1.0f, normalizedOffset)); - } - } - - std::sort (colorStops.begin(), colorStops.end(), [] (const auto& a, const auto& b) - { - return a.delta < b.delta; - }); - - YUP_DRAWABLE_LOG ("Expanded linear gradient spread - id: " << gradient.id - << " spreadMethod: " << gradient.spreadMethod - << " repeatStart: " << repeatStart - << " repeatEnd: " << repeatEnd - << " stopCount: " << colorStops.size()); - } - } - - if (colorStops.empty()) - { - for (const auto& stop : gradient.stops) - { - Color color = stop.color.withMultipliedAlpha (stop.opacity); - - if (gradient.type == Gradient::Linear) - { - const auto interpolated = Point (start.getX() + stop.offset * (end.getX() - start.getX()), - start.getY() + stop.offset * (end.getY() - start.getY())); - - colorStops.emplace_back (color, interpolated, stop.offset); - } - else - { - // Radial gradient: position lies along the radius vector to preserve radius computation - const auto radialPoint = Point (center.getX() + radius * stop.offset, center.getY()); - colorStops.emplace_back (color, radialPoint, stop.offset); - } - } - } - - ColorGradient::Type type = (gradient.type == Gradient::Linear) ? ColorGradient::Linear : ColorGradient::Radial; - ColorGradient result (type, colorStops); - - YUP_DRAWABLE_LOG ("Created ColorGradient with " << colorStops.size() << " stops"); - return result; -} - -//============================================================================== - -void Drawable::parseFilter (const XmlElement& element) -{ - String id = element.getStringAttribute ("id"); - if (id.isEmpty()) - { - YUP_DRAWABLE_LOG ("parseFilter skipped - missing id"); - return; - } - - Filter::Ptr filter = new Filter; - filter->id = id; - - if (auto href = element.getStringAttribute ("xlink:href"); href.isNotEmpty() && href.startsWith ("#")) - { - filter->href = href.substring (1); - YUP_DRAWABLE_LOG ("Filter references: " << filter->href); - } - - YUP_DRAWABLE_LOG ("parseFilter - id: " << id); - - for (auto* child = element.getFirstChildElement(); child != nullptr; child = child->getNextElement()) - { - if (! child->hasTagName ("feGaussianBlur")) - { - YUP_DRAWABLE_LOG ("Unsupported filter primitive ignored - filter: " << id << " tag: " << child->getTagNameWithoutNamespace()); - continue; - } - - const auto stdDeviationString = child->getStringAttribute ("stdDeviation"); - if (stdDeviationString.isEmpty()) - { - YUP_DRAWABLE_LOG ("GaussianBlur primitive missing stdDeviation - filter: " << id); - continue; - } - - const auto values = StringArray::fromTokens (stdDeviationString, " ,", ""); - float stdDeviation = 0.0f; - - if (values.size() >= 2) - { - const auto stdDeviationX = parseUnit (values[0]); - const auto stdDeviationY = parseUnit (values[1]); - stdDeviation = jmax (stdDeviationX, stdDeviationY); - YUP_DRAWABLE_LOG ("Parsed anisotropic GaussianBlur - filter: " << id - << " stdDeviationX: " << stdDeviationX - << " stdDeviationY: " << stdDeviationY - << " featherApproximation: " << stdDeviation); - } - else if (values.size() == 1) - { - stdDeviation = parseUnit (values[0]); - YUP_DRAWABLE_LOG ("Parsed GaussianBlur - filter: " << id << " stdDeviation: " << stdDeviation); - } - - if (stdDeviation > 0.0f) - filter->gaussianBlurStdDeviation = jmax (filter->gaussianBlurStdDeviation.value_or (0.0f), stdDeviation); - } - - filters.push_back (filter); - filtersById.set (id, filter); - YUP_DRAWABLE_LOG ("parseFilter result - id: " << id - << " gaussianBlurStdDeviation: " << filter->gaussianBlurStdDeviation.value_or (0.0f)); -} - -//============================================================================== - -Drawable::Filter::Ptr Drawable::getFilterById (const String& id) -{ - return filtersById[id]; -} - -//============================================================================== - -Drawable::Filter::Ptr Drawable::resolveFilter (Filter::Ptr filter) -{ - if (filter == nullptr || filter->href.isEmpty()) - return filter; - - auto referencedFilter = resolveFilter (getFilterById (filter->href)); - if (referencedFilter == nullptr) - { - YUP_DRAWABLE_LOG ("Referenced filter not found: " << filter->href); - return filter; - } - - Filter::Ptr resolvedFilter = new Filter; - resolvedFilter->id = filter->id; - resolvedFilter->href = filter->href; - resolvedFilter->gaussianBlurStdDeviation = filter->gaussianBlurStdDeviation ? filter->gaussianBlurStdDeviation - : referencedFilter->gaussianBlurStdDeviation; - - YUP_DRAWABLE_LOG ("Resolved filter " << filter->id << " from reference " << filter->href); - return resolvedFilter; -} - -//============================================================================== - -void Drawable::parseClipPath (const XmlElement& element) -{ - String id = element.getStringAttribute ("id"); - if (id.isEmpty()) - { - YUP_DRAWABLE_LOG ("parseClipPath skipped - missing id"); - return; - } - - ClipPath::Ptr clipPath = new ClipPath; - clipPath->id = id; - - if (element.getStringAttribute ("clipPathUnits") == "objectBoundingBox") - clipPath->units = ClipPath::ObjectBoundingBox; - - YUP_DRAWABLE_LOG ("parseClipPath - id: " << id - << " units: " << (clipPath->units == ClipPath::ObjectBoundingBox ? "objectBoundingBox" : "userSpaceOnUse")); - - // Parse child elements that make up the clipping path - for (auto* child = element.getFirstChildElement(); child != nullptr; child = child->getNextElement()) - { - Element::Ptr clipElement = new Element; - clipElement->tagName = child->getTagNameWithoutNamespace(); - - if (auto childId = child->getStringAttribute ("id"); childId.isNotEmpty()) - clipElement->id = childId; - - if (child->hasTagName ("path")) - { - auto path = Path(); - String pathData = child->getStringAttribute ("d"); - if (pathData.isNotEmpty() && path.fromString (pathData)) - { - clipElement->path = std::move (path); - YUP_DRAWABLE_LOG ("Parsed clip path child path - id: " << id << " bounds: " << clipElement->path->getBounds().toString()); - } - else - { - YUP_DRAWABLE_LOG ("Clip path child path failed/empty - id: " << id << " pathLength: " << pathData.length()); - } - } - else if (child->hasTagName ("rect")) - { - auto x = child->getFloatAttribute ("x"); - auto y = child->getFloatAttribute ("y"); - auto width = child->getFloatAttribute ("width"); - auto height = child->getFloatAttribute ("height"); - auto rx = child->getFloatAttribute ("rx"); - auto ry = child->getFloatAttribute ("ry"); - - auto path = Path(); - if (rx > 0.0f || ry > 0.0f) - { - if (rx == 0.0f) - rx = ry; - if (ry == 0.0f) - ry = rx; - - path.addRoundedRectangle (x, y, width, height, rx, ry, rx, ry); - } - else - { - path.addRectangle (x, y, width, height); - } - - clipElement->path = std::move (path); - YUP_DRAWABLE_LOG ("Parsed clip path child rect - id: " << id - << " bounds: " << clipElement->path->getBounds().toString()); - } - else if (child->hasTagName ("circle")) - { - auto cx = child->getFloatAttribute ("cx"); - auto cy = child->getFloatAttribute ("cy"); - auto r = child->getFloatAttribute ("r"); - - auto path = Path(); - path.addCenteredEllipse (cx, cy, r, r); - clipElement->path = std::move (path); - YUP_DRAWABLE_LOG ("Parsed clip path child circle - id: " << id - << " bounds: " << clipElement->path->getBounds().toString()); - } - else if (child->hasTagName ("ellipse")) - { - auto cx = child->getFloatAttribute ("cx"); - auto cy = child->getFloatAttribute ("cy"); - auto rx = child->getFloatAttribute ("rx"); - auto ry = child->getFloatAttribute ("ry"); - - auto path = Path(); - path.addCenteredEllipse (cx, cy, rx, ry); - clipElement->path = std::move (path); - YUP_DRAWABLE_LOG ("Parsed clip path child ellipse - id: " << id - << " bounds: " << clipElement->path->getBounds().toString()); - } - else if (child->hasTagName ("polygon") || child->hasTagName ("polyline")) - { - String points = child->getStringAttribute ("points"); - auto coords = StringArray::fromTokens (points, " ,", ""); - auto path = Path(); - - if (coords.size() >= 4 && coords.size() % 2 == 0) - { - path.startNewSubPath (coords[0].getFloatValue(), coords[1].getFloatValue()); - - for (int i = 2; i < coords.size(); i += 2) - path.lineTo (coords[i].getFloatValue(), coords[i + 1].getFloatValue()); - - if (child->hasTagName ("polygon")) - path.closeSubPath(); - } - - clipElement->path = std::move (path); - YUP_DRAWABLE_LOG ("Parsed clip path child " << child->getTagNameWithoutNamespace() - << " - id: " << id - << " coordinateCount: " << coords.size() - << " bounds: " << clipElement->path->getBounds().toString()); - } - else - { - YUP_DRAWABLE_LOG ("Unsupported clip path child - id: " << id << " tag: " << child->getTagNameWithoutNamespace()); - } - - if (clipElement->path) - { - auto clipChildTransform = parseTransform (*child, AffineTransform::identity(), *clipElement); - parseStyle (*child, clipChildTransform, *clipElement); - - if (clipElement->clipRule && *clipElement->clipRule == "evenodd") - clipElement->path->setUsingNonZeroWinding (false); - - clipPath->elements.push_back (clipElement); - } - } - - clipPaths.push_back (clipPath); - clipPathsById.set (id, clipPath); - YUP_DRAWABLE_LOG ("parseClipPath result - id: " << id << " elementCount: " << clipPath->elements.size()); -} - -//============================================================================== - -Drawable::ClipPath::Ptr Drawable::getClipPathById (const String& id) -{ - return clipPathsById[id]; -} - -//============================================================================== - -void Drawable::parseCSSStyle (const String& styleString, Element& e) -{ - YUP_DRAWABLE_LOG ("parseCSSStyle - tag: " << e.tagName - << " id: " << e.id.value_or (String ("none")) - << " style: " << styleString); - - auto declarations = StringArray::fromTokens (styleString, ";", ""); - - for (const auto& declaration : declarations) - { - auto colonPos = declaration.indexOf (":"); - if (colonPos > 0) - { - String property = declaration.substring (0, colonPos).trim(); - String value = declaration.substring (colonPos + 1).trim(); - - YUP_DRAWABLE_LOG ("Applying inline CSS declaration - property: " << property << " value: " << value); - applyStyleProperty (property, value, e); - } - } -} - -void Drawable::applyStyleProperty (StringRef propertyRef, StringRef valueRef, Element& e) -{ - String property (propertyRef.text); - String value (valueRef.text); - - property = property.trim().toLowerCase(); - value = value.trim(); - - YUP_DRAWABLE_LOG ("applyStyleProperty - tag: " << e.tagName - << " id: " << e.id.value_or (String ("none")) - << " property: " << property - << " value: " << value); - - if (property == "fill") - { - e.fillCurrentColor = false; - e.fillUrl.reset(); - e.fillColor.reset(); - e.noFill = false; - - if (value == "none") - e.noFill = true; - else if (value == "currentColor") - e.fillCurrentColor = true; - else if (auto url = extractUrlId (value); url.isNotEmpty()) - e.fillUrl = url; - else if (value.isNotEmpty()) - e.fillColor = Color::fromString (value); - } - else if (property == "stroke") - { - e.strokeCurrentColor = false; - e.strokeUrl.reset(); - e.strokeColor.reset(); - e.noStroke = false; - - if (value == "none") - e.noStroke = true; - else if (value == "currentColor") - e.strokeCurrentColor = true; - else if (auto url = extractUrlId (value); url.isNotEmpty()) - e.strokeUrl = url; - else if (value.isNotEmpty()) - e.strokeColor = Color::fromString (value); - } - else if (property == "color") - { - if (value != "currentColor" && value != "inherit") - e.color = Color::fromString (value); - } - else if (property == "stroke-width") - { - float strokeWidth = parseUnit (value, e.strokeWidth.value_or (1.0f), e.fontSize.value_or (12.0f)); - if (strokeWidth >= 0.0f) - e.strokeWidth = strokeWidth; - } - else if (property == "stroke-linejoin") - { - if (value == "round") - e.strokeJoin = StrokeJoin::Round; - else if (value == "miter") - e.strokeJoin = StrokeJoin::Miter; - else if (value == "bevel") - e.strokeJoin = StrokeJoin::Bevel; - } - else if (property == "stroke-linecap") - { - if (value == "round") - e.strokeCap = StrokeCap::Round; - else if (value == "square") - e.strokeCap = StrokeCap::Square; - else if (value == "butt") - e.strokeCap = StrokeCap::Butt; - } - else if (property == "opacity") - { - float opacity = value.getFloatValue(); - if (opacity >= 0.0f && opacity <= 1.0f) - e.opacity = opacity; - } - else if (property == "display") - { - if (value == "none") - e.hidden = true; - } - else if (property == "visibility") - { - e.hidden = value == "hidden" || value == "collapse"; - } - else if (property == "font-family") - { - e.fontFamily = value.unquoted(); - } - else if (property == "font-size") - { - float fontSize = parseUnit (value, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); - if (fontSize > 0.0f) - e.fontSize = fontSize; - } - else if (property == "text-anchor") - { - e.textAnchor = value; - } - else if (property == "letter-spacing") - { - if (value != "normal") - e.letterSpacing = parseUnit (value, 0.0f, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); - } - else if (property == "word-spacing") - { - if (value != "normal") - e.wordSpacing = parseUnit (value, 0.0f, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); - } - else if (property == "font-weight") - { - YUP_DRAWABLE_LOG ("CSS font-weight currently not applied - value: " << value); - } - else if (property == "font-style") - { - YUP_DRAWABLE_LOG ("CSS font-style currently not applied - value: " << value); - } - else if (property == "font-variant") - { - YUP_DRAWABLE_LOG ("CSS font-variant currently not applied - value: " << value); - } - else if (property == "font-stretch") - { - YUP_DRAWABLE_LOG ("CSS font-stretch currently not applied - value: " << value); - } - else if (property == "font") - { - YUP_DRAWABLE_LOG ("CSS font shorthand currently not parsed - value: " << value); - } - else if (property == "dominant-baseline") - { - YUP_DRAWABLE_LOG ("CSS dominant-baseline currently not applied - value: " << value); - } - else if (property == "alignment-baseline") - { - YUP_DRAWABLE_LOG ("CSS alignment-baseline currently not applied - value: " << value); - } - else if (property == "baseline-shift") - { - YUP_DRAWABLE_LOG ("CSS baseline-shift currently not applied - value: " << value); - } - else if (property == "clip-path") - { - String clipPathUrl = extractUrlId (value); - if (clipPathUrl.isNotEmpty()) - e.clipPathUrl = clipPathUrl; - } - else if (property == "filter") - { - if (value == "none") - e.filterUrl.reset(); - else if (auto filterUrl = extractUrlId (value); filterUrl.isNotEmpty()) - e.filterUrl = filterUrl; - else - YUP_DRAWABLE_LOG ("CSS filter currently only supports url(...) - value: " << value); - } - else if (property == "stroke-dasharray") - { - if (value == "none") - e.strokeDashArray.reset(); - else - { - auto dashes = parseLengthList (value, e.fontSize.value_or (12.0f), 100.0f); - if (! dashes.isEmpty()) - e.strokeDashArray = dashes; - } - } - else if (property == "stroke-dashoffset") - { - e.strokeDashOffset = parseUnit (value); - } - else if (property == "fill-opacity") - { - float opacity = value.getFloatValue(); - if (opacity >= 0.0f && opacity <= 1.0f) - e.fillOpacity = opacity; - } - else if (property == "stroke-opacity") - { - float opacity = value.getFloatValue(); - if (opacity >= 0.0f && opacity <= 1.0f) - e.strokeOpacity = opacity; - } - else if (property == "fill-rule") - { - if (value == "evenodd" || value == "nonzero") - e.fillRule = value; - } - else if (property == "clip-rule") - { - if (value == "evenodd" || value == "nonzero") - e.clipRule = value; - } - else - { - YUP_DRAWABLE_LOG ("Unsupported CSS property ignored - property: " << property << " value: " << value); - } -} - -//============================================================================== - -void Drawable::applyStylesheetRules (const XmlElement& xmlElement, Element& e) -{ - std::vector matchedRules; - - for (const auto& rule : cssRules) - { - if (matchesCssSelector (xmlElement, rule)) - matchedRules.push_back (std::addressof (rule)); - } - - std::stable_sort (matchedRules.begin(), matchedRules.end(), [] (const CssRule* a, const CssRule* b) - { - if (a->specificity != b->specificity) - return a->specificity < b->specificity; - - return a->order < b->order; - }); - - if (! matchedRules.empty()) - { - YUP_DRAWABLE_LOG ("applyStylesheetRules - tag: " << e.tagName - << " id: " << e.id.value_or (String ("none")) - << " matchedRules: " << matchedRules.size()); - } - - for (const auto* rule : matchedRules) - { - YUP_DRAWABLE_LOG ("Applying stylesheet rule - selector: " << rule->selector - << " specificity: " << rule->specificity - << " order: " << rule->order - << " declarationCount: " << rule->declarations.size()); - - for (const auto& declaration : rule->declarations) - { - auto colonPos = declaration.indexOf (":"); - if (colonPos > 0) - applyStyleProperty (declaration.substring (0, colonPos).trim(), declaration.substring (colonPos + 1).trim(), e); - } - } -} - -void Drawable::parseStyleElement (const XmlElement& element) -{ - auto css = element.getAllSubText(); - int ruleOrder = static_cast (cssRules.size()); - - YUP_DRAWABLE_LOG ("parseStyleElement - cssLength: " << css.length() << " existingRules: " << cssRules.size()); - - while (css.isNotEmpty()) - { - auto openBrace = css.indexOf ("{"); - auto closeBrace = css.indexOf ("}"); - if (openBrace <= 0 || closeBrace <= openBrace) - break; - - auto selectorText = css.substring (0, openBrace).trim(); - auto declarationText = css.substring (openBrace + 1, closeBrace).trim(); - - css = css.substring (closeBrace + 1); - - auto selectors = StringArray::fromTokens (selectorText, ",", ""); - auto declarations = StringArray::fromTokens (declarationText, ";", ""); - - for (auto selector : selectors) - { - selector = selector.trim(); - if (selector.isEmpty()) - continue; - - CssRule rule; - rule.selector = selector; - rule.declarations = declarations; - rule.order = ruleOrder++; - - if (selector.startsWithChar ('#')) - rule.specificity = 100; - else if (selector.startsWithChar ('.')) - rule.specificity = 10; - else if (selector.containsChar ('#')) - rule.specificity = 101; - else if (selector.containsChar ('.')) - rule.specificity = 11; - else - rule.specificity = 1; - - YUP_DRAWABLE_LOG ("Parsed CSS rule - selector: " << rule.selector - << " specificity: " << rule.specificity - << " declarationCount: " << rule.declarations.size() - << " order: " << rule.order); - cssRules.push_back (std::move (rule)); - } - } - - YUP_DRAWABLE_LOG ("parseStyleElement result - totalRules: " << cssRules.size()); -} - -bool Drawable::matchesCssSelector (const XmlElement& xmlElement, const CssRule& rule) const -{ - auto selector = rule.selector.trim(); - if (selector.isEmpty() || selector.containsChar (' ') || selector.containsChar ('>') || selector.containsChar ('+')) - return false; - - String tagName; - String id; - String className; - - auto hashIndex = selector.indexOf ("#"); - auto dotIndex = selector.indexOf ("."); - auto splitIndex = -1; - - if (hashIndex >= 0 && dotIndex >= 0) - splitIndex = jmin (hashIndex, dotIndex); - else - splitIndex = jmax (hashIndex, dotIndex); - - if (splitIndex > 0) - tagName = selector.substring (0, splitIndex); - - if (hashIndex == 0) - id = selector.substring (1); - else if (hashIndex > 0) - id = selector.substring (hashIndex + 1, dotIndex > hashIndex ? dotIndex : selector.length()); - - if (dotIndex == 0) - className = selector.substring (1); - else if (dotIndex > 0) - className = selector.substring (dotIndex + 1); - - if (splitIndex < 0 && ! selector.startsWithChar ('#') && ! selector.startsWithChar ('.')) - tagName = selector; - - if (tagName.isNotEmpty() && tagName != xmlElement.getTagNameWithoutNamespace()) - return false; - - if (id.isNotEmpty() && id != xmlElement.getStringAttribute ("id")) - return false; - - if (className.isNotEmpty()) - { - auto classes = StringArray::fromTokens (xmlElement.getStringAttribute ("class"), " \t\r\n", ""); - if (! classes.contains (className)) - return false; - } - - return tagName.isNotEmpty() || id.isNotEmpty() || className.isNotEmpty(); -} - -//============================================================================== - -float Drawable::parseUnit (const String& value, float defaultValue, float fontSize, float viewportSize) -{ - if (value.isEmpty()) - return defaultValue; - - String trimmed = value.trim(); - if (trimmed.isEmpty()) - return defaultValue; - - // Extract numeric part and unit - int unitStart = 0; - while (unitStart < trimmed.length() - && (CharacterFunctions::isDigit (trimmed[unitStart]) - || trimmed[unitStart] == '.' - || trimmed[unitStart] == '-' - || trimmed[unitStart] == '+' - || trimmed[unitStart] == 'e' - || trimmed[unitStart] == 'E')) - { - unitStart++; - } - - float numericValue = trimmed.substring (0, unitStart).getFloatValue(); - String unit = trimmed.substring (unitStart).trim().toLowerCase(); - - // Handle different SVG units - if (unit.isEmpty() || unit == "px") - return numericValue; // Default user units or pixels - - else if (unit == "pt") - return numericValue * 1.333333f; // 1pt = 1.333px - - else if (unit == "pc") - return numericValue * 16.0f; // 1pc = 16px - - else if (unit == "mm") - return numericValue * 3.779528f; // 1mm = 3.779528px (96 DPI) - - else if (unit == "cm") - return numericValue * 37.79528f; // 1cm = 37.79528px (96 DPI) - - else if (unit == "in") - return numericValue * 96.0f; // 1in = 96px (96 DPI) - - else if (unit == "em") - return numericValue * fontSize; // Relative to font size - - else if (unit == "ex") - return numericValue * fontSize * 0.5f; // Approximately 0.5em - - else if (unit == "%") - return numericValue * viewportSize * 0.01f; // Percentage of viewport - - else - return numericValue; // Unknown unit, treat as user units -} - -float Drawable::parseLengthAttribute (const XmlElement& element, StringRef attributeName, float defaultValue, float fontSize, float viewportSize) -{ - auto value = element.getStringAttribute (attributeName); - if (value.isEmpty()) - return defaultValue; - - const auto result = parseUnit (value, defaultValue, fontSize, viewportSize); - YUP_DRAWABLE_LOG ("parseLengthAttribute - tag: " << element.getTagNameWithoutNamespace() - << " attribute: " << String (attributeName.text) - << " raw: " << value - << " result: " << result - << " default: " << defaultValue - << " fontSize: " << fontSize - << " viewportSize: " << viewportSize); - return result; -} - -Array Drawable::parseLengthList (const String& value, float fontSize, float viewportSize) -{ - Array result; - auto tokens = StringArray::fromTokens (value, " ,\t\r\n", ""); - - for (const auto& token : tokens) - { - if (token.isNotEmpty()) - result.add (parseUnit (token, 0.0f, fontSize, viewportSize)); - } - - YUP_DRAWABLE_LOG ("parseLengthList - raw: " << value - << " count: " << result.size() - << " fontSize: " << fontSize - << " viewportSize: " << viewportSize); - return result; -} - -std::optional Drawable::loadImageFromHref (const String& href) const -{ - YUP_DRAWABLE_LOG ("loadImageFromHref - href: " << href - << " baseDirectory: " << parseOptions.baseDirectory.getFullPathName() - << " allowDataImages: " << (parseOptions.allowDataImages ? "true" : "false") - << " allowLocalImages: " << (parseOptions.allowLocalImages ? "true" : "false") - << " hasResolver: " << (parseOptions.imageResolver ? "true" : "false")); - - if (parseOptions.imageResolver) - { - if (auto resolved = parseOptions.imageResolver (href, parseOptions.baseDirectory)) - { - YUP_DRAWABLE_LOG ("loadImageFromHref - resolved by custom resolver"); - return resolved; - } - - YUP_DRAWABLE_LOG ("loadImageFromHref - custom resolver returned no image"); - } - - if (href.startsWithIgnoreCase ("http:") || href.startsWithIgnoreCase ("https:")) - { - YUP_DRAWABLE_LOG ("loadImageFromHref skipped - network URLs are disabled"); - return std::nullopt; - } - - MemoryBlock imageData; - - if (href.startsWithIgnoreCase ("data:")) - { - if (! parseOptions.allowDataImages) - { - YUP_DRAWABLE_LOG ("loadImageFromHref skipped - data images disabled"); - return std::nullopt; - } - - auto comma = href.indexOfChar (','); - if (comma < 0) - { - YUP_DRAWABLE_LOG ("loadImageFromHref failed - malformed data URI"); - return std::nullopt; - } - - auto metadata = href.substring (0, comma).toLowerCase(); - auto payload = href.substring (comma + 1); - - if (metadata.contains (";base64")) - { - MemoryOutputStream decoded; - if (! Base64::convertFromBase64 (decoded, payload)) - { - YUP_DRAWABLE_LOG ("loadImageFromHref failed - base64 decode failed"); - return std::nullopt; - } - - imageData = decoded.getMemoryBlock(); - YUP_DRAWABLE_LOG ("loadImageFromHref - decoded base64 data URI bytes: " << imageData.getSize()); - } - else - { - imageData = MemoryBlock (payload.toRawUTF8(), static_cast (payload.getNumBytesAsUTF8())); - YUP_DRAWABLE_LOG ("loadImageFromHref - copied non-base64 data URI bytes: " << imageData.getSize()); - } - } - else - { - if (! parseOptions.allowLocalImages || parseOptions.baseDirectory.getFullPathName().isEmpty()) - { - YUP_DRAWABLE_LOG ("loadImageFromHref skipped - local images disabled or base directory empty"); - return std::nullopt; - } - - auto imageFile = parseOptions.baseDirectory.getChildFile (href); - if (! imageFile.existsAsFile() || ! imageFile.loadFileAsData (imageData)) - { - YUP_DRAWABLE_LOG ("loadImageFromHref failed - local file missing or unreadable: " << imageFile.getFullPathName()); - return std::nullopt; - } - - YUP_DRAWABLE_LOG ("loadImageFromHref - loaded local file: " << imageFile.getFullPathName() - << " bytes: " << imageData.getSize()); - } - - if (imageData.isEmpty()) - { - YUP_DRAWABLE_LOG ("loadImageFromHref failed - image data empty"); - return std::nullopt; - } - - auto result = Image::loadFromData (imageData.asBytes()); - if (result.failed()) - { - YUP_DRAWABLE_LOG ("loadImageFromHref failed - Image::loadFromData failed: " << result.getErrorMessage()); - return std::nullopt; - } - - YUP_DRAWABLE_LOG ("loadImageFromHref result - image decoded"); - return result.getValue(); -} - -Font Drawable::resolveFont (const Element& element) const -{ - const auto fontSize = element.fontSize.value_or (12.0f); - - YUP_DRAWABLE_LOG ("resolveFont - tag: " << element.tagName - << " id: " << element.id.value_or (String ("none")) - << " family: " << element.fontFamily.value_or (String ("none")) - << " size: " << fontSize - << " hasResolver: " << (parseOptions.fontResolver ? "true" : "false")); - - if (parseOptions.fontResolver) - { - if (auto resolved = parseOptions.fontResolver (element.fontFamily.value_or (String()), fontSize)) - { - auto result = resolved->withHeight (fontSize); - YUP_DRAWABLE_LOG ("resolveFont - resolved by custom resolver" - << " requestedHeight: " << fontSize - << " resolvedHeightBefore: " << resolved->getHeight() - << " resultHeight: " << result.getHeight() - << " ascent: " << result.getAscent() - << " descent: " << result.getDescent() - << " weight: " << result.getWeight() - << " italic: " << (result.isItalic() ? "true" : "false") - << " axisCount: " << result.getNumAxis()); - return result; - } - - YUP_DRAWABLE_LOG ("resolveFont - custom resolver returned no font"); - } - - auto result = Font().withHeight (fontSize); - YUP_DRAWABLE_LOG ("resolveFont - using default font" - << " resultHeight: " << result.getHeight() - << " ascent: " << result.getAscent() - << " descent: " << result.getDescent() - << " weight: " << result.getWeight() - << " italic: " << (result.isItalic() ? "true" : "false") - << " axisCount: " << result.getNumAxis()); - return result; -} - -void Drawable::renderTextElement (Graphics& g, const Element& element) -{ - if (! element.text || ! element.textPosition || element.text->isEmpty()) - { - YUP_DRAWABLE_LOG ("renderTextElement skipped - missing text/position" - << " tag: " << element.tagName - << " hasText: " << (element.text ? "true" : "false") - << " hasPosition: " << (element.textPosition ? "true" : "false")); - return; - } - - auto position = *element.textPosition; - YUP_DRAWABLE_LOG ("renderTextElement position inputs - tag: " << element.tagName - << " id: " << element.id.value_or (String ("none")) - << " basePosition: " << position.toString() - << " hasXList: " << (element.textX ? "true" : "false") - << " hasYList: " << (element.textY ? "true" : "false") - << " hasDxList: " << (element.textDx ? "true" : "false") - << " hasDyList: " << (element.textDy ? "true" : "false")); - - if (element.textX && ! element.textX->isEmpty()) - { - YUP_DRAWABLE_LOG ("renderTextElement applying x list first value: " << element.textX->getFirst()); - position.setX (element.textX->getFirst()); - } - if (element.textY && ! element.textY->isEmpty()) - { - YUP_DRAWABLE_LOG ("renderTextElement applying y list first value: " << element.textY->getFirst()); - position.setY (element.textY->getFirst()); - } - if (element.textDx && ! element.textDx->isEmpty()) - { - YUP_DRAWABLE_LOG ("renderTextElement applying dx first value: " << element.textDx->getFirst()); - position.setX (position.getX() + element.textDx->getFirst()); - } - if (element.textDy && ! element.textDy->isEmpty()) - { - YUP_DRAWABLE_LOG ("renderTextElement applying dy first value: " << element.textDy->getFirst()); - position.setY (position.getY() + element.textDy->getFirst()); - } - - const auto font = resolveFont (element); - const auto fontSize = element.fontSize.value_or (12.0f); + const auto font = resolveFont (element); + const auto fontSize = element.fontSize.value_or (12.0f); StyledText styledText; { @@ -3312,14 +695,6 @@ void Drawable::renderTextElement (Graphics& g, const Element& element) modifier.setHorizontalAlign (StyledText::left); modifier.setVerticalAlign (StyledText::top); modifier.appendText (*element.text, font, -1.0f, element.letterSpacing.value_or (0.0f)); - - YUP_DRAWABLE_LOG ("renderTextElement StyledText setup - maxSize: " << jmax (fontSize, static_cast (element.text->length()) * fontSize * 2.0f) - << "x" << (fontSize * 4.0f) - << " wrap: noWrap" - << " horizontalAlign: left" - << " verticalAlign: top" - << " appendedLength: " << element.text->length() - << " appendedLetterSpacing: " << element.letterSpacing.value_or (0.0f)); } const auto computedTextBounds = styledText.getComputedTextBounds(); @@ -3335,93 +710,47 @@ void Drawable::renderTextElement (Graphics& g, const Element& element) auto textX = position.getX(); if (element.textAnchor == "middle") - { - YUP_DRAWABLE_LOG ("renderTextElement applying middle anchor - textWidth: " << textWidth); textX -= textWidth * 0.5f; - } else if (element.textAnchor == "end") - { - YUP_DRAWABLE_LOG ("renderTextElement applying end anchor - textWidth: " << textWidth); textX -= textWidth; - } - else if (element.textAnchor) - { - YUP_DRAWABLE_LOG ("renderTextElement anchor not specially handled - anchor: " << *element.textAnchor); - } Rectangle textBounds (textX, position.getY() + (ascent * fontSize), textWidth, textHeight + bottomPadding); - YUP_DRAWABLE_LOG ("renderTextElement - text: " << *element.text - << " position: " << position.toString() - << " bounds: " << textBounds.toString() - << " computedTextBounds: " << computedTextBounds.toString() - << " fontSize: " << fontSize - << " fontHeight: " << font.getHeight() - << " fontAscent: " << fontAscent - << " fontDescent: " << fontDescent - << " metricsAscentUsed: " << ascent - << " metricsDescentUsed: " << descent - << " fontWeight: " << font.getWeight() - << " fontItalic: " << (font.isItalic() ? "true" : "false") - << " fontAxisCount: " << font.getNumAxis() - << " anchor: " << element.textAnchor.value_or (String ("none")) - << " letterSpacing: " << element.letterSpacing.value_or (0.0f) - << " wordSpacing: " << element.wordSpacing.value_or (0.0f) - << " textWidth: " << textWidth - << " textHeight: " << textHeight - << " bottomPadding: " << bottomPadding - << " graphicsTransform: " << g.getTransform().toString()); - - YUP_DRAWABLE_LOG ("renderTextElement drawing fitted text - bounds: " << textBounds.toString()); g.fillFittedText (styledText, textBounds); } -void Drawable::renderImageElement (Graphics& g, const Element& element) +//============================================================================== + +void Drawable::renderImageElement (Graphics& g, const SVGElement& element) { if (! element.imageBounds) - { - YUP_DRAWABLE_LOG ("renderImageElement skipped - missing bounds" - << " tag: " << element.tagName - << " href: " << element.imageHref.value_or (String ("none"))); return; - } if (element.image) { - YUP_DRAWABLE_LOG ("renderImageElement - drawing cached image bounds: " << element.imageBounds->toString()); g.drawImage (*element.image, *element.imageBounds); return; } if (element.imageHref) { - if (auto image = loadImageFromHref (*element.imageHref)) + if (document != nullptr) { - YUP_DRAWABLE_LOG ("renderImageElement - drawing resolved image href: " << *element.imageHref - << " bounds: " << element.imageBounds->toString()); - g.drawImage (*image, *element.imageBounds); + if (auto image = SVGParser::loadImageFromHref (document->getParseOptions(), *element.imageHref)) + g.drawImage (*image, *element.imageBounds); } - else - { - YUP_DRAWABLE_LOG ("renderImageElement skipped - image href did not resolve: " << *element.imageHref); - } - } - else - { - YUP_DRAWABLE_LOG ("renderImageElement skipped - missing image and href"); } } +//============================================================================== + Path Drawable::createDashedPath (const Path& source, const Array& dashArray, float dashOffset) const { if (dashArray.isEmpty()) - { - YUP_DRAWABLE_LOG ("createDashedPath - dash array empty, returning source"); return source; - } Array positiveDashes; for (auto dash : dashArray) @@ -3431,10 +760,7 @@ Path Drawable::createDashedPath (const Path& source, const Array& dashArr } if (positiveDashes.isEmpty()) - { - YUP_DRAWABLE_LOG ("createDashedPath - no positive dash values, returning source"); return source; - } Path result; float totalPatternLength = 0.0f; @@ -3442,15 +768,7 @@ Path Drawable::createDashedPath (const Path& source, const Array& dashArr totalPatternLength += dash; if (totalPatternLength <= 0.0f) - { - YUP_DRAWABLE_LOG ("createDashedPath - total pattern length invalid, returning source"); return source; - } - - YUP_DRAWABLE_LOG ("createDashedPath - sourceBounds: " << source.getBounds().toString() - << " dashCount: " << positiveDashes.size() - << " dashOffset: " << dashOffset - << " totalPatternLength: " << totalPatternLength); int dashIndex = 0; float patternPosition = std::fmod (jmax (0.0f, dashOffset), totalPatternLength); @@ -3523,90 +841,22 @@ Path Drawable::createDashedPath (const Path& source, const Array& dashArr } } - YUP_DRAWABLE_LOG ("createDashedPath result - bounds: " << result.getBounds().toString() - << " empty: " << (result.isEmpty() ? "true" : "false")); - return result; -} - -//============================================================================== - -Rectangle Drawable::calculateBounds() const -{ - // Use viewBox if available, otherwise use size - if (! viewBox.isEmpty()) - { - YUP_DRAWABLE_LOG ("calculateBounds - using viewBox: " << viewBox.toString()); - return viewBox; - } - - if (size.getWidth() > 0 && size.getHeight() > 0) - { - auto sizeBounds = Rectangle (0, 0, size.getWidth(), size.getHeight()); - YUP_DRAWABLE_LOG ("calculateBounds - using size: " << sizeBounds.toString()); - return Rectangle (0, 0, size.getWidth(), size.getHeight()); - } - - // Fallback: calculate bounds from all elements with their transforms applied - // This gives us the actual visual bounds of the rendered content - Rectangle bounds; - bool hasValidBounds = false; - - for (const auto& element : elements) - { - if (element->path) - { - auto pathBounds = element->path->getBounds(); - if (element->transform) - pathBounds = element->path->getBoundsTransformed (*element->transform); - - YUP_DRAWABLE_LOG ("calculateBounds - element tag: " << element->tagName - << " id: " << element->id.value_or (String ("none")) - << " bounds: " << pathBounds.toString() - << " hasTransform: " << (element->transform ? "true" : "false")); - - if (hasValidBounds) - bounds = bounds.unionWith (pathBounds); - else - { - bounds = pathBounds; - hasValidBounds = true; - } - } - else - { - YUP_DRAWABLE_LOG ("calculateBounds - skipping element without path tag: " << element->tagName - << " id: " << element->id.value_or (String ("none"))); - } - } - - auto result = hasValidBounds ? bounds : Rectangle (0, 0, 100, 100); - YUP_DRAWABLE_LOG ("calculateBounds result - hasValidBounds: " << (hasValidBounds ? "true" : "false") - << " bounds: " << result.toString()); return result; } //============================================================================== -AffineTransform Drawable::calculateTransformForTarget (const Rectangle& sourceBounds, const Rectangle& targetArea, Fitting fitting, Justification justification) const +AffineTransform Drawable::calculateTransformForTarget (const Rectangle& sourceBounds, + const Rectangle& targetArea, + Fitting fitting, + Justification justification) const { if (sourceBounds.isEmpty() || targetArea.isEmpty()) - { - YUP_DRAWABLE_LOG ("calculateTransformForTarget - empty source or target" - << " source: " << sourceBounds.toString() - << " target: " << targetArea.toString()); return AffineTransform::identity(); - } float scaleX = targetArea.getWidth() / sourceBounds.getWidth(); float scaleY = targetArea.getHeight() / sourceBounds.getHeight(); - YUP_DRAWABLE_LOG ("calculateTransformForTarget - source: " << sourceBounds.toString() - << " target: " << targetArea.toString() - << " initialScaleX: " << scaleX - << " initialScaleY: " << scaleY - << " fitting: " << static_cast (fitting) - << " justificationFlags: " << static_cast (justification.getFlags())); - - // Apply scaling based on fitting mode + switch (fitting) { case Fitting::none: @@ -3614,149 +864,216 @@ AffineTransform Drawable::calculateTransformForTarget (const Rectangle& s break; case Fitting::scaleToFit: - scaleX = scaleY = jmin (scaleX, scaleY); // Scale to fit both dimensions + scaleX = scaleY = jmin (scaleX, scaleY); break; case Fitting::fitWidth: - scaleY = scaleX; // Scale to fit width, preserve aspect ratio + scaleY = scaleX; break; case Fitting::fitHeight: - scaleX = scaleY; // Scale to fit height, preserve aspect ratio + scaleX = scaleY; break; case Fitting::scaleToFill: case Fitting::centerCrop: - scaleX = scaleY = jmax (scaleX, scaleY); // Scale to fill, may crop + scaleX = scaleY = jmax (scaleX, scaleY); break; case Fitting::fill: - // Use calculated scales as-is (non-uniform scaling) break; case Fitting::centerInside: - // Like scaleToFit but don't upscale beyond original size scaleX = scaleY = jmin (1.0f, jmin (scaleX, scaleY)); break; case Fitting::stretchWidth: - scaleY = 1.0f; // Stretch horizontally only + scaleY = 1.0f; break; case Fitting::stretchHeight: - scaleX = 1.0f; // Stretch vertically only + scaleX = 1.0f; break; case Fitting::tile: - // For tile mode, use no scaling (tiling would be handled elsewhere) scaleX = scaleY = 1.0f; break; } - // Calculate scaled size float scaledWidth = sourceBounds.getWidth() * scaleX; float scaledHeight = sourceBounds.getHeight() * scaleY; - // Calculate offset based on justification float offsetX = targetArea.getX(); float offsetY = targetArea.getY(); - // Horizontal justification if (justification.testFlags (Justification::horizontalCenter)) offsetX += (targetArea.getWidth() - scaledWidth) * 0.5f; else if (justification.testFlags (Justification::right)) offsetX += targetArea.getWidth() - scaledWidth; - // Vertical justification if (justification.testFlags (Justification::verticalCenter)) offsetY += (targetArea.getHeight() - scaledHeight) * 0.5f; else if (justification.testFlags (Justification::bottom)) offsetY += targetArea.getHeight() - scaledHeight; - // Create transform: translate to origin, scale, then translate to target position - auto result = AffineTransform::translation (-sourceBounds.getX(), -sourceBounds.getY()) - .scaled (scaleX, scaleY) - .translated (offsetX, offsetY); - - YUP_DRAWABLE_LOG ("calculateTransformForTarget result - scaleX: " << scaleX - << " scaleY: " << scaleY - << " scaledWidth: " << scaledWidth - << " scaledHeight: " << scaledHeight - << " offsetX: " << offsetX - << " offsetY: " << offsetY - << " transform: " << result.toString()); - return result; + return AffineTransform::translation (-sourceBounds.getX(), -sourceBounds.getY()) + .scaled (scaleX, scaleY) + .translated (offsetX, offsetY); } //============================================================================== -Fitting Drawable::parsePreserveAspectRatio (const String& preserveAspectRatio) +ColorGradient Drawable::createColorGradientFromSVG (const SVGGradient& gradient, const Rectangle* objectBounds) const { - if (preserveAspectRatio.isEmpty() || preserveAspectRatio == "xMidYMid meet") - return Fitting::scaleToFit; // Default SVG behavior + YUP_DRAWABLE_LOG ("Creating ColorGradient from SVG gradient ID: " << gradient.id + << " type: " << (gradient.type == SVGGradient::Linear ? "Linear" : "Radial") + << " units: " << (gradient.units == SVGGradient::UserSpaceOnUse ? "userSpaceOnUse" : "objectBoundingBox")); - if (preserveAspectRatio.contains ("none")) - return Fitting::fill; // Non-uniform scaling allowed + if (gradient.stops.empty()) + { + YUP_DRAWABLE_LOG ("No stops in gradient, returning empty"); + return ColorGradient(); + } - if (preserveAspectRatio.contains ("slice")) - return Fitting::scaleToFill; // Scale to fill, may crop + const bool hasBounds = objectBounds != nullptr && objectBounds->getWidth() > 0.0f && objectBounds->getHeight() > 0.0f; + AffineTransform unitsTransform = AffineTransform::identity(); - // Default to uniform scaling (meet) - return Fitting::scaleToFit; -} + if (gradient.units == SVGGradient::ObjectBoundingBox && hasBounds) + { + unitsTransform = AffineTransform::translation (objectBounds->getX(), objectBounds->getY()) + .scaled (objectBounds->getWidth(), objectBounds->getHeight()); + } -Justification Drawable::parseAspectRatioAlignment (const String& preserveAspectRatio) -{ - if (preserveAspectRatio.isEmpty()) - return Justification::center; // Default SVG alignment - - Justification result = Justification::left; - - // Parse horizontal alignment - if (preserveAspectRatio.contains ("xMin")) - result = result | Justification::left; - else if (preserveAspectRatio.contains ("xMax")) - result = result | Justification::right; - else // xMid (default) - result = result | Justification::horizontalCenter; - - // Parse vertical alignment - if (preserveAspectRatio.contains ("YMin")) - result = result | Justification::top; - else if (preserveAspectRatio.contains ("YMax")) - result = result | Justification::bottom; - else // YMid (default) - result = result | Justification::verticalCenter; + const AffineTransform gradientSpaceTransform = unitsTransform.followedBy (gradient.transform); - return result; -} + auto transformPoint = [&gradientSpaceTransform] (Point p) + { + float x = p.getX(); + float y = p.getY(); + if (! gradientSpaceTransform.isIdentity()) + gradientSpaceTransform.transformPoint (x, y); + return Point (x, y); + }; -//============================================================================== + const Point start = transformPoint (gradient.start); + const Point end = transformPoint (gradient.end); + const Point center = transformPoint (gradient.center); -String Drawable::extractGradientUrl (const String& value) -{ - return extractUrlId (value); -} + auto computeRadius = [&]() -> float + { + if (gradient.radius <= 0.0f) + return 0.0f; -String Drawable::extractUrlId (const String& value) -{ - // Find the start of the URL - int urlStart = value.indexOf ("url("); - if (urlStart == -1) - return String(); - - // Find the end of the URL (first closing parenthesis after the URL start) - int urlEnd = value.indexOf (urlStart, ")"); - if (urlEnd == -1) - return String(); - - String url = value.substring (urlStart + 4, urlEnd).trim().unquoted(); - if (! url.startsWithChar ('#')) - return String(); - - url = url.substring (1); - YUP_DRAWABLE_LOG ("Extracted gradient URL: '" << url << "' from: '" << value << "'"); - return url; + const Point edgePoints[] = { + transformPoint (Point (gradient.center.getX() + gradient.radius, gradient.center.getY())), + transformPoint (Point (gradient.center.getX() - gradient.radius, gradient.center.getY())), + transformPoint (Point (gradient.center.getX(), gradient.center.getY() + gradient.radius)), + transformPoint (Point (gradient.center.getX(), gradient.center.getY() - gradient.radius)) + }; + + float maxRadius = 0.0f; + for (const auto& edgePoint : edgePoints) + maxRadius = jmax (maxRadius, Line (center, edgePoint).length()); + + return maxRadius; + }; + + const auto radius = gradient.type == SVGGradient::Radial ? computeRadius() : 0.0f; + + std::vector colorStops; + colorStops.reserve (gradient.stops.size()); + + if (gradient.type == SVGGradient::Linear + && objectBounds != nullptr + && (gradient.spreadMethod == "reflect" || gradient.spreadMethod == "repeat")) + { + const auto dx = end.getX() - start.getX(); + const auto dy = end.getY() - start.getY(); + const auto lengthSquared = dx * dx + dy * dy; + + if (lengthSquared > 0.0f) + { + const Point corners[] = { + objectBounds->getTopLeft(), + objectBounds->getTopRight(), + objectBounds->getBottomLeft(), + objectBounds->getBottomRight() + }; + + float minT = 0.0f; + float maxT = 1.0f; + + for (const auto& corner : corners) + { + const auto t = ((corner.getX() - start.getX()) * dx + (corner.getY() - start.getY()) * dy) / lengthSquared; + minT = jmin (minT, t); + maxT = jmax (maxT, t); + } + + int firstRepeat = static_cast (std::floor (minT)); + int lastRepeat = static_cast (std::ceil (maxT)); + + constexpr int maxGradientRepeats = 32; + if (lastRepeat - firstRepeat > maxGradientRepeats) + { + const auto centerRepeat = (firstRepeat + lastRepeat) / 2; + firstRepeat = centerRepeat - (maxGradientRepeats / 2); + lastRepeat = firstRepeat + maxGradientRepeats; + } + + const auto repeatStart = static_cast (firstRepeat); + const auto repeatEnd = static_cast (jmax (lastRepeat, firstRepeat + 1)); + const auto repeatRange = repeatEnd - repeatStart; + const auto expandedStart = Point (start.getX() + dx * repeatStart, start.getY() + dy * repeatStart); + const auto expandedEnd = Point (start.getX() + dx * repeatEnd, start.getY() + dy * repeatEnd); + + colorStops.reserve (gradient.stops.size() * static_cast (repeatEnd - repeatStart)); + + for (int repeat = firstRepeat; repeat < lastRepeat; ++repeat) + { + const bool reflected = gradient.spreadMethod == "reflect" && (std::abs (repeat) % 2) == 1; + + for (const auto& stop : gradient.stops) + { + const auto repeatedOffset = static_cast (repeat) + (reflected ? (1.0f - stop.offset) : stop.offset); + const auto normalizedOffset = (repeatedOffset - repeatStart) / repeatRange; + const auto position = Point (expandedStart.getX() + (expandedEnd.getX() - expandedStart.getX()) * normalizedOffset, + expandedStart.getY() + (expandedEnd.getY() - expandedStart.getY()) * normalizedOffset); + + colorStops.emplace_back (stop.color.withMultipliedAlpha (stop.opacity), position, jlimit (0.0f, 1.0f, normalizedOffset)); + } + } + + std::sort (colorStops.begin(), colorStops.end(), [] (const auto& a, const auto& b) + { + return a.delta < b.delta; + }); + } + } + + if (colorStops.empty()) + { + for (const auto& stop : gradient.stops) + { + Color color = stop.color.withMultipliedAlpha (stop.opacity); + + if (gradient.type == SVGGradient::Linear) + { + const auto interpolated = Point (start.getX() + stop.offset * (end.getX() - start.getX()), + start.getY() + stop.offset * (end.getY() - start.getY())); + + colorStops.emplace_back (color, interpolated, stop.offset); + } + else + { + const auto radialPoint = Point (center.getX() + radius * stop.offset, center.getY()); + colorStops.emplace_back (color, radialPoint, stop.offset); + } + } + } + + ColorGradient::Type type = (gradient.type == SVGGradient::Linear) ? ColorGradient::Linear : ColorGradient::Radial; + return ColorGradient (type, colorStops); } } // namespace yup diff --git a/modules/yup_graphics/drawables/yup_Drawable.h b/modules/yup_graphics/drawables/yup_Drawable.h index f3718b67c..109391c5b 100644 --- a/modules/yup_graphics/drawables/yup_Drawable.h +++ b/modules/yup_graphics/drawables/yup_Drawable.h @@ -2,7 +2,7 @@ ============================================================================== This file is part of the YUP library. - Copyright (c) 2024 - kunitoki@gmail.com + Copyright (c) 2026 - kunitoki@gmail.com YUP is an open source library subject to open-source licensing. @@ -31,25 +31,8 @@ namespace yup class YUP_API Drawable { public: - //============================================================================== - /** Options used when parsing SVG data. */ - struct ParseOptions - { - /** Base directory used to resolve local image hrefs. */ - File baseDirectory; - - /** Allows image data embedded as data: URIs. */ - bool allowDataImages = true; - - /** Allows local file image hrefs relative to baseDirectory. Network URLs are never loaded. */ - bool allowLocalImages = true; - - /** Optional custom image resolver. Return std::nullopt to use the default resolver. */ - std::function (StringRef href, const File& baseDirectory)> imageResolver; - - /** Optional custom font resolver. Return std::nullopt to use the default font. */ - std::function (StringRef family, float size)> fontResolver; - }; + /** Options used when parsing SVG data. Alias for SVGDocument::ParseOptions. */ + using ParseOptions = SVGDocument::ParseOptions; //============================================================================== /** Constructor. */ @@ -110,215 +93,20 @@ class YUP_API Drawable Justification justification = Justification::center); private: - struct Element : public ReferenceCountedObject - { - using Ptr = ReferenceCountedObjectPtr; - - String tagName; - std::optional id; - StringArray classNames; - - std::optional transform; - std::optional localTransform; // Transform from the element itself (not accumulated) - std::optional path; - std::optional reference; - - std::optional fillColor; - std::optional strokeColor; - std::optional color; - bool fillCurrentColor = false; - bool strokeCurrentColor = false; - std::optional fillOpacity; - std::optional strokeOpacity; - std::optional strokeWidth; - std::optional strokeJoin; - std::optional strokeCap; - std::optional> strokeDashArray; - std::optional strokeDashOffset; - std::optional fillRule; // "evenodd" or "nonzero" - std::optional clipRule; // "evenodd" or "nonzero" - bool noFill = false; - bool noStroke = false; - - std::optional opacity; - - // Text properties - std::optional text; - std::optional> textPosition; - std::optional fontFamily; - std::optional fontSize; - std::optional textAnchor; - std::optional letterSpacing; - std::optional wordSpacing; - std::optional> textX; - std::optional> textY; - std::optional> textDx; - std::optional> textDy; - - // Gradient properties - std::optional fillUrl; - std::optional strokeUrl; - std::optional filterUrl; - - // Image properties - std::optional imageHref; - std::optional> imageBounds; - std::optional image; - - // Clipping properties - std::optional clipPathUrl; - std::optional> viewBox; - std::optional> viewportSize; - Fitting preserveAspectRatioFitting = Fitting::scaleToFit; - Justification preserveAspectRatioJustification = Justification::center; - bool isSymbol = false; - bool hidden = false; - - std::vector children; - }; - - struct GradientStop - { - float offset; - Color color; - float opacity = 1.0f; - }; - - struct Gradient : public ReferenceCountedObject - { - using Ptr = ReferenceCountedObjectPtr; - - enum Type - { - Linear, - Radial - }; - - enum Units - { - UserSpaceOnUse, - ObjectBoundingBox - }; - - Type type; - String id; - Units units = ObjectBoundingBox; // Default per SVG spec - String href; // xlink:href reference to another gradient - String spreadMethod = "pad"; - - // Linear gradient properties - Point start; - Point end; - - // Radial gradient properties - Point center; - float radius = 0.0f; - Point focal; - - std::vector stops; - AffineTransform transform; - - bool hasStart = false; - bool hasEnd = false; - bool hasCenter = false; - bool hasRadius = false; - bool hasFocal = false; - bool hasUnits = false; - bool hasSpreadMethod = false; - }; - - struct ClipPath : public ReferenceCountedObject - { - using Ptr = ReferenceCountedObjectPtr; - - enum Units - { - UserSpaceOnUse, - ObjectBoundingBox - }; - - String id; - Units units = UserSpaceOnUse; - std::vector elements; - }; - - struct Filter : public ReferenceCountedObject - { - using Ptr = ReferenceCountedObjectPtr; - - String id; - String href; - std::optional gaussianBlurStdDeviation; - }; - - struct CssRule - { - String selector; - StringArray declarations; - int specificity = 0; - int order = 0; - }; - - void paintElement (Graphics& g, const Element& element, bool hasParentFillEnabled, bool hasParentStrokeEnabled, Color currentColor, int recursionDepth = 0); - void paintDebugElement (Graphics& g, const Element& element); - bool parseElement (const XmlElement& element, bool parentIsRoot, AffineTransform currentTransform, Element* parent = nullptr); - void parseStyle (const XmlElement& element, const AffineTransform& currentTransform, Element& e); - AffineTransform parseTransform (const XmlElement& element, const AffineTransform& currentTransform, Element& e); - void parseGradient (const XmlElement& element); - Gradient::Ptr getGradientById (const String& id); - Gradient::Ptr resolveGradient (Gradient::Ptr gradient); - ColorGradient createColorGradientFromSVG (const Gradient& gradient, const Rectangle* objectBounds = nullptr); - void parseFilter (const XmlElement& element); - Filter::Ptr getFilterById (const String& id); - Filter::Ptr resolveFilter (Filter::Ptr filter); - void parseClipPath (const XmlElement& element); - ClipPath::Ptr getClipPathById (const String& id); - void parseCSSStyle (const String& styleString, Element& e); - void applyStyleProperty (StringRef property, StringRef value, Element& e); - void applyStylesheetRules (const XmlElement& xmlElement, Element& e); - void parseStyleElement (const XmlElement& element); - float parseUnit (const String& value, float defaultValue = 0.0f, float fontSize = 12.0f, float viewportSize = 100.0f); - float parseLengthAttribute (const XmlElement& element, StringRef attributeName, float defaultValue, float fontSize, float viewportSize); - Array parseLengthList (const String& value, float fontSize, float viewportSize); - AffineTransform parseTransform (const String& transformString); - String extractGradientUrl (const String& value); - String extractUrlId (const String& value); - bool parseDocument (std::unique_ptr svgRoot); - bool matchesCssSelector (const XmlElement& xmlElement, const CssRule& rule) const; - std::optional loadImageFromHref (const String& href) const; - Font resolveFont (const Element& element) const; + void paintElement (Graphics& g, const SVGData& data, const SVGElement& element, bool hasParentFillEnabled, bool hasParentStrokeEnabled, Color currentColor, int recursionDepth = 0); + void paintDebugElement (Graphics& g, const SVGElement& element); + void renderTextElement (Graphics& g, const SVGElement& element); + void renderImageElement (Graphics& g, const SVGElement& element); Path createDashedPath (const Path& source, const Array& dashArray, float dashOffset) const; - void renderTextElement (Graphics& g, const Element& element); - void renderImageElement (Graphics& g, const Element& element); - - // SVG preserveAspectRatio parsing - Fitting parsePreserveAspectRatio (const String& preserveAspectRatio); - Justification parseAspectRatioAlignment (const String& preserveAspectRatio); - - // Helper methods for layout and painting - Rectangle calculateBounds() const; - AffineTransform calculateTransformForTarget (const Rectangle& sourceBounds, const Rectangle& targetArea, Fitting fitting, Justification justification) const; - - Rectangle viewBox; - Size size; - Rectangle bounds; - AffineTransform transform; - std::vector elements; - HashMap elementsById; - std::vector gradients; - HashMap gradientsById; - std::vector filters; - HashMap filtersById; - std::vector clipPaths; - HashMap clipPathsById; - std::vector cssRules; - ParseOptions parseOptions; - - // Root SVG element's default presentation attributes - bool rootHasFill = true; // SVG default fill is black - bool rootHasStroke = false; // SVG default stroke is none - std::optional rootFillColor; - std::optional rootStrokeColor; + AffineTransform calculateTransformForTarget (const Rectangle& sourceBounds, + const Rectangle& targetArea, + Fitting fitting, + Justification justification) const; + Font resolveFont (const SVGElement& element) const; + ColorGradient createColorGradientFromSVG (const SVGGradient& gradient, + const Rectangle* objectBounds = nullptr) const; + + SVGDocument::Ptr document; }; } // namespace yup diff --git a/modules/yup_graphics/svg/yup_SVGClipPath.h b/modules/yup_graphics/svg/yup_SVGClipPath.h new file mode 100644 index 000000000..4538a9941 --- /dev/null +++ b/modules/yup_graphics/svg/yup_SVGClipPath.h @@ -0,0 +1,41 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +/** A parsed SVG clipPath element. */ +struct SVGClipPath : public ReferenceCountedObject +{ + using Ptr = ReferenceCountedObjectPtr; + + enum Units + { + UserSpaceOnUse, + ObjectBoundingBox + }; + + String id; + Units units = UserSpaceOnUse; + std::vector elements; +}; + +} // namespace yup diff --git a/modules/yup_graphics/svg/yup_SVGCssParser.cpp b/modules/yup_graphics/svg/yup_SVGCssParser.cpp new file mode 100644 index 000000000..31128bd57 --- /dev/null +++ b/modules/yup_graphics/svg/yup_SVGCssParser.cpp @@ -0,0 +1,414 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== + +SVGCssParser::SVGCssParser (SVGData& dataRef) + : data (dataRef) +{ +} + +//============================================================================== + +void SVGCssParser::parseCSSStyle (const String& styleString, SVGElement& e) +{ + YUP_DRAWABLE_LOG ("parseCSSStyle - tag: " << e.tagName + << " id: " << e.id.value_or (String ("none")) + << " style: " << styleString); + + auto declarations = StringArray::fromTokens (styleString, ";", ""); + + for (const auto& declaration : declarations) + { + auto colonPos = declaration.indexOf (":"); + if (colonPos > 0) + { + String property = declaration.substring (0, colonPos).trim(); + String value = declaration.substring (colonPos + 1).trim(); + + YUP_DRAWABLE_LOG ("Applying inline CSS declaration - property: " << property << " value: " << value); + applyStyleProperty (property, value, e); + } + } +} + +//============================================================================== + +void SVGCssParser::applyStyleProperty (StringRef propertyRef, StringRef valueRef, SVGElement& e) +{ + String property (propertyRef.text); + String value (valueRef.text); + + property = property.trim().toLowerCase(); + value = value.trim(); + + YUP_DRAWABLE_LOG ("applyStyleProperty - tag: " << e.tagName + << " id: " << e.id.value_or (String ("none")) + << " property: " << property + << " value: " << value); + + if (property == "fill") + { + e.fillCurrentColor = false; + e.fillUrl.reset(); + e.fillColor.reset(); + e.noFill = false; + + if (value == "none") + e.noFill = true; + else if (value == "currentColor") + e.fillCurrentColor = true; + else if (auto url = SVGParser::extractUrlId (value); url.isNotEmpty()) + e.fillUrl = url; + else if (value.isNotEmpty()) + e.fillColor = Color::fromString (value); + } + else if (property == "stroke") + { + e.strokeCurrentColor = false; + e.strokeUrl.reset(); + e.strokeColor.reset(); + e.noStroke = false; + + if (value == "none") + e.noStroke = true; + else if (value == "currentColor") + e.strokeCurrentColor = true; + else if (auto url = SVGParser::extractUrlId (value); url.isNotEmpty()) + e.strokeUrl = url; + else if (value.isNotEmpty()) + e.strokeColor = Color::fromString (value); + } + else if (property == "color") + { + if (value != "currentColor" && value != "inherit") + e.color = Color::fromString (value); + } + else if (property == "stroke-width") + { + float strokeWidth = SVGParser::parseUnit (value, e.strokeWidth.value_or (1.0f), e.fontSize.value_or (12.0f)); + if (strokeWidth >= 0.0f) + e.strokeWidth = strokeWidth; + } + else if (property == "stroke-linejoin") + { + if (value == "round") + e.strokeJoin = StrokeJoin::Round; + else if (value == "miter") + e.strokeJoin = StrokeJoin::Miter; + else if (value == "bevel") + e.strokeJoin = StrokeJoin::Bevel; + } + else if (property == "stroke-linecap") + { + if (value == "round") + e.strokeCap = StrokeCap::Round; + else if (value == "square") + e.strokeCap = StrokeCap::Square; + else if (value == "butt") + e.strokeCap = StrokeCap::Butt; + } + else if (property == "opacity") + { + float opacity = value.getFloatValue(); + if (opacity >= 0.0f && opacity <= 1.0f) + e.opacity = opacity; + } + else if (property == "display") + { + if (value == "none") + e.hidden = true; + } + else if (property == "visibility") + { + e.hidden = value == "hidden" || value == "collapse"; + } + else if (property == "font-family") + { + e.fontFamily = value.unquoted(); + } + else if (property == "font-size") + { + float fontSize = SVGParser::parseUnit (value, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); + if (fontSize > 0.0f) + e.fontSize = fontSize; + } + else if (property == "text-anchor") + { + e.textAnchor = value; + } + else if (property == "letter-spacing") + { + if (value != "normal") + e.letterSpacing = SVGParser::parseUnit (value, 0.0f, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); + } + else if (property == "word-spacing") + { + if (value != "normal") + e.wordSpacing = SVGParser::parseUnit (value, 0.0f, e.fontSize.value_or (12.0f), e.fontSize.value_or (12.0f)); + } + else if (property == "font-weight") + { + YUP_DRAWABLE_LOG ("CSS font-weight currently not applied - value: " << value); + } + else if (property == "font-style") + { + YUP_DRAWABLE_LOG ("CSS font-style currently not applied - value: " << value); + } + else if (property == "font-variant") + { + YUP_DRAWABLE_LOG ("CSS font-variant currently not applied - value: " << value); + } + else if (property == "font-stretch") + { + YUP_DRAWABLE_LOG ("CSS font-stretch currently not applied - value: " << value); + } + else if (property == "font") + { + YUP_DRAWABLE_LOG ("CSS font shorthand currently not parsed - value: " << value); + } + else if (property == "dominant-baseline") + { + YUP_DRAWABLE_LOG ("CSS dominant-baseline currently not applied - value: " << value); + } + else if (property == "alignment-baseline") + { + YUP_DRAWABLE_LOG ("CSS alignment-baseline currently not applied - value: " << value); + } + else if (property == "baseline-shift") + { + YUP_DRAWABLE_LOG ("CSS baseline-shift currently not applied - value: " << value); + } + else if (property == "clip-path") + { + String clipPathUrl = SVGParser::extractUrlId (value); + if (clipPathUrl.isNotEmpty()) + e.clipPathUrl = clipPathUrl; + } + else if (property == "filter") + { + if (value == "none") + e.filterUrl.reset(); + else if (auto filterUrl = SVGParser::extractUrlId (value); filterUrl.isNotEmpty()) + e.filterUrl = filterUrl; + else + YUP_DRAWABLE_LOG ("CSS filter currently only supports url(...) - value: " << value); + } + else if (property == "stroke-dasharray") + { + if (value == "none") + e.strokeDashArray.reset(); + else + { + auto dashes = SVGParser::parseLengthList (value, e.fontSize.value_or (12.0f), 100.0f); + if (! dashes.isEmpty()) + e.strokeDashArray = dashes; + } + } + else if (property == "stroke-dashoffset") + { + e.strokeDashOffset = SVGParser::parseUnit (value); + } + else if (property == "fill-opacity") + { + float opacity = value.getFloatValue(); + if (opacity >= 0.0f && opacity <= 1.0f) + e.fillOpacity = opacity; + } + else if (property == "stroke-opacity") + { + float opacity = value.getFloatValue(); + if (opacity >= 0.0f && opacity <= 1.0f) + e.strokeOpacity = opacity; + } + else if (property == "fill-rule") + { + if (value == "evenodd" || value == "nonzero") + e.fillRule = value; + } + else if (property == "clip-rule") + { + if (value == "evenodd" || value == "nonzero") + e.clipRule = value; + } + else + { + YUP_DRAWABLE_LOG ("Unsupported CSS property ignored - property: " << property << " value: " << value); + } +} + +//============================================================================== + +void SVGCssParser::applyStylesheetRules (const XmlElement& xmlElement, SVGElement& e) +{ + std::vector matchedRules; + + for (const auto& rule : data.cssRules) + { + if (matchesCssSelector (xmlElement, rule)) + matchedRules.push_back (std::addressof (rule)); + } + + std::stable_sort (matchedRules.begin(), matchedRules.end(), [] (const SVGCssRule* a, const SVGCssRule* b) + { + if (a->specificity != b->specificity) + return a->specificity < b->specificity; + + return a->order < b->order; + }); + + if (! matchedRules.empty()) + { + YUP_DRAWABLE_LOG ("applyStylesheetRules - tag: " << e.tagName + << " id: " << e.id.value_or (String ("none")) + << " matchedRules: " << matchedRules.size()); + } + + for (const auto* rule : matchedRules) + { + YUP_DRAWABLE_LOG ("Applying stylesheet rule - selector: " << rule->selector + << " specificity: " << rule->specificity + << " order: " << rule->order + << " declarationCount: " << rule->declarations.size()); + + for (const auto& declaration : rule->declarations) + { + auto colonPos = declaration.indexOf (":"); + if (colonPos > 0) + applyStyleProperty (declaration.substring (0, colonPos).trim(), declaration.substring (colonPos + 1).trim(), e); + } + } +} + +//============================================================================== + +void SVGCssParser::parseStyleElement (const XmlElement& element) +{ + auto css = element.getAllSubText(); + int ruleOrder = static_cast (data.cssRules.size()); + + YUP_DRAWABLE_LOG ("parseStyleElement - cssLength: " << css.length() << " existingRules: " << data.cssRules.size()); + + while (css.isNotEmpty()) + { + auto openBrace = css.indexOf ("{"); + auto closeBrace = css.indexOf ("}"); + if (openBrace <= 0 || closeBrace <= openBrace) + break; + + auto selectorText = css.substring (0, openBrace).trim(); + auto declarationText = css.substring (openBrace + 1, closeBrace).trim(); + + css = css.substring (closeBrace + 1); + + auto selectors = StringArray::fromTokens (selectorText, ",", ""); + auto declarations = StringArray::fromTokens (declarationText, ";", ""); + + for (auto selector : selectors) + { + selector = selector.trim(); + if (selector.isEmpty()) + continue; + + SVGCssRule rule; + rule.selector = selector; + rule.declarations = declarations; + rule.order = ruleOrder++; + + if (selector.startsWithChar ('#')) + rule.specificity = 100; + else if (selector.startsWithChar ('.')) + rule.specificity = 10; + else if (selector.containsChar ('#')) + rule.specificity = 101; + else if (selector.containsChar ('.')) + rule.specificity = 11; + else + rule.specificity = 1; + + YUP_DRAWABLE_LOG ("Parsed CSS rule - selector: " << rule.selector + << " specificity: " << rule.specificity + << " declarationCount: " << rule.declarations.size() + << " order: " << rule.order); + data.cssRules.push_back (std::move (rule)); + } + } + + YUP_DRAWABLE_LOG ("parseStyleElement result - totalRules: " << data.cssRules.size()); +} + +//============================================================================== + +bool SVGCssParser::matchesCssSelector (const XmlElement& xmlElement, const SVGCssRule& rule) const +{ + auto selector = rule.selector.trim(); + if (selector.isEmpty() || selector.containsChar (' ') || selector.containsChar ('>') || selector.containsChar ('+')) + return false; + + String tagName; + String id; + String className; + + auto hashIndex = selector.indexOf ("#"); + auto dotIndex = selector.indexOf ("."); + auto splitIndex = -1; + + if (hashIndex >= 0 && dotIndex >= 0) + splitIndex = jmin (hashIndex, dotIndex); + else + splitIndex = jmax (hashIndex, dotIndex); + + if (splitIndex > 0) + tagName = selector.substring (0, splitIndex); + + if (hashIndex == 0) + id = selector.substring (1); + else if (hashIndex > 0) + id = selector.substring (hashIndex + 1, dotIndex > hashIndex ? dotIndex : selector.length()); + + if (dotIndex == 0) + className = selector.substring (1); + else if (dotIndex > 0) + className = selector.substring (dotIndex + 1); + + if (splitIndex < 0 && ! selector.startsWithChar ('#') && ! selector.startsWithChar ('.')) + tagName = selector; + + if (tagName.isNotEmpty() && tagName != xmlElement.getTagNameWithoutNamespace()) + return false; + + if (id.isNotEmpty() && id != xmlElement.getStringAttribute ("id")) + return false; + + if (className.isNotEmpty()) + { + auto classes = StringArray::fromTokens (xmlElement.getStringAttribute ("class"), " \t\r\n", ""); + if (! classes.contains (className)) + return false; + } + + return tagName.isNotEmpty() || id.isNotEmpty() || className.isNotEmpty(); +} + +} // namespace yup diff --git a/modules/yup_graphics/svg/yup_SVGCssParser.h b/modules/yup_graphics/svg/yup_SVGCssParser.h new file mode 100644 index 000000000..4f305159d --- /dev/null +++ b/modules/yup_graphics/svg/yup_SVGCssParser.h @@ -0,0 +1,45 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +/** Handles all CSS / style-related parsing for an SVG document. + + Constructed with a reference to the mutable SVGData being built. + The SVGParser drives this class during a parse pass. +*/ +class SVGCssParser +{ +public: + explicit SVGCssParser (SVGData& data); + + void parseCSSStyle (const String& styleString, SVGElement& e); + void applyStyleProperty (StringRef property, StringRef value, SVGElement& e); + void applyStylesheetRules (const XmlElement& xmlElement, SVGElement& e); + void parseStyleElement (const XmlElement& element); + bool matchesCssSelector (const XmlElement& xmlElement, const SVGCssRule& rule) const; + +private: + SVGData& data; +}; + +} // namespace yup diff --git a/modules/yup_graphics/svg/yup_SVGCssRule.h b/modules/yup_graphics/svg/yup_SVGCssRule.h new file mode 100644 index 000000000..e17ed5acc --- /dev/null +++ b/modules/yup_graphics/svg/yup_SVGCssRule.h @@ -0,0 +1,34 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +/** A single parsed CSS rule from an SVG " + ""); + ASSERT_NE (nullptr, doc); + + doc->visit ([] (const SVGData& data) + { + ASSERT_EQ (1u, data.cssRules.size()); + const auto& rule = data.cssRules[0]; + EXPECT_EQ (String ("rect"), rule.selector); + EXPECT_EQ (1, rule.specificity); + EXPECT_EQ (2, rule.declarations.size()); + }); +} + +TEST (SVGDocumentTests, CSSIdRuleHasSpecificity100) +{ + auto doc = parse ("" + "" + ""); + ASSERT_NE (nullptr, doc); + + doc->visit ([] (const SVGData& data) + { + ASSERT_EQ (1u, data.cssRules.size()); + EXPECT_EQ (100, data.cssRules[0].specificity); + }); +} + +TEST (SVGDocumentTests, CSSClassRuleHasSpecificity10) +{ + auto doc = parse ("" + "" + ""); + ASSERT_NE (nullptr, doc); + + doc->visit ([] (const SVGData& data) + { + ASSERT_EQ (1u, data.cssRules.size()); + EXPECT_EQ (10, data.cssRules[0].specificity); + }); +} + +TEST (SVGDocumentTests, CSSMultipleRulesPreserveOrder) +{ + auto doc = parse ("" + "" + ""); + ASSERT_NE (nullptr, doc); + + doc->visit ([] (const SVGData& data) + { + ASSERT_EQ (3u, data.cssRules.size()); + EXPECT_EQ (String ("rect"), data.cssRules[0].selector); + EXPECT_EQ (String ("circle"), data.cssRules[1].selector); + EXPECT_EQ (String (".x"), data.cssRules[2].selector); + EXPECT_EQ (0, data.cssRules[0].order); + EXPECT_EQ (1, data.cssRules[1].order); + EXPECT_EQ (2, data.cssRules[2].order); + }); +} + +TEST (SVGDocumentTests, CSSRulesFromMultipleStyleElements) +{ + auto doc = parse ("" + "" + "" + "" + "" + "" + ""); + ASSERT_NE (nullptr, doc); + + doc->visit ([] (const SVGData& data) + { + EXPECT_EQ (2u, data.cssRules.size()); + }); +} + +TEST (SVGDocumentTests, CSSRuleDeclarationsContainPropertyColon) +{ + auto doc = parse ("" + "" + ""); + ASSERT_NE (nullptr, doc); + + doc->visit ([] (const SVGData& data) + { + ASSERT_EQ (1u, data.cssRules.size()); + const auto& rule = data.cssRules[0]; + EXPECT_EQ (3, rule.declarations.size()); + // Each declaration contains a colon + for (int i = 0; i < rule.declarations.size(); ++i) + EXPECT_TRUE (rule.declarations[i].contains (":")) << rule.declarations[i]; + }); +} + +// ============================================================================== +// SVGElement class names +// ============================================================================== + +TEST (SVGDocumentTests, ClassAttributeParsedIntoArray) +{ + auto doc = parse (""); + ASSERT_NE (nullptr, doc); + + doc->visit ([] (const SVGData& data) + { + ASSERT_EQ (1u, data.elements.size()); + const auto& elem = *data.elements[0]; + EXPECT_EQ (3, elem.classNames.size()); + EXPECT_TRUE (elem.classNames.contains ("foo")); + EXPECT_TRUE (elem.classNames.contains ("bar")); + EXPECT_TRUE (elem.classNames.contains ("baz")); + }); +} + +TEST (SVGDocumentTests, SingleClassParsed) +{ + auto doc = parse (""); + ASSERT_NE (nullptr, doc); + + doc->visit ([] (const SVGData& data) + { + ASSERT_EQ (1u, data.elements.size()); + EXPECT_EQ (1, data.elements[0]->classNames.size()); + EXPECT_TRUE (data.elements[0]->classNames.contains ("highlight")); + }); +} + +TEST (SVGDocumentTests, NoClassAttributeProducesEmptyArray) +{ + auto doc = parse (""); + ASSERT_NE (nullptr, doc); + + doc->visit ([] (const SVGData& data) + { + ASSERT_EQ (1u, data.elements.size()); + EXPECT_EQ (0, data.elements[0]->classNames.size()); + }); +} + +// ============================================================================== +// SVGDocument::clear +// ============================================================================== + +TEST (SVGDocumentTests, ClearResetsViewBoxToEmpty) +{ + auto doc = parse (""); + ASSERT_NE (nullptr, doc); + + doc->clear(); + + EXPECT_TRUE (doc->getBounds().isEmpty()); + doc->visit ([] (const SVGData& data) + { + EXPECT_TRUE (data.viewBox.isEmpty()); + EXPECT_EQ (0u, data.elements.size()); + EXPECT_EQ (0u, data.gradients.size()); + EXPECT_EQ (0u, data.filters.size()); + EXPECT_EQ (0u, data.clipPaths.size()); + EXPECT_EQ (0u, data.masks.size()); + EXPECT_EQ (0u, data.markers.size()); + EXPECT_EQ (0u, data.patterns.size()); + EXPECT_EQ (0u, data.cssRules.size()); + }); +} diff --git a/tests/yup_graphics/yup_SVGParser.cpp b/tests/yup_graphics/yup_SVGParser.cpp new file mode 100644 index 000000000..93cc54861 --- /dev/null +++ b/tests/yup_graphics/yup_SVGParser.cpp @@ -0,0 +1,2222 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +namespace +{ + +auto makeHeadlessGraphics (int w = 64, int h = 64) +{ + struct HeadlessGfx + { + GraphicsContext::Ptr ctx; + std::unique_ptr renderer; + std::unique_ptr graphics; + }; + + HeadlessGfx g; + g.ctx = GraphicsContext::createContext (GraphicsContext::Headless, {}); + g.renderer = g.ctx->makeRenderer (w, h); + g.graphics = std::make_unique (*g.ctx, *g.renderer); + return g; +} + +} // namespace + +// ============================================================================== +// Root SVG element parsing +// ============================================================================== + +TEST (SVGParserTests, ParseMinimalSVGTag) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseSVGWithXmlDeclaration) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); + EXPECT_FLOAT_EQ (10.0f, d.getBounds().getWidth()); + EXPECT_FLOAT_EQ (10.0f, d.getBounds().getHeight()); +} + +TEST (SVGParserTests, ParseSVGWithNamespace) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); + EXPECT_FLOAT_EQ (20.0f, d.getBounds().getWidth()); + EXPECT_FLOAT_EQ (30.0f, d.getBounds().getHeight()); +} + +TEST (SVGParserTests, ParseSVGWithXlinkNamespace) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseSVGWithWidthAndHeight) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); + EXPECT_FLOAT_EQ (120.0f, d.getBounds().getWidth()); + EXPECT_FLOAT_EQ (80.0f, d.getBounds().getHeight()); +} + +TEST (SVGParserTests, ParseSVGViewBoxTakesPrecedenceOverWidthHeight) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); + EXPECT_FLOAT_EQ (50.0f, d.getBounds().getWidth()); + EXPECT_FLOAT_EQ (25.0f, d.getBounds().getHeight()); +} + +TEST (SVGParserTests, ParseSVGWithViewBoxNonZeroOrigin) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); + EXPECT_FLOAT_EQ (100.0f, d.getBounds().getWidth()); + EXPECT_FLOAT_EQ (80.0f, d.getBounds().getHeight()); +} + +TEST (SVGParserTests, ParseSVGReturnsFalseForNonSVGRoot) +{ + Drawable d; + EXPECT_FALSE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseSVGReturnsFalseForInvalidXML) +{ + Drawable d; + EXPECT_FALSE (d.parseSVG ("not xml at all")); +} + +TEST (SVGParserTests, ParseSVGReturnsFalseForEmptyString) +{ + Drawable d; + EXPECT_FALSE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseSVGReturnsFalseForPartialXML) +{ + Drawable d; + EXPECT_FALSE (d.parseSVG ("")); +} + +// ============================================================================== +// Path element — command coverage +// ============================================================================== + +TEST (SVGParserTests, ParsePathWithMoveToLineTo) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithRelativeMoveToLineTo) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithHorizontalAndVerticalLines) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithRelativeHorizontalVertical) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithCubicBezier) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithRelativeCubicBezier) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithSmoothCubicBezier) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithRelativeSmoothCubicBezier) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithQuadraticBezier) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithRelativeQuadraticBezier) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithSmoothQuadraticBezier) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithArcAbsolute) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithArcRelative) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithArcSweepFlags) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParsePathWithClosePath) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithLowercaseClose) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithMultipleSubpaths) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithImplicitLineTo) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithCompactNotation) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathReturnsFalseForInvalidStartCommand) +{ + Drawable d; + EXPECT_FALSE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathReturnsTrueForEmptyD) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithEvenOddFillRule) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePathWithNonZeroFillRule) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +// ============================================================================== +// Basic shapes +// ============================================================================== + +TEST (SVGParserTests, ParseRectBasic) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseRectWithRoundedCorners) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseRectWithOnlyRx) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseRectWithOnlyRy) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseRectWithZeroDimensions) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseCircleBasic) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseCircleAtOrigin) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseCircleWithZeroRadius) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseEllipseBasic) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseEllipseAtOrigin) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseLineBasic) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseLineHorizontal) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseLineVertical) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseLineSamePoint) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePolygonTriangle) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePolygonSquare) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePolygonWithCommasAndSpaces) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePolygonWithEmptyPoints) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePolylineBasic) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePolylineOpenPath) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +// ============================================================================== +// Groups and structural elements +// ============================================================================== + +TEST (SVGParserTests, ParseGroupEmpty) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseGroupWithSingleChild) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseGroupWithMultipleChildren) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseDeepNestedGroups) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("" + "" + "")); +} + +TEST (SVGParserTests, ParseGroupWithId) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseGroupWithClass) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseDefsEmpty) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseDefsWithMultipleDefinitions) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseSymbolHiddenByDefault) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseUseWithHref) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseUseWithXlinkHref) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseUseWithMissingReference) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "")); +} + +TEST (SVGParserTests, ParseUseWithViewportSize) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +// ============================================================================== +// Text elements +// ============================================================================== + +TEST (SVGParserTests, ParseTextBasic) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("Hello World")); +} + +TEST (SVGParserTests, ParseTextWithFontAttributes) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "Bold Text" + "")); +} + +TEST (SVGParserTests, ParseTextWithTextAnchor) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "Centered" + "Left" + "Right" + "")); +} + +TEST (SVGParserTests, ParseTextWithTspanChildren) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "Line one" + "Line two" + "Line three" + "" + "")); +} + +TEST (SVGParserTests, ParseTspanWithDxDy) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "before" + "lifted" + "after" + "" + "")); +} + +TEST (SVGParserTests, ParseTextWithMultiplePositions) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "A B C D" + "")); +} + +TEST (SVGParserTests, ParseTextWithTransform) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "Rotated" + "")); +} + +TEST (SVGParserTests, ParseTextEmpty) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +// ============================================================================== +// Image elements +// ============================================================================== + +TEST (SVGParserTests, ParseImageWithDataUri) +{ + Drawable d; + // 1x1 red PNG as data URI + EXPECT_TRUE (d.parseSVG ( + "" + "" + "")); +} + +TEST (SVGParserTests, ParseImageWithCustomResolver) +{ + Drawable d; + + Drawable::ParseOptions opts; + opts.imageResolver = [] (StringRef href, const File&) -> std::optional + { + if (String (href.text) == "test-icon") + { + Image img (4, 4, PixelFormat::RGBA); + img.fill (0xff0000ff); + return img; + } + return std::nullopt; + }; + + EXPECT_TRUE (d.parseSVG ( + "" + "" + "", + opts)); +} + +TEST (SVGParserTests, ParseImageWithXlinkHref) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "")); +} + +TEST (SVGParserTests, ParseImageWithMissingHref) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +// ============================================================================== +// Gradients +// ============================================================================== + +TEST (SVGParserTests, ParseLinearGradientVertical) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseLinearGradientHorizontal) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseLinearGradientUserSpaceOnUse) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseLinearGradientWithGradientTransform) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseLinearGradientHrefInheritance) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseLinearGradientWithStopOpacity) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseRadialGradientBasic) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseRadialGradientWithFocalPoint) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseRadialGradientUserSpaceOnUse) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseGradientSpreadMethodReflect) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseGradientSpreadMethodRepeat) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseGradientUsedOnStroke) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +// ============================================================================== +// ClipPath +// ============================================================================== + +TEST (SVGParserTests, ParseClipPathWithRect) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseClipPathWithCircle) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseClipPathWithPath) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseClipPathWithEvenOddClipRule) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseClipPathNestedUse) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseClipPathWithMissingId) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "")); +} + +// ============================================================================== +// Mask +// ============================================================================== + +TEST (SVGParserTests, ParseMaskWithWhiteFill) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseMaskWithGradientAlpha) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseMaskUserSpaceOnUse) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseMaskObjectBoundingBox) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseMaskAppliedToGroup) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +// ============================================================================== +// Markers +// ============================================================================== + +TEST (SVGParserTests, ParseMarkerOrientAuto) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseMarkerOrientFixed) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseMarkerOrientAutoStartReverse) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseMarkerStrokeWidthUnits) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseMarkerUserSpaceOnUse) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseMarkerWithViewBox) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseMarkerShorthand) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseMarkersOnPolyline) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +// ============================================================================== +// Patterns +// ============================================================================== + +TEST (SVGParserTests, ParsePatternUserSpaceOnUse) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParsePatternObjectBoundingBox) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParsePatternWithViewBox) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParsePatternWithTransform) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParsePatternHrefInheritance) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParsePatternWithComplexContent) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +// ============================================================================== +// Filters +// ============================================================================== + +TEST (SVGParserTests, ParseFilterGaussianBlur) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseFilterWithHref) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseFilterNoneResetsFilter) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "")); +} + +// ============================================================================== +// CSS styling +// ============================================================================== + +TEST (SVGParserTests, ParseCSSTypeSelector) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseCSSIdSelector) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseCSSClassSelector) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseCSSMultipleSelectors) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseCSSInlineStyleOverridesAttribute) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseCSSDisplayNone) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseCSSVisibilityHidden) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseCSSCurrentColor) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseCSSFillOpacityAndStrokeOpacity) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "")); +} + +TEST (SVGParserTests, ParseCSSFontProperties) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "Styled text" + "")); +} + +TEST (SVGParserTests, ParseCSSLetterAndWordSpacing) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "Spaced out text" + "")); +} + +TEST (SVGParserTests, ParseCSSStrokeDasharray) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "")); +} + +TEST (SVGParserTests, ParseCSSStrokeDasharrayNone) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "")); +} + +TEST (SVGParserTests, ParseCSSMixBlendModeAllValues) +{ + static const char* modes[] = { + "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity" + }; + + for (auto* mode : modes) + { + Drawable d; + String svg = "" + "" + ""; + EXPECT_TRUE (d.parseSVG (svg)) << "Failed for blend mode: " << mode; + } +} + +// ============================================================================== +// Stroke and fill properties +// ============================================================================== + +TEST (SVGParserTests, ParseStrokeLineJoinValues) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseStrokeLineCapValues) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseStrokeMiterLimit) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseFillNone) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "")); +} + +TEST (SVGParserTests, ParseStrokeNone) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "")); +} + +TEST (SVGParserTests, ParseColorNamedValues) +{ + static const char* colors[] = { + "red", "green", "blue", "black", "white", "yellow", "cyan", "magenta", "orange", "purple", "gray", "grey", "silver", "maroon", "navy" + }; + + for (auto* color : colors) + { + Drawable d; + String svg = ""; + EXPECT_TRUE (d.parseSVG (svg)) << "Failed for color: " << color; + } +} + +TEST (SVGParserTests, ParseColorHexValues) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseColorRgbFunction) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "")); +} + +// ============================================================================== +// Transforms +// ============================================================================== + +TEST (SVGParserTests, ParseTransformTranslate) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseTransformTranslateSingleValue) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseTransformScale) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseTransformScaleXY) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseTransformRotate) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseTransformRotateAboutPoint) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseTransformSkewX) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseTransformSkewY) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseTransformMatrix) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseTransformMatrixCommas) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseTransformChained) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParseTransformOnGroup) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseTransformOriginAttribute) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "")); +} + +// ============================================================================== +// Units +// ============================================================================== + +TEST (SVGParserTests, ParseDimensionsInPixels) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); + EXPECT_FLOAT_EQ (100.0f, d.getBounds().getWidth()); + EXPECT_FLOAT_EQ (50.0f, d.getBounds().getHeight()); +} + +TEST (SVGParserTests, ParseDimensionsInInches) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); + EXPECT_FLOAT_EQ (192.0f, d.getBounds().getWidth()); + EXPECT_FLOAT_EQ (96.0f, d.getBounds().getHeight()); +} + +TEST (SVGParserTests, ParseStrokeWidthWithUnits) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "")); +} + +TEST (SVGParserTests, ParseFontSizeWithUnits) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "Point size text" + "Em size text" + "")); +} + +// ============================================================================== +// PreserveAspectRatio +// ============================================================================== + +TEST (SVGParserTests, ParsePreserveAspectRatioNone) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePreserveAspectRatioXMidYMid) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePreserveAspectRatioXMinYMin) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePreserveAspectRatioXMaxYMax) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); +} + +TEST (SVGParserTests, ParsePreserveAspectRatioOnSymbol) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "")); +} + +// ============================================================================== +// Nested SVG +// ============================================================================== + +TEST (SVGParserTests, ParseNestedSVG) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseNestedSVGWithPercentageDimensions) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "")); +} + +// ============================================================================== +// Cascading and inheritance +// ============================================================================== + +TEST (SVGParserTests, ParseColorInheritanceFromParent) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseFontInheritanceFromParent) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "Parent font" + "" + "Child font" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseOpacityInheritance) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "")); +} + +// ============================================================================== +// Complex real-world documents +// ============================================================================== + +TEST (SVGParserTests, ParseComplexIconWithMultiplePaths) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseComplexLogoWithGroupsAndGradients) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "LOGO" + "" + "")); +} + +TEST (SVGParserTests, ParseChartWithMultipleElements) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "Q1" + "Q2" + "Q3" + "Q4" + "")); +} + +TEST (SVGParserTests, ParseSVGWithAllBasicShapes) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseArrowheadSVG) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseFlowchartSVG) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "Start" + "" + "" + "Process" + "" + "" + "Decision?" + "")); +} + +TEST (SVGParserTests, ParseHatchPatternSVG) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); +} + +// ============================================================================== +// Render tests (no crash) +// ============================================================================== + +TEST (SVGParserTests, RenderAllShapesToHeadlessContext) +{ + Drawable d; + ASSERT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "")); + + auto g = makeHeadlessGraphics (128, 128); + EXPECT_NO_THROW (d.paint (*g.graphics, { 0.0f, 0.0f, 128.0f, 128.0f })); +} + +TEST (SVGParserTests, RenderGradientsToHeadlessContext) +{ + Drawable d; + ASSERT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "")); + + auto g = makeHeadlessGraphics(); + EXPECT_NO_THROW (d.paint (*g.graphics, { 0.0f, 0.0f, 64.0f, 64.0f })); +} + +TEST (SVGParserTests, RenderClipPathToHeadlessContext) +{ + Drawable d; + ASSERT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "")); + + auto g = makeHeadlessGraphics(); + EXPECT_NO_THROW (d.paint (*g.graphics, { 0.0f, 0.0f, 64.0f, 64.0f })); +} + +TEST (SVGParserTests, RenderMaskToHeadlessContext) +{ + Drawable d; + ASSERT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); + + auto g = makeHeadlessGraphics(); + EXPECT_NO_THROW (d.paint (*g.graphics, { 0.0f, 0.0f, 64.0f, 64.0f })); +} + +TEST (SVGParserTests, RenderMarkersToHeadlessContext) +{ + Drawable d; + ASSERT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); + + auto g = makeHeadlessGraphics(); + EXPECT_NO_THROW (d.paint (*g.graphics, { 0.0f, 0.0f, 64.0f, 64.0f })); +} + +TEST (SVGParserTests, RenderPatternToHeadlessContext) +{ + Drawable d; + ASSERT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "")); + + auto g = makeHeadlessGraphics(); + EXPECT_NO_THROW (d.paint (*g.graphics, { 0.0f, 0.0f, 64.0f, 64.0f })); +} + +TEST (SVGParserTests, RenderTextToHeadlessContext) +{ + Drawable d; + ASSERT_TRUE (d.parseSVG ( + "" + "Hello, World!" + "Bold Red" + "")); + + auto g = makeHeadlessGraphics (128, 64); + EXPECT_NO_THROW (d.paint (*g.graphics, { 0.0f, 0.0f, 128.0f, 64.0f })); +} + +TEST (SVGParserTests, RenderStrokeDashToHeadlessContext) +{ + Drawable d; + ASSERT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "")); + + auto g = makeHeadlessGraphics(); + EXPECT_NO_THROW (d.paint (*g.graphics, { 0.0f, 0.0f, 64.0f, 64.0f })); +} + +TEST (SVGParserTests, RenderUseReferenceToHeadlessContext) +{ + Drawable d; + ASSERT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "")); + + auto g = makeHeadlessGraphics(); + EXPECT_NO_THROW (d.paint (*g.graphics, { 0.0f, 0.0f, 64.0f, 64.0f })); +} + +TEST (SVGParserTests, RenderBlendModesToHeadlessContext) +{ + static const char* modes[] = { "multiply", "screen", "overlay", "difference" }; + + for (auto* mode : modes) + { + Drawable d; + String svg = "" + "" + ""; + ASSERT_TRUE (d.parseSVG (svg)) << mode; + + auto g = makeHeadlessGraphics(); + EXPECT_NO_THROW (d.paint (*g.graphics, { 0.0f, 0.0f, 64.0f, 64.0f })) << mode; + } +} + +TEST (SVGParserTests, RenderCyclicUseDoesNotCrash) +{ + Drawable d; + ASSERT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "")); + + auto g = makeHeadlessGraphics(); + EXPECT_NO_THROW (d.paint (*g.graphics, { 0.0f, 0.0f, 64.0f, 64.0f })); +} + +TEST (SVGParserTests, RenderMutuallyCyclicUseDoesNotCrash) +{ + Drawable d; + ASSERT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "" + "" + "")); + + auto g = makeHeadlessGraphics(); + EXPECT_NO_THROW (d.paint (*g.graphics, { 0.0f, 0.0f, 64.0f, 64.0f })); +} + +TEST (SVGParserTests, RenderDeepRecursionInUseDoesNotCrash) +{ + String inner = ""; + String svg = ""; + for (int i = 0; i < 20; ++i) + svg += ""; + svg += ""; + + Drawable d; + ASSERT_TRUE (d.parseSVG (svg)); + + auto g = makeHeadlessGraphics(); + EXPECT_NO_THROW (d.paint (*g.graphics, { 0.0f, 0.0f, 64.0f, 64.0f })); +} + +// ============================================================================== +// Stress / edge cases +// ============================================================================== + +TEST (SVGParserTests, ParseSVGWithNoContent) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ("")); + EXPECT_FLOAT_EQ (100.0f, d.getBounds().getWidth()); + EXPECT_FLOAT_EQ (100.0f, d.getBounds().getHeight()); +} + +TEST (SVGParserTests, ParseSVGWithManyElements) +{ + String svg = ""; + for (int i = 0; i < 100; ++i) + { + float x = float (i % 10) * 100.0f; + float y = float (i / 10) * 100.0f; + svg += ""; + } + svg += ""; + + Drawable d; + EXPECT_TRUE (d.parseSVG (svg)); + + auto g = makeHeadlessGraphics (256, 256); + EXPECT_NO_THROW (d.paint (*g.graphics, { 0.0f, 0.0f, 256.0f, 256.0f })); +} + +TEST (SVGParserTests, ParseSVGWithCommentNodes) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseSVGWithCDATAInStyle) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "" + "")); +} + +TEST (SVGParserTests, ParseAndReparseProducesSameBounds) +{ + const String svg = ""; + + Drawable d; + ASSERT_TRUE (d.parseSVG (svg)); + const auto bounds1 = d.getBounds(); + + ASSERT_TRUE (d.parseSVG (svg)); + const auto bounds2 = d.getBounds(); + + EXPECT_FLOAT_EQ (bounds1.getWidth(), bounds2.getWidth()); + EXPECT_FLOAT_EQ (bounds1.getHeight(), bounds2.getHeight()); +} + +TEST (SVGParserTests, ClearAfterParseThenParseDifferentSVG) +{ + Drawable d; + ASSERT_TRUE (d.parseSVG ("")); + EXPECT_FLOAT_EQ (100.0f, d.getBounds().getWidth()); + + d.clear(); + EXPECT_TRUE (d.getBounds().isEmpty()); + + ASSERT_TRUE (d.parseSVG ("")); + EXPECT_FLOAT_EQ (200.0f, d.getBounds().getWidth()); + EXPECT_FLOAT_EQ (150.0f, d.getBounds().getHeight()); +} + +TEST (SVGParserTests, ParseSVGWithUnknownElements) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "ignored content" + "" + "")); +} + +TEST (SVGParserTests, ParseSVGWithUnknownAttributes) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "")); +} + +TEST (SVGParserTests, ParseSVGWithWhitespaceInAttributes) +{ + Drawable d; + EXPECT_TRUE (d.parseSVG ( + "" + "" + "")); +} From 9d2eedb31a5bed930a690c9713dfd4c1290c697b Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 15:54:40 +0200 Subject: [PATCH 09/18] More tweaks --- modules/yup_core/files/yup_File.h | 2 +- modules/yup_core/files/yup_FileSearchPath.cpp | 2 +- modules/yup_core/network/yup_URL.cpp | 2 +- tests/yup_graphics/yup_Drawable.cpp | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/yup_core/files/yup_File.h b/modules/yup_core/files/yup_File.h index 775be9911..c102e04c8 100644 --- a/modules/yup_core/files/yup_File.h +++ b/modules/yup_core/files/yup_File.h @@ -81,7 +81,7 @@ class YUP_API File final On the Mac/Linux, the path can include "~" notation for referring to user home directories. */ - File (const String& absolutePath); + explicit File (const String& absolutePath); /** Creates a copy of another file object. */ File (const File&); diff --git a/modules/yup_core/files/yup_FileSearchPath.cpp b/modules/yup_core/files/yup_FileSearchPath.cpp index 9426c2be8..0b09c7c3b 100644 --- a/modules/yup_core/files/yup_FileSearchPath.cpp +++ b/modules/yup_core/files/yup_FileSearchPath.cpp @@ -138,7 +138,7 @@ void FileSearchPath::removeRedundantPaths() { const auto checkedIsChildOf = [&] (const auto& a, const auto& b) { - return File::isAbsolutePath (a) && File::isAbsolutePath (b) && File (a).isAChildOf (b); + return File::isAbsolutePath (a) && File::isAbsolutePath (b) && File (a).isAChildOf (File (b)); }; const auto fContainsDirectory = [&] (const auto& f) diff --git a/modules/yup_core/network/yup_URL.cpp b/modules/yup_core/network/yup_URL.cpp index f72dbf3bc..d41e608b0 100644 --- a/modules/yup_core/network/yup_URL.cpp +++ b/modules/yup_core/network/yup_URL.cpp @@ -854,7 +854,7 @@ File URL::fileFromFileSchemeURL (const URL& fileURL) path = path.substring (1); #endif - return path; + return File (path); } int URL::getPort() const diff --git a/tests/yup_graphics/yup_Drawable.cpp b/tests/yup_graphics/yup_Drawable.cpp index 90889e986..9e06dd184 100644 --- a/tests/yup_graphics/yup_Drawable.cpp +++ b/tests/yup_graphics/yup_Drawable.cpp @@ -890,7 +890,7 @@ TEST (DrawableTests, PaintSVGWithScimitarClipPathAndGradientStroke) << "" << ""; - bool result = drawable.parseSVG (svg); + bool result = drawable.parseSVGText (svg); EXPECT_TRUE (result); From 36d960561ffb6d280017f6710a727205d806f647 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 17:14:21 +0200 Subject: [PATCH 10/18] Fix tests --- tests/yup_graphics/yup_Drawable.cpp | 6 ++++-- tests/yup_graphics/yup_SVGParser.cpp | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/yup_graphics/yup_Drawable.cpp b/tests/yup_graphics/yup_Drawable.cpp index 9e06dd184..b42647e78 100644 --- a/tests/yup_graphics/yup_Drawable.cpp +++ b/tests/yup_graphics/yup_Drawable.cpp @@ -934,16 +934,18 @@ TEST (DrawableTests, PaintSVGWithTransformedRadialGradient) // Edge Cases and Error Handling // ============================================================================== -TEST (DrawableTests, ParseSVGWithInvalidPath) +TEST (DrawableTests, ParseSVGWithInvalidPathStillSucceeds) { Drawable drawable; + // Child element failures don't abort the document parse — the bad element is + // silently skipped so the document still reports success. File tempFile = File::getSpecialLocation (File::tempDirectory).getChildFile ("test_invalid_path.svg"); tempFile.replaceWithText (""); bool result = drawable.parseSVG (tempFile); - EXPECT_FALSE (result); + EXPECT_TRUE (result); tempFile.deleteFile(); } diff --git a/tests/yup_graphics/yup_SVGParser.cpp b/tests/yup_graphics/yup_SVGParser.cpp index 93cc54861..4b0338583 100644 --- a/tests/yup_graphics/yup_SVGParser.cpp +++ b/tests/yup_graphics/yup_SVGParser.cpp @@ -249,10 +249,12 @@ TEST (SVGParserTests, ParsePathWithCompactNotation) EXPECT_TRUE (d.parseSVG ("")); } -TEST (SVGParserTests, ParsePathReturnsFalseForInvalidStartCommand) +TEST (SVGParserTests, ParsePathWithInvalidStartCommandStillSucceeds) { Drawable d; - EXPECT_FALSE (d.parseSVG ("")); + // Child element failures don't abort the document parse — the bad path is + // silently skipped so the document still reports success. + EXPECT_TRUE (d.parseSVG ("")); } TEST (SVGParserTests, ParsePathReturnsTrueForEmptyD) From 94d0d15ddb5213fa606d8bb5f704c64f2bc3dba9 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 17:28:37 +0200 Subject: [PATCH 11/18] More support --- examples/graphics/data/svg/pulpitrock.svg | 6 +++ examples/graphics/source/examples/Svg.h | 50 +++++++++++++++++++ .../yup_graphics/drawables/yup_Drawable.cpp | 27 ++++++++-- modules/yup_graphics/svg/yup_SVGParser.cpp | 6 +++ tests/yup_graphics/yup_SVGParser.cpp | 31 ++++++++++++ 5 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 examples/graphics/data/svg/pulpitrock.svg diff --git a/examples/graphics/data/svg/pulpitrock.svg b/examples/graphics/data/svg/pulpitrock.svg new file mode 100644 index 000000000..4a3102021 --- /dev/null +++ b/examples/graphics/data/svg/pulpitrock.svg @@ -0,0 +1,6 @@ + + + + Pulpit Rock, Norway + Sorry, your browser does not support inline SVG. + diff --git a/examples/graphics/source/examples/Svg.h b/examples/graphics/source/examples/Svg.h index 6cac32303..bef85201f 100644 --- a/examples/graphics/source/examples/Svg.h +++ b/examples/graphics/source/examples/Svg.h @@ -99,10 +99,59 @@ class SvgDemo : public yup::Component demoFont = std::move (font); } + std::optional fetchHttpImage (const yup::String& href) + { + if (! href.startsWithIgnoreCase ("http:") && ! href.startsWithIgnoreCase ("https:")) + return std::nullopt; + + if (httpImageCache.contains (href)) + return httpImageCache[href]; + + yup::MemoryBlock imageData; + int statusCode = 0; + + auto streamOptions = yup::URL::InputStreamOptions (yup::URL::ParameterHandling::inAddress) + .withConnectionTimeoutMs (5000) + .withNumRedirectsToFollow (5) + .withStatusCode (&statusCode) + .withExtraHeaders ("User-Agent: YUP SVG Demo\r\nAccept: image/*\r\n"); + + auto stream = yup::URL (href).createInputStream (streamOptions); + if (stream == nullptr) + { + YUP_DBG ("Unable to fetch SVG image href: " << href); + return std::nullopt; + } + + stream->readIntoMemoryBlock (imageData); + + if ((statusCode != 0 && (statusCode < 200 || statusCode >= 300)) || imageData.isEmpty()) + { + YUP_DBG ("Unable to fetch SVG image href: " << href << " status: " << statusCode); + return std::nullopt; + } + + auto imageResult = yup::Image::loadFromData (imageData.asBytes()); + if (imageResult.failed()) + { + YUP_DBG ("Unable to decode SVG image href: " << href << " error: " << imageResult.getErrorMessage()); + return std::nullopt; + } + + auto image = imageResult.getValue(); + httpImageCache.set (href, image); + return image; + } + yup::Drawable::ParseOptions createParseOptions (const yup::File& svgFile) { yup::Drawable::ParseOptions options; options.baseDirectory = svgFile.getParentDirectory(); + options.imageResolver = [this] (yup::StringRef href, const yup::File&) -> std::optional + { + return fetchHttpImage (yup::String (href.text)); + }; + options.fontResolver = [this] (yup::StringRef, float fontSize, int weight, bool italic) -> std::optional { if (demoFont) @@ -129,5 +178,6 @@ class SvgDemo : public yup::Component yup::Array svgFiles; yup::File dataDirectory; std::optional demoFont; + yup::HashMap httpImageCache; int currentSvgFileIndex = 0; }; diff --git a/modules/yup_graphics/drawables/yup_Drawable.cpp b/modules/yup_graphics/drawables/yup_Drawable.cpp index 62684eb78..082926e1a 100644 --- a/modules/yup_graphics/drawables/yup_Drawable.cpp +++ b/modules/yup_graphics/drawables/yup_Drawable.cpp @@ -1054,10 +1054,10 @@ void Drawable::paintDebugElement (Graphics& g, const SVGElement& element) Font Drawable::resolveFont (const SVGElement& element) const { if (document == nullptr) - return Font().withHeight (element.fontSize.value_or (12.0f)); + return Font().withHeight (element.fontSize.value_or (16.0f)); const auto& options = document->getParseOptions(); - const auto fontSize = element.fontSize.value_or (12.0f); + const auto fontSize = element.fontSize.value_or (16.0f); const auto fontWeight = element.fontWeight.value_or (400); const auto fontItalic = element.fontItalic.value_or (false); @@ -1089,7 +1089,7 @@ void Drawable::renderTextElement (Graphics& g, const SVGElement& element) position.setY (position.getY() + element.textDy->getFirst()); const auto font = resolveFont (element); - const auto fontSize = element.fontSize.value_or (12.0f); + const auto fontSize = element.fontSize.value_or (16.0f); StyledText styledText; { @@ -1134,9 +1134,26 @@ void Drawable::renderImageElement (Graphics& g, const SVGElement& element) if (! element.imageBounds) return; + auto drawImage = [this, &g, &element] (const Image& image) + { + if (! image.isValid()) + return; + + const Rectangle imageSourceBounds (0.0f, 0.0f, static_cast (image.getWidth()), static_cast (image.getHeight())); + const auto imageTransform = calculateTransformForTarget (imageSourceBounds, + *element.imageBounds, + element.preserveAspectRatioFitting, + element.preserveAspectRatioJustification); + const auto fittedImageBounds = imageSourceBounds.transformed (imageTransform); + + const auto savedState = g.saveState(); + g.setClipPath (*element.imageBounds); + g.drawImage (image, fittedImageBounds); + }; + if (element.image) { - g.drawImage (*element.image, *element.imageBounds); + drawImage (*element.image); return; } @@ -1145,7 +1162,7 @@ void Drawable::renderImageElement (Graphics& g, const SVGElement& element) if (document != nullptr) { if (auto image = SVGParser::loadImageFromHref (document->getParseOptions(), *element.imageHref)) - g.drawImage (*image, *element.imageBounds); + drawImage (*image); } } } diff --git a/modules/yup_graphics/svg/yup_SVGParser.cpp b/modules/yup_graphics/svg/yup_SVGParser.cpp index 01f542e30..efc352f46 100644 --- a/modules/yup_graphics/svg/yup_SVGParser.cpp +++ b/modules/yup_graphics/svg/yup_SVGParser.cpp @@ -471,6 +471,12 @@ bool SVGParser::parseElement (const XmlElement& element, bool parentIsRoot, Affi e->imageBounds = Rectangle (x, y, width, height); + if (auto preserveAspectRatio = element.getStringAttribute ("preserveAspectRatio"); preserveAspectRatio.isNotEmpty()) + { + e->preserveAspectRatioFitting = parsePreserveAspectRatio (preserveAspectRatio); + e->preserveAspectRatioJustification = parseAspectRatioAlignment (preserveAspectRatio); + } + String href = element.getStringAttribute ("href"); if (href.isEmpty()) href = element.getStringAttribute ("xlink:href"); diff --git a/tests/yup_graphics/yup_SVGParser.cpp b/tests/yup_graphics/yup_SVGParser.cpp index 4b0338583..3d974326b 100644 --- a/tests/yup_graphics/yup_SVGParser.cpp +++ b/tests/yup_graphics/yup_SVGParser.cpp @@ -655,6 +655,37 @@ TEST (SVGParserTests, ParseImageWithMissingHref) EXPECT_TRUE (d.parseSVG ("")); } +TEST (SVGParserTests, ParseImageDefaultsToPreservingAspectRatio) +{ + auto doc = SVGParser::parse (""); + ASSERT_NE (nullptr, doc); + + doc->visit ([] (const SVGData& data) + { + ASSERT_EQ (1u, data.elements.size()); + + const auto& image = *data.elements.front(); + EXPECT_EQ (Fitting::scaleToFit, image.preserveAspectRatioFitting); + EXPECT_TRUE (image.preserveAspectRatioJustification.testFlags (Justification::center)); + }); +} + +TEST (SVGParserTests, ParseImagePreserveAspectRatioAttribute) +{ + auto doc = SVGParser::parse (""); + ASSERT_NE (nullptr, doc); + + doc->visit ([] (const SVGData& data) + { + ASSERT_EQ (1u, data.elements.size()); + + const auto& image = *data.elements.front(); + EXPECT_EQ (Fitting::scaleToFill, image.preserveAspectRatioFitting); + EXPECT_TRUE (image.preserveAspectRatioJustification.testFlags (Justification::right)); + EXPECT_TRUE (image.preserveAspectRatioJustification.testFlags (Justification::top)); + }); +} + // ============================================================================== // Gradients // ============================================================================== From 21304a7be4957e926b89ee5333b2409faee5fb26 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 17:29:52 +0200 Subject: [PATCH 12/18] Fix tests --- modules/yup_core/native/yup_Files_windows.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/yup_core/native/yup_Files_windows.cpp b/modules/yup_core/native/yup_Files_windows.cpp index 621fbf557..aa0c6fefb 100644 --- a/modules/yup_core/native/yup_Files_windows.cpp +++ b/modules/yup_core/native/yup_Files_windows.cpp @@ -687,7 +687,7 @@ void File::findFileSystemRoots (Array& destArray) roots.sort (true); for (int i = 0; i < roots.size(); ++i) - destArray.add (roots[i]); + destArray.add (File (roots[i])); } //============================================================================== From a8505508dbc7d9f0bf855c69610fcd03494df1b4 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 20:42:37 +0200 Subject: [PATCH 13/18] Make File constructor explicit --- modules/yup_core/native/yup_Files_android.cpp | 10 +++---- modules/yup_core/native/yup_Files_apple.mm | 26 ++++++++++++------- modules/yup_core/native/yup_Files_wasm.cpp | 2 +- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/modules/yup_core/native/yup_Files_android.cpp b/modules/yup_core/native/yup_Files_android.cpp index 35e5b35ae..d63ec2e88 100644 --- a/modules/yup_core/native/yup_Files_android.cpp +++ b/modules/yup_core/native/yup_Files_android.cpp @@ -251,12 +251,12 @@ File AndroidContentUriResolver::getLocalFileFromContentUri (const URL& url) if (type == "image") type = "images"; - return getCursorDataColumn (URL ("content://media/external/" + type + "/media"), - "_id=?", - StringArray { mediaId }); + return File (getCursorDataColumn (URL ("content://media/external/" + type + "/media"), + "_id=?", + StringArray { mediaId })); } - return getCursorDataColumn (url); + return File (getCursorDataColumn (url)); } String AndroidContentUriResolver::getFileNameFromContentUri (const URL& url) @@ -745,7 +745,7 @@ static File getAppDataDir (bool dataDir) LocalRef applicationInfo (env->CallObjectMethod (getAppContext().get(), AndroidContext.getApplicationInfo)); LocalRef jString (env->GetObjectField (applicationInfo.get(), dataDir ? AndroidApplicationInfo.dataDir : AndroidApplicationInfo.publicSourceDir)); - return { yupString ((jstring) jString.get()) }; + return File (yupString ((jstring) jString.get())); } File File::getSpecialLocation (const SpecialLocationType type) diff --git a/modules/yup_core/native/yup_Files_apple.mm b/modules/yup_core/native/yup_Files_apple.mm index 613f83ebc..d5206af3d 100644 --- a/modules/yup_core/native/yup_Files_apple.mm +++ b/modules/yup_core/native/yup_Files_apple.mm @@ -163,11 +163,11 @@ bool openDocument(const String& fileName, const String& parameters, const Array< StringArray params; params.addTokens(parameters, true); - NSMutableArray* paramArray = [NSMutableArray array]; + NSMutableArray* paramArray = [NSMutableArray array]; for (int i = 0; i < params.size(); ++i) [paramArray addObject:yupStringToNS(params[i])]; - NSMutableDictionary* envDict = [NSMutableDictionary dictionary]; + NSMutableDictionary* envDict = [NSMutableDictionary dictionary]; if (environment) { for (int i = 0; i < environment->size(); ++i) @@ -202,7 +202,7 @@ bool openDocument(const String& fileName, const String& parameters, const Array< YUP_BEGIN_IGNORE_DEPRECATION_WARNINGS - NSMutableDictionary* dict = [NSMutableDictionary dictionary]; + NSMutableDictionary* dict = [NSMutableDictionary dictionary]; if (params.size()) { @@ -306,6 +306,7 @@ bool openDocument(const String& fileName, const String& parameters, const Array< case userDocumentsDirectory: resultPath = MacFileHelpers::getIOSSystemLocation(NSDocumentDirectory); break; + case userDesktopDirectory: resultPath = MacFileHelpers::getIOSSystemLocation(NSDesktopDirectory); break; @@ -313,7 +314,7 @@ bool openDocument(const String& fileName, const String& parameters, const Array< case tempDirectory: { File tmp(MacFileHelpers::getIOSSystemLocation(NSCachesDirectory)); - tmp = tmp.getChildFile(yup_getExecutableFile().getFileNameWithoutExtension()); + tmp = tmp.getChildFile(File(yup_getExecutableFile().getFileNameWithoutExtension())); tmp.createDirectory(); return tmp.getFullPathName(); } @@ -322,6 +323,7 @@ bool openDocument(const String& fileName, const String& parameters, const Array< case userDocumentsDirectory: resultPath = "~/Documents"; break; + case userDesktopDirectory: resultPath = "~/Desktop"; break; @@ -336,21 +338,27 @@ bool openDocument(const String& fileName, const String& parameters, const Array< case userMusicDirectory: resultPath = "~/Music"; break; + case userMoviesDirectory: resultPath = "~/Movies"; break; + case userPicturesDirectory: resultPath = "~/Pictures"; break; + case userApplicationDataDirectory: resultPath = "~/Library"; break; + case commonApplicationDataDirectory: resultPath = "/Library"; break; + case commonDocumentsDirectory: resultPath = "/Users/Shared"; break; + case globalApplicationsDirectory: resultPath = "/Applications"; break; @@ -481,11 +489,11 @@ bool openDocument(const String& fileName, const String& parameters, const Array< { YUP_AUTORELEASEPOOL { - enumerator = [[NSFileManager defaultManager] enumeratorAtPath:yupStringToNS(directory.getFullPathName())]; - } - } - - ~Pimpl() = default; + enumerator = [[NSFileManager defaultManager] enumeratorAtPath:yupStringToNS(directory.getFullPathName())]; + } + } + + ~Pimpl() = default; bool next(String& filenameFound, bool* const isDir, diff --git a/modules/yup_core/native/yup_Files_wasm.cpp b/modules/yup_core/native/yup_Files_wasm.cpp index afb942314..bec374437 100644 --- a/modules/yup_core/native/yup_Files_wasm.cpp +++ b/modules/yup_core/native/yup_Files_wasm.cpp @@ -135,7 +135,7 @@ File File::getSpecialLocation (const SpecialLocationType type) } */ - return { "/" }; + return File ("/"); } //============================================================================== From c078575fdb1e286cab3ba12931120e603cb34210 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 20:58:08 +0200 Subject: [PATCH 14/18] Fix tests --- modules/yup_core/native/yup_Files_apple.mm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/yup_core/native/yup_Files_apple.mm b/modules/yup_core/native/yup_Files_apple.mm index d5206af3d..cde0d0b85 100644 --- a/modules/yup_core/native/yup_Files_apple.mm +++ b/modules/yup_core/native/yup_Files_apple.mm @@ -314,9 +314,9 @@ bool openDocument(const String& fileName, const String& parameters, const Array< case tempDirectory: { File tmp(MacFileHelpers::getIOSSystemLocation(NSCachesDirectory)); - tmp = tmp.getChildFile(File(yup_getExecutableFile().getFileNameWithoutExtension())); + tmp = tmp.getChildFile(yup_getExecutableFile().getFileNameWithoutExtension()); tmp.createDirectory(); - return tmp.getFullPathName(); + return File(tmp.getFullPathName(); } #else From 46fde0611d80353ca43775836de178dab52bad93 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 21:14:07 +0200 Subject: [PATCH 15/18] Fix typo --- modules/yup_core/native/yup_Files_apple.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/yup_core/native/yup_Files_apple.mm b/modules/yup_core/native/yup_Files_apple.mm index cde0d0b85..d5abe7fc5 100644 --- a/modules/yup_core/native/yup_Files_apple.mm +++ b/modules/yup_core/native/yup_Files_apple.mm @@ -316,7 +316,7 @@ bool openDocument(const String& fileName, const String& parameters, const Array< File tmp(MacFileHelpers::getIOSSystemLocation(NSCachesDirectory)); tmp = tmp.getChildFile(yup_getExecutableFile().getFileNameWithoutExtension()); tmp.createDirectory(); - return File(tmp.getFullPathName(); + return File(tmp.getFullPathName()); } #else From 5af6a2ff178af0e8c2ec2d925f51d9b5b3cf42d2 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 21:18:05 +0200 Subject: [PATCH 16/18] Fix network issues on wasm --- modules/yup_core/native/yup_Network_wasm.cpp | 2 +- modules/yup_core/yup_core.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/yup_core/native/yup_Network_wasm.cpp b/modules/yup_core/native/yup_Network_wasm.cpp index e828a3a4e..2e7e40a2a 100644 --- a/modules/yup_core/native/yup_Network_wasm.cpp +++ b/modules/yup_core/native/yup_Network_wasm.cpp @@ -37,7 +37,7 @@ bool YUP_CALLTYPE Process::openEmailWithAttachments (const String& /* targetEmai } //============================================================================== -#if YUP_EMSCRIPTEN && ! YUP_USE_CURL +#if YUP_EMSCRIPTEN class WebInputStream::Pimpl { public: diff --git a/modules/yup_core/yup_core.cpp b/modules/yup_core/yup_core.cpp index 57fd7f003..d955f37ba 100644 --- a/modules/yup_core/yup_core.cpp +++ b/modules/yup_core/yup_core.cpp @@ -323,11 +323,11 @@ extern char** environ; #include "threads/yup_HighResolutionTimer.cpp" #include "threads/yup_WaitableEvent.cpp" #include "network/yup_URL.cpp" +#include "network/yup_WebInputStream.cpp" +#include "streams/yup_URLInputSource.cpp" #if ! YUP_WASM #include "threads/yup_ChildProcess.cpp" -#include "network/yup_WebInputStream.cpp" -#include "streams/yup_URLInputSource.cpp" #endif //============================================================================== From d6f285b30ec4de7f20523b766e574926f9f33244 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 21:49:54 +0200 Subject: [PATCH 17/18] Fix tests --- .../yup_graphics/drawables/yup_Drawable.cpp | 6 +- modules/yup_graphics/drawables/yup_Drawable.h | 4 +- modules/yup_graphics/svg/yup_SVGCssParser.cpp | 10 +- modules/yup_graphics/svg/yup_SVGParser.cpp | 38 +-- tests/yup_graphics.cpp | 2 + tests/yup_graphics/yup_Drawable.cpp | 34 +-- tests/yup_graphics/yup_SVGDocument.cpp | 262 ++++++++++++++++++ tests/yup_graphics/yup_SVGParser.cpp | 4 +- 8 files changed, 310 insertions(+), 50 deletions(-) diff --git a/modules/yup_graphics/drawables/yup_Drawable.cpp b/modules/yup_graphics/drawables/yup_Drawable.cpp index 082926e1a..d25a5ed1a 100644 --- a/modules/yup_graphics/drawables/yup_Drawable.cpp +++ b/modules/yup_graphics/drawables/yup_Drawable.cpp @@ -158,14 +158,14 @@ bool Drawable::parseSVG (const File& svgFile, const ParseOptions& options) //============================================================================== -bool Drawable::parseSVGText (StringRef svgText) +bool Drawable::parseSVG (StringRef svgText) { YUP_DRAWABLE_LOG ("parseSVG(text) - length: " << String (svgText.text).length()); - return parseSVGText (svgText, ParseOptions()); + return parseSVG (svgText, ParseOptions()); } -bool Drawable::parseSVGText (StringRef svgText, const ParseOptions& options) +bool Drawable::parseSVG (StringRef svgText, const ParseOptions& options) { YUP_DRAWABLE_LOG ("parseSVG(text, options) - length: " << String (svgText.text).length()); diff --git a/modules/yup_graphics/drawables/yup_Drawable.h b/modules/yup_graphics/drawables/yup_Drawable.h index 24387ff73..358993a76 100644 --- a/modules/yup_graphics/drawables/yup_Drawable.h +++ b/modules/yup_graphics/drawables/yup_Drawable.h @@ -57,10 +57,10 @@ class YUP_API Drawable @return True if the SVG text was parsed successfully, false otherwise. */ - bool parseSVGText (StringRef svgText); + bool parseSVG (StringRef svgText); /** Parses SVG text with custom parse options. */ - bool parseSVGText (StringRef svgText, const ParseOptions& options); + bool parseSVG (StringRef svgText, const ParseOptions& options); //============================================================================== /** Clears the drawable. */ diff --git a/modules/yup_graphics/svg/yup_SVGCssParser.cpp b/modules/yup_graphics/svg/yup_SVGCssParser.cpp index ef6d05efe..025d4ad30 100644 --- a/modules/yup_graphics/svg/yup_SVGCssParser.cpp +++ b/modules/yup_graphics/svg/yup_SVGCssParser.cpp @@ -399,7 +399,15 @@ void SVGCssParser::parseStyleElement (const XmlElement& element) css = css.substring (closeBrace + 1); auto selectors = StringArray::fromTokens (selectorText, ",", ""); - auto declarations = StringArray::fromTokens (declarationText, ";", ""); + auto declarationTokens = StringArray::fromTokens (declarationText, ";", ""); + StringArray declarations; + + for (const auto& declaration : declarationTokens) + { + auto trimmedDeclaration = declaration.trim(); + if (trimmedDeclaration.isNotEmpty()) + declarations.add (trimmedDeclaration); + } for (auto selector : selectors) { diff --git a/modules/yup_graphics/svg/yup_SVGParser.cpp b/modules/yup_graphics/svg/yup_SVGParser.cpp index efc352f46..797572576 100644 --- a/modules/yup_graphics/svg/yup_SVGParser.cpp +++ b/modules/yup_graphics/svg/yup_SVGParser.cpp @@ -153,7 +153,7 @@ bool SVGParser::parseDocument (std::unique_ptr svgRoot) bool SVGParser::parseElement (const XmlElement& element, bool parentIsRoot, AffineTransform currentTransform, SVGElement* parent) { SVGElement::Ptr e = new SVGElement; - bool isRootElement = element.hasTagName ("svg"); + bool isDocumentRoot = parent == nullptr && element.hasTagName ("svg"); e->tagName = element.getTagNameWithoutNamespace(); YUP_DRAWABLE_LOG ("parseElement - tag: " << e->tagName @@ -534,29 +534,10 @@ bool SVGParser::parseElement (const XmlElement& element, bool parentIsRoot, Affi e->hidden = true; YUP_DRAWABLE_LOG ("Parsing defs - id: " << element.getStringAttribute ("id", "none")); - - for (auto* child = element.getFirstChildElement(); child != nullptr; child = child->getNextElement()) - { - if (child->hasTagName ("linearGradient") || child->hasTagName ("radialGradient")) - parseGradient (*child); - else if (child->hasTagName ("filter")) - parseFilter (*child); - else if (child->hasTagName ("clipPath")) - parseClipPath (*child); - else if (child->hasTagName ("mask")) - parseMask (*child); - else if (child->hasTagName ("marker")) - parseMarker (*child); - else if (child->hasTagName ("pattern")) - parsePattern (*child); - else if (child->hasTagName ("style")) - cssParser.parseStyleElement (*child); - } } else if (element.hasTagName ("style")) { YUP_DRAWABLE_LOG ("Parsing style element"); - cssParser.parseStyleElement (element); return true; } else @@ -603,9 +584,9 @@ bool SVGParser::parseElement (const XmlElement& element, bool parentIsRoot, Affi else if (child->hasTagName ("pattern")) parsePattern (*child); else if (child->hasTagName ("style")) - cssParser.parseStyleElement (*child); + continue; else - parseElement (*child, isRootElement, currentTransform, e.get()); + parseElement (*child, isDocumentRoot, currentTransform, e.get()); } if (e->tagName == "text" || e->tagName == "tspan") @@ -641,7 +622,7 @@ bool SVGParser::parseElement (const XmlElement& element, bool parentIsRoot, Affi } } - if (isRootElement) + if (isDocumentRoot) { if (e->fillColor) { @@ -1105,7 +1086,10 @@ void SVGParser::parseGradient (const XmlElement& element) return value.getFloatValue(); }; - String href = element.getStringAttribute ("xlink:href"); + String href = element.getStringAttribute ("href"); + if (href.isEmpty()) + href = element.getStringAttribute ("xlink:href"); + if (href.isNotEmpty() && href.startsWith ("#")) { gradient->href = href.substring (1); @@ -1316,7 +1300,11 @@ void SVGParser::parseFilter (const XmlElement& element) SVGFilter::Ptr filter = new SVGFilter; filter->id = id; - if (auto href = element.getStringAttribute ("xlink:href"); href.isNotEmpty() && href.startsWith ("#")) + auto href = element.getStringAttribute ("href"); + if (href.isEmpty()) + href = element.getStringAttribute ("xlink:href"); + + if (href.isNotEmpty() && href.startsWith ("#")) filter->href = href.substring (1); YUP_DRAWABLE_LOG ("parseFilter - id: " << id); diff --git a/tests/yup_graphics.cpp b/tests/yup_graphics.cpp index 8938e56ac..b442256f4 100644 --- a/tests/yup_graphics.cpp +++ b/tests/yup_graphics.cpp @@ -33,3 +33,5 @@ #include "yup_graphics/yup_Size.cpp" #include "yup_graphics/yup_StrokeType.cpp" #include "yup_graphics/yup_StyledText.cpp" +#include "yup_graphics/yup_SVGDocument.cpp" +#include "yup_graphics/yup_SVGParser.cpp" diff --git a/tests/yup_graphics/yup_Drawable.cpp b/tests/yup_graphics/yup_Drawable.cpp index b42647e78..70227b9cf 100644 --- a/tests/yup_graphics/yup_Drawable.cpp +++ b/tests/yup_graphics/yup_Drawable.cpp @@ -160,7 +160,7 @@ TEST (DrawableTests, ParseSVGFromString) { Drawable drawable; - bool result = drawable.parseSVGText (""); + bool result = drawable.parseSVG (""); EXPECT_TRUE (result); EXPECT_EQ (20.0f, drawable.getBounds().getWidth()); @@ -179,7 +179,7 @@ TEST (DrawableTests, ParseSVGFromStringWithCSSCascadeAndCurrentColor) { Drawable drawable; - bool result = drawable.parseSVGText ( + bool result = drawable.parseSVG ( "" "