SVG From the Ground Up — Can you imaging that?
It’s time to put the pieces together and get to rendering some SVG!
In the last couple of installments, we were looking at how to do some very low level scanning and parsing. We got through the basics of XML, and looked at some geometry with parsing of the SVG <path> ‘d’ element. The next step is to decide on how we’re going to represent an entire document in memory so that we can render the whole image. This is a pretty big subject, so we’ll start with some design constraints and goals.
At the end of the day, I want to turn the .svg file into bits on the screen. The blend2d graphics library has a BLContext object, which does all the drawing that I need. It can draw everything from individual lines, to gradient shaded polygons, and bitmaps. SVG has some particular drawing requirements, in terms which elements are drawn first, how they are styled, how they are grouped, etc. One example of this is the usage of Cascading Style Sheets (CSS). What this means is that if I turn on one attribute, such as a fill color for polygons, that attribute will be applied to all subsequent elements in a tree drawn after it, until something changes.
Example:
<svg viewbox='10 10 190 10' xmlns="http://www.w3.org/2000/svg"> <g stroke="red" stroke-width='4'> <line x1='10' y1='10' x2='200' y2='200'/> <line stroke='green' x1='10' y1='100' x2='200' y2='200'/> <line stroke-width='8' stroke='blue' x1='10' y1='200' x2='200' y2='200'/> <rect x='100' y='10' width='50' height='50' /> </g> </svg>
The ‘<g…’ serves as a grouping mechanism. It allows you to set some attributes which will apply to all the elements that are within that group. In this case, I set the stroke (the color used to draw lines) to ‘red’. Until something later changes this, the default will be red lines. I also set the stroke-width (number of pixels used to draw the lines). So, again, unless it is explicitly changed, all subsequent lines in the group will have this width.
The first line drawn, since it does not change the color or the width, uses red, and 4.
The second line drawn, changes the color to ‘green’, but does not change the width.
The third line drawn, changes the color to blue, and changes the width to 8
The rectangle, does not explicitly change the color, so red line drawing, with a width of 4, and a default filll color of black.
Of note, changing the attributes on a single element, such as the green line, does not change that attribute for sibling elements, it only applies to that single element. Only attributes applied at a group level will affect the elements within that group, from above.
This imposes some of our first requirements. We need an object that can contain drawing attributes. In addition, there’s a difference between objects that contain the attributes, such as stroke-width, stroke, fill, etc, and actual geometry, such as line, polygon, path. I will drop SVGObject here, as it is a baseline. If you want to follow along, the code is in the svgtypes.h file.
struct IMapSVGNodes; // forward declaration struct SVGObject : public IDrawable { XmlElement fSourceElement; IMapSVGNodes* fRoot{ nullptr }; std::string fName{}; // The tag name of the element BLVar fVar{}; bool fIsVisible{ false }; BLBox fExtent{}; SVGObject() = delete; SVGObject(const SVGObject& other) :fName(other.fName) {} SVGObject(IMapSVGNodes* root) :fRoot(root) {} virtual ~SVGObject() = default; SVGObject& operator=(const SVGObject& other) { fRoot = other.fRoot; fName = other.fName; BLVar fVar = other.fVar; return *this; } IMapSVGNodes* root() const { return fRoot; } virtual void setRoot(IMapSVGNodes* root) { fRoot = root; } const std::string& name() const { return fName; } void setName(const std::string& name) { fName = name; } const bool visible() const { return fIsVisible; } void setVisible(bool visible) { fIsVisible = visible; } const XmlElement& sourceElement() const { return fSourceElement; } // sub-classes should return something interesting as BLVar // This can be used for styling, so images, colors, patterns, gradients, etc virtual const BLVar& getVariant() { return fVar; } void draw(IRender& ctx) override { ;// draw the object } virtual void loadSelfFromXml(const XmlElement& elem) { ; } virtual void loadFromXmlElement(const svg2b2d::XmlElement& elem) { fSourceElement = elem; // load the common attributes setName(elem.name()); // call to loadselffromxml // so sub-class can do its own loading loadSelfFromXml(elem); } };
As a base object, it contains the bare minimum that is common across all subsequent objects. It also has a couple of extras which have proven to be convenient, if not strictly necessary.
The strictly necessary is the ‘void draw(IRender &ctx)’. Almost all objects, whether they be attributes, or elements, will need to affect the drawing context. So, they all will need to be given a chance to do that. The ‘draw()’ routine is what gives them that chance.
All objects need to be able to construct themselves from the xml element stream, so the convenient ‘load..’ functions sit here. Whether it’s an alement, or an attribute, it has a name, so we set the name as well. Attributes can set their name independently from being loaded from the XmlElement, so this is a bit of specialization, but it’s ok.
There is this bit of an oddity in the forward declaration of ‘struct IMapSVGNodes; // forward declaration’. As we’ll see much later, we need the ability to lookup nodes based on an ID, so we need an interface somewhere that allows us to do that. As every node constructed might need to do this, we need a way to pass this interface down the tree, without copying it, and without causing circular references, so the forward declaration, and use of the ‘root()’ method.
That’s got us started. We now have something of a base object.
Next up, we have SVGVisualProperty
// SVGVisualProperty // This is meant to be the base class for things that are optionally // used to alter the graphics context. // If isSet() is true, then the drawSelf() is called. // sub-classes should override drawSelf() to do the actual drawing // // This is used for things like; Paint, Transform, Miter, etc. // struct SVGVisualProperty : public SVGObject { bool fIsSet{ false }; //SVGVisualProperty() :SVGObject(),fIsSet(false){} SVGVisualProperty(IMapSVGNodes *root):SVGObject(root),fIsSet(false){} SVGVisualProperty(const SVGVisualProperty& other) :SVGObject(other) ,fIsSet(other.fIsSet) {} SVGVisualProperty operator=(const SVGVisualProperty& rhs) { SVGObject::operator=(rhs); fIsSet = rhs.fIsSet; return *this; } void set(const bool value) { fIsSet = value; } bool isSet() const { return fIsSet; } virtual void loadSelfFromChunk(const ByteSpan& chunk) { ; } virtual void loadFromChunk(const ByteSpan& chunk) { loadSelfFromChunk(chunk); } // Apply propert to the context conditionally virtual void drawSelf(IRender& ctx) { ; } void draw(IRender& ctx) override { if (isSet()) drawSelf(ctx); } };
It’s not much, and you might question whether it needs to even exist. Maybe it’s couple of routines can just be merged into the SVGObject itself. That is a simple design changed to contemplate, as the only real attribute introduced here is the ‘isSet()’. This is essentially a way to say ‘the value is null’. If I had nullable types, I’d just use that mechanism. But, it also allows you to turn an attribute on and off programmatically, which might turn out to be useful.
Now we can look at a single attribute, the stroke-width, and see how it goes from an xmlElement attribute, to a property in our tree.
//========================================================= // SVGStrokeWidth //========================================================= struct SVGStrokeWidth : public SVGVisualProperty { double fWidth{ 1.0}; //SVGStrokeWidth() : SVGVisualProperty() {} SVGStrokeWidth(IMapSVGNodes* iMap) : SVGVisualProperty(iMap) {} SVGStrokeWidth(const SVGStrokeWidth& other) :SVGVisualProperty(other) { fWidth = other.fWidth; } SVGStrokeWidth& operator=(const SVGStrokeWidth& rhs) { SVGVisualProperty::operator=(rhs); fWidth = rhs.fWidth; return *this; } void drawSelf(IRender& ctx) { ctx.setStrokeWidth(fWidth); } void loadSelfFromChunk(const ByteSpan& inChunk) { fWidth = toNumber(inChunk); set(true); } static std::shared_ptr<SVGStrokeWidth> createFromChunk(IMapSVGNodes* root, const std::string& name, const ByteSpan& inChunk) { std::shared_ptr<SVGStrokeWidth> sw = std::make_shared<SVGStrokeWidth>(root); // If the chunk is empty, return immediately if (inChunk) sw->loadFromChunk(inChunk); return sw; } static std::shared_ptr<SVGStrokeWidth> createFromXml(IMapSVGNodes* root, const std::string& name, const XmlElement& elem) { return createFromChunk(root, name, elem.getAttribute(name)); } };
It starts from the ‘createFromXml…’. We can look at the main parsing loop later, but there is a point where we’re looking at the attributes of an element, and we’ll run across the ‘stroke-width’, and call this function. The ‘createFromChunk’ is then called, which then calls loadFromChunk after instantiating an object.
There are a couple more design choices being made here. First is the fact that we’re using ‘std::shared_ptr’. This implies heap allocation of memory, and this is the right place to finally make such a decision. We did not want the XML parser itself to do any allocations, but we’re finally at the point where we need to. It’s possible to not even do allocations here, just have the attributes allocated on the objects that use them. But, since attributes can be shared, it’s easier just to bite the bullet now, and use shared_ptr.
In the case of stroke-width, we want to save the width specified (call toNumber()), and when it comes time to apply that width, in the ‘drawSelf()’, we make the rigth call on the drawing context ‘setStrokeWidth()’. Since the same drawing context is used throughout the rendering process, setting an attribute at one point will make that attribute sticky, until something else changes it, which is the behavior that we want.
I would like to describe the ‘stroke’ and ‘fill’ attributes, but they are actually the largest portions of the library. Setting these attributes can occur in so many different ways, it’s worth taking a look at them. Here I will just show a few ways in which they can be used, so you get a feel for how involved they are:
<line stroke="blue" x1='0' y1='0' x2='100' y2='100'/> <line stroke="rgb(0,0,255)" x1='0' y1='0' x2='100' y2='100'/> <line stroke="rgba(0,0,255,1.0)" x1='0' y1='0' x2='100' y2='100'/> <line stroke="rgba(0,0,100%,1.0)" x1='0' y1='0' x2='100' y2='100'/> <line stroke="rgba(0%,0%,100%,100%)" x1='0' y1='0' x2='100' y2='100'/> <line style = "stroke:blue" x1='0' y1='0' x2='100' y2='100'/> <line stroke= "url(#SVGID_1)" x1='0' y1='0' x2='100' y2='100'/>
And more…
There is a bewildering assortment of ways in which you can set a stroke or fill, and they don’t all resolve to a single color value. It can be patterns, gradients, even other graphics. So, it can get pretty intense. The SVGPaint structure does a good job of representing all the possibilities, so take a look at that if you want to want to see the intimate details.
We round out our basic object strucutures by looking at how shapes are represented.
// // SVGVisualObject // This is any object that will change the state of the rendering context // that's everything from paint that needs to be applied, to geometries // that need to be drawn, to line widths, text alignment, and the like. // Most things, other than basic attribute type, will be a sub-class of this struct SVGVisualNode : public SVGObject { std::string fId{}; // The id of the element std::map<std::string, std::shared_ptr<SVGVisualProperty>> fVisualProperties{}; SVGVisualNode() = default; SVGVisualNode(IMapSVGNodes* root) : SVGObject(root) { setRoot(root); } SVGVisualNode(const SVGVisualNode& other) :SVGObject(other) { fId = other.fId; fVisualProperties = other.fVisualProperties; } SVGVisualNode & operator=(const SVGVisualNode& rhs) { fId = rhs.fId; fVisualProperties = rhs.fVisualProperties; return *this; } const std::string& id() const { return fId; } void setId(const std::string& id) { fId = id; } void loadVisualProperties(const XmlElement& elem) { // Run Through the property creation routines, generating // properties for the ones we find in the XmlElement for (auto& propconv : gSVGPropertyCreation) { // get the named attribute auto attrName = propconv.first; // We have a property and value, convert to SVGVisibleProperty // and add it to our map of visual properties auto prop = propconv.second(root(), attrName, elem); if (prop->isSet()) fVisualProperties[attrName] = prop; } } void setCommonVisualProperties(const XmlElement &elem) { // load the common stuff that doesn't require // any additional processing loadVisualProperties(elem); // Handle the style attribute separately by turning // it into a standalone XmlElement, and then loading // that like a normal element, by running through the properties again // It's ok if there were already styles in separate attributes of the // original elem, because anything in the 'style' attribute is supposed // to override whatever was there. auto styleChunk = elem.getAttribute("style"); if (styleChunk) { // Create an XML Element to hang the style properties on as attributes XmlElement styleElement{}; // use CSSInlineIterator to iterate through the key value pairs // creating a visual attribute, using the gSVGPropertyCreation map CSSInlineStyleIterator iter(styleChunk); while (iter.next()) { std::string name = std::string((*iter).first.fStart, (*iter).first.fEnd); if (!name.empty() && (*iter).second) { styleElement.addAttribute(name, (*iter).second); } } loadVisualProperties(styleElement); } // Deal with any more attributes that need special handling } void loadSelfFromXml(const XmlElement& elem) override { SVGObject::loadSelfFromXml(elem); auto id = elem.getAttribute("id"); if (id) setId(std::string(id.fStart, id.fEnd)); setCommonVisualProperties(elem); } // Contains styling attributes void applyAttributes(IRender& ctx) { for (auto& prop : fVisualProperties) { prop.second->draw(ctx); } } virtual void drawSelf(IRender& ctx) { ; } void draw(IRender& ctx) override { ctx.save(); applyAttributes(ctx); drawSelf(ctx); ctx.restore(); } };
We are building up nodes in a tree structure. The SVGVisualNode is essentially the primary node of that construction. At the end of all the tree construction, we want to end up with a root node where we can just call ‘draw(context)’, and have it render itself into the context. That node needs to deal with the Cascading Styles, children drawing in the proper order (painter’s algorithm), deal with all the attributes, and context state.
Of particular note, right there at the end is the ‘draw()’ method. It starts with ‘ctx.save()’ and finishes with ‘ctx.restore()’. This is critical to maintaining the design constraint of ‘attributes are applied locally in the tree’. So, we save the sate of the context coming in, make whatever changes we, or our children will make, then restore the state upon exit. This is the essential operation required to maintain proper application of drawing attributes. Luckily, or rather by design, the blend2d library makes saving and restoring state very fast and efficient. If the base library did not have this facility, it would be up to our code to maintain this state.
Another note here is ‘applyAttributes’. This is what allows things such as the ‘<g>’ element to apply attributes at a high level in the tree, and subsequent elements don’t have to worry about it. They can just apply the attributes that they alter. And where do those common attributes come from?
static std::map<std::string, std::function<std::shared_ptr<SVGVisualProperty> (IMapSVGNodes *root, const std::string& , const XmlElement& )>> gSVGPropertyCreation = { {"fill", [](IMapSVGNodes* root, const std::string& name, const XmlElement& elem) {return SVGPaint::createFromXml(root, "fill", elem ); } } ,{"fill-rule", [](IMapSVGNodes* root, const std::string& name, const XmlElement& elem) {return SVGFillRule::createFromXml(root, "fill-rule", elem); } } ,{"font-size", [](IMapSVGNodes* root, const std::string& name, const XmlElement& elem) {return SVGFontSize::createFromXml(root, "font-size", elem); } } ,{"opacity", [](IMapSVGNodes* root, const std::string& name, const XmlElement& elem) {return SVGOpacity::createFromXml(root, "opacity", elem); } } ,{"stroke", [](IMapSVGNodes* root, const std::string& name, const XmlElement& elem ) {return SVGPaint::createFromXml(root, "stroke", elem); } } ,{"stroke-linejoin", [](IMapSVGNodes* root, const std::string& name, const XmlElement& elem) {return SVGStrokeLineJoin::createFromXml(root, "stroke-linejoin", elem); } } ,{"stroke-linecap", [](IMapSVGNodes* root, const std::string& name, const XmlElement& elem ) {return SVGStrokeLineCap::createFromXml(root, "stroke-linecap", elem); } } ,{"stroke-miterlimit", [](IMapSVGNodes* root, const std::string& name, const XmlElement& elem ) {return SVGStrokeMiterLimit::createFromXml(root, "stroke-miterlimit", elem); } } ,{"stroke-width", [](IMapSVGNodes* root, const std::string& name, const XmlElement& elem ) {return SVGStrokeWidth::createFromXml(root, "stroke-width", elem); } } ,{"text-anchor", [](IMapSVGNodes* root, const std::string& name, const XmlElement& elem) {return SVGTextAnchor::createFromXml(root, "text-anchor", elem); } } ,{"transform", [](IMapSVGNodes* root, const std::string& name, const XmlElement& elem) {return SVGTransform::createFromXml(root, "transform", elem); } } };
A nice dispatch table of the most common of attributes. The ‘loadVisualProperties()’ method uses this dispatch table to load the common display properties. Individual geometry objects can load their own specific properties after this, but these are the common ones, so this is very convenient. This table can and should be expanded as even more properties can be supported.
Finally, let’s get to the meat of the geometry representation. This can be found in the svgshapes.h file.
struct SVGPathBasedShape : public SVGShape { BLPath fPath{}; SVGPathBasedShape() :SVGShape() {} SVGPathBasedShape(IMapSVGNodes* iMap) :SVGShape(iMap) {} void drawSelf(IRender &ctx) override { ctx.fillPath(fPath); ctx.strokePath(fPath); } };
Ignoring the SVGShape object (a small shim atop SVGObject), we have a BLPath, and a drawSelf(). What could be simpler? The general premise is that all geometry can be represented as a BLPath at the end of the day. Everything from single lines, to polygons, to complex paths, they all boil down to a BLPath. Making this object hugely simplifies the drawing task. All subsequent geometry classes just need to convert themselves into BLPath, which we’ll see is very easy.
Here is the SVGLine, as it’s fairly simple, and representative of the rest of the geometries.
struct SVGLine : public SVGPathBasedShape { BLLine fGeometry{}; SVGLine() :SVGPathBasedShape(){ reset(0, 0, 0, 0); } SVGLine(IMapSVGNodes* iMap) :SVGPathBasedShape(iMap) {} void loadSelfFromXml(const XmlElement& elem) override { SVGPathBasedShape::loadSelfFromXml(elem); fGeometry.x0 = parseDimension(elem.getAttribute("x1")).calculatePixels(); fGeometry.y0 = parseDimension(elem.getAttribute("y1")).calculatePixels(); fGeometry.x1 = parseDimension(elem.getAttribute("x2")).calculatePixels(); fGeometry.y1 = parseDimension(elem.getAttribute("y2")).calculatePixels(); fPath.addLine(fGeometry); } static std::shared_ptr<SVGLine> createFromXml(IMapSVGNodes *iMap, const XmlElement& elem) { auto shape = std::make_shared<SVGLine>(iMap); shape->loadFromXmlElement(elem); return shape; } };
It’s fairly boilerplate. Just have to get the right attributes turned into values for the BLLine geometry, and add that to our path. That’s it. The rect, circle, ellipse, polyline, polygon, and path objects, all do pretty much the same thing, in as small a space. These are much simpler than having to deal with the ‘stroke’ or ‘fill’ attributes. There is some trickery here in terms of parsing the actual coordinate values, because they can be represented in different kinds of units, but the SVGDimension object deals with all those details.
That’s about enough code for this time around. We’ve looked at attributes, and VisualNodes, and we know that we need cascading styles, painter’s algorithm drawing order, and an ability to draw into a context. Now we have all the pieces we need to complete the final rendering task.
Next time around, I’ll wrap it up by bringing in the SVG ‘parser’, which will combine the XML scanning with our document tree, and render final images.