← ALL WRITING

iOS Development

What an SVG actually is, and why rendering one is hard

1. Introduction: a picture that is really a program

Open an SVG file in a text editor and you will not find pixels. You will find angle brackets, tag names, and numbers: <svg>, <circle>, <path d="M10 10 L90 90">. An SVG is a text document that describes how to draw something, not a picture that already exists. In that sense it has more in common with a short program than with a photograph.

This is easy to forget because SVGs behave, from a user's perspective, exactly like images. You put one in an <img> tag, or hand it to an image view, and a picture appears. But "an image you can read in a text editor" still has to become pixels on a screen somehow, and that transformation is not free. Every SVG you have ever seen rendered was the output of a small pipeline: something read the text, built an understanding of what it meant, and then painted that understanding onto a canvas. The rest of this post is about what that pipeline actually has to do, and why each step is less trivial than it looks.

2. Vector vs. raster: two ways to mean "image"

There are two fundamentally different ways to represent a picture in a computer, and the word "image" gets used for both, which causes no end of confusion.

A raster image is a grid. It is a fixed array of colored pixels, each one sitting at an exact row and column, each one holding a fixed color value. A JPEG, a PNG, a screenshot: all of these are rasters. If you zoom in far enough, you eventually see the individual squares, because that is literally all the data there is. A 400x300 PNG has 120,000 colored squares in it and nothing more; there is no way to ask it what color the space "between" two pixels should be, because that space does not exist in the representation.

A vector image is a recipe. Instead of storing colors at coordinates, it stores a description of shapes: "draw a circle centered at (50, 50) with radius 20, filled blue." That description contains no pixels at all. Pixels only appear at the very end, when something reads the recipe and decides, for a specific output size, which pixels the circle should cover.

This is why vector images scale without blurring and raster images do not. A raster image has a fixed amount of information (one color per grid cell) and stretching it means guessing new pixels from old ones, which is approximation no matter how good the algorithm. A vector image has no fixed resolution baked in; scaling it up just means re-running the same recipe against a bigger canvas, computing a fresh, exact answer for where the circle's edge falls at that size. The recipe does not get blurrier because it was never made of pixels to begin with.

SVG (Scalable Vector Graphics) is a vector format. Everything that follows is about how that recipe is written, and about what turning a recipe into a grid of pixels actually costs.

3. SVG is XML: a tree of elements

Concretely, an SVG file is an XML document. XML is a text syntax for describing structure: elements are marked off with opening and closing tags, elements can carry attributes, and elements can be nested inside other elements. A minimal SVG looks like this:

<svg width="200" height="100" viewBox="0 0 200 100">
  <g fill="none" stroke="black">
    <rect x="10" y="10" width="80" height="60" />
    <circle cx="150" cy="40" r="30" />
  </g>
</svg>

Here <svg> is the root element. Inside it is a <g> element (a group), which itself contains a <rect> and a <circle>. This nesting is not cosmetic. Because each element can contain other elements, the document as a whole forms a tree: <svg> is the root, <g> is its child, and <rect> and <circle> are children of <g>. Real-world SVGs nest much deeper than this, with groups inside groups inside groups, but the shape is always the same: a single root, with everything else reachable by walking down through parents and children.

This tree structure matters for two reasons that will come up repeatedly later in this post. First, attributes set on a parent element (a fill color, a stroke width, a transform) can apply to everything nested inside it, so understanding what a leaf element actually looks like sometimes requires looking back up the tree. Second, the tree is the thing that eventually gets turned into an in-memory structure and walked, in roughly the same shape, to produce the final picture.

3a. Elements as drawing primitives

SVG defines a handful of basic shape elements, each one a declarative description of a shape rather than an instruction to draw. That distinction is worth sitting with: <rect x="10" y="10" width="80" height="60" /> does not say "move a pen to (10, 10), then draw a line 80 units right, then 60 units down, then 80 units left, then close." It says "there is a rectangle here, with these bounds." Something else, later in the pipeline, is responsible for deciding how to actually put ink on the canvas to represent that rectangle.

The common shape elements are:

  • <rect>: an axis-aligned rectangle, described by a corner and a width/height (optionally with rounded corners).
  • <circle>: described by a center and a single radius.
  • <ellipse>: like a circle but with independent horizontal and vertical radii.
  • <line>: a single straight segment between two points.
  • <polygon>: a closed shape defined by a list of points, connected in order and automatically closed back to the first point.
  • <path>: the general-purpose shape element, capable of expressing everything the others can and a great deal more.

Every one of these is just a labeled bundle of numbers. None of them contains any notion of "how" to draw; they only say "what."

3b. The <path> element and its command mini-language

<path> is the most important of these elements because it is the most general. Where <rect> and <circle> each describe one specific kind of shape, <path> can describe an arbitrary outline made of straight lines, curves, and arcs, and in practice it is what most complex SVG artwork is built from.

A <path> element carries its geometry in a single attribute, d, whose value is a compact string of commands. Each command is a single letter followed by the numbers it needs, and the whole string reads like a sequence of instructions for moving a pen across the page:

  • M x y: move the pen to (x, y) without drawing anything (start a new subpath).
  • L x y: draw a straight line from the current position to (x, y).
  • C x1 y1 x2 y2 x y: draw a cubic Bezier curve to (x, y), using (x1, y1) and (x2, y2) as control points that shape the curve.
  • A rx ry rot large sweep x y: draw an elliptical arc to (x, y).
  • Z: close the current subpath by drawing a straight line back to its starting point.

Take d="M10 10 L90 10 L50 80 Z". Read left to right: move the pen to (10, 10), draw a line to (90, 10), draw a line to (50, 80), then close the path back to (10, 10). The result is a triangle. Nothing about that string is a triangle in any visual sense; it is a sequence of pen instructions that, followed in order, traces one out.

This is the core idea to carry forward: an SVG, all the way down to its most expressive element, is fundamentally a tiny language of drawing commands written as text.

4. Coordinates, viewBox, and units

Every number in an SVG file (a rectangle's x, a circle's cx, a path's coordinates) is a position in some coordinate space, and SVG's coordinate space has a couple of properties that are worth being explicit about because they are easy to get backwards.

The origin is at the top-left, and y increases downward. This is the opposite of the coordinate convention used in ordinary Cartesian math (where y increases upward), but it matches how screens and most 2-D graphics APIs already think about position: row 0 is the top row, and row numbers grow as you move down. A point at (0, 0) is the top-left corner of the coordinate space, and a point at (0, 100) is 100 units below it, not above it.

The viewBox attribute establishes what that coordinate space actually covers. viewBox="0 0 200 100" says: the logical drawing surface runs from (0, 0) to (200, 100), a 200-by-100 rectangle in whatever units the document's shapes are using. Everything inside the SVG is positioned relative to that logical rectangle, independent of how large the SVG is eventually displayed.

That independence is the whole point. An SVG's width and height attributes (or the size it is given in a browser or an app) describe the output rectangle: how many actual pixels it should occupy on screen. The viewBox and the output size do not have to match, and usually do not. If viewBox="0 0 200 100" but the SVG is displayed at 400x200 pixels, every logical unit in the viewBox maps to two device pixels; the whole coordinate system is scaled up uniformly to fit the requested output size. This is the mechanism that makes an SVG's shapes "scale" at all: nothing about the shape data changes, only the transform used when mapping logical coordinates to actual output pixels.

5. Styling and the cascade

A shape element describes geometry: where its edges are. It says nothing, by itself, about how that geometry should look, so SVG needs a separate set of properties for that: fill (the color used to paint the shape's interior), stroke (the color used to paint its outline), stroke-width, opacity, and many more.

What makes this more than a simple lookup is that these style properties can arrive at an element through three different channels, and all three can be present on the same document, sometimes even on the same element:

  • Presentation attributes: writing the property directly as an XML attribute, e.g. <rect fill="red" />.
  • Inline style: writing the property inside a style attribute using CSS syntax, e.g. <rect style="fill: red;" />.
  • CSS <style> rules: a <style> element elsewhere in the document containing CSS rules that select elements by tag name, class, or id, e.g. rect { fill: red; }.

These three channels have to be reconciled into one final answer for each property on each element, and they do not have equal weight. Broadly, an inline style attribute takes priority over a matching <style> rule, which in turn takes priority over a plain presentation attribute, and CSS's !important modifier can invert that ordering again for whichever declaration carries it. This ordering, and the process of resolving competing declarations down to one winner, is what CSS calls "the cascade." An SVG renderer has to implement some form of it even for documents that only ever use one of the three channels, because it can never assume in advance which channels a given document will use.

5a. Inheritance

Layered on top of the cascade is inheritance: some style properties, when left unspecified on an element, take their value from the element's parent rather than from a fixed default. fill is one of these: set fill="blue" on a <g>, and every shape nested inside it that does not specify its own fill will paint blue, because the property flows down the tree from parent to child.

Not every property behaves this way. opacity, for example, is not inherited: a parent's opacity does not become a child's opacity by default, because opacity has a compositing meaning (how the element blends with what is behind it) that would produce a different, and usually unwanted, result if it silently propagated to descendants.

The consequence is that a single element's final appearance cannot be read off its own tag in isolation. Two identical <circle> elements, with identical attributes, can render completely differently if they sit inside different ancestor chains, because their unspecified properties resolve against different inherited values. Determining what a shape actually looks like requires knowing its position in the tree, not just its own attributes.

6. The structural features that make SVG more than shapes

Beyond shapes and their basic styling, SVG defines a set of features that let one part of the document affect how another part is painted, and these are what turn "a list of shapes" into something closer to a small compositing system:

  • Gradients (<linearGradient>, <radialGradient>): instead of a flat color, a fill or stroke can reference a gradient defined elsewhere in the document, which itself is a list of color stops to be interpolated across a shape's area.
  • <use>: an element that instantiates a copy of another element defined elsewhere in the document, by reference (via an id), rather than by duplicating its markup. The same <use> mechanism can reference the same target many times, each with its own position and transform.
  • clip-path: restricts painting to the area inside another shape, effectively cutting out everything beyond that shape's boundary.
  • Masks: like clipping, but graded rather than binary; a mask's own luminance or alpha values determine how much of the masked content shows through at each point, rather than an in-or-out boundary.
  • Group opacity: an opacity set on a <g> (or any container) that applies to the group's rendered result as a whole, rather than to each child independently.

That last distinction, group opacity versus per-child opacity, is worth dwelling on because it is where "flat list of shapes" stops being an adequate mental model. If a group contains two overlapping shapes and the group has opacity="0.5", the correct result is for the group to be painted as if it were a single, fully-opaque composite image and then that whole image made half-transparent. If each shape were instead independently made half-transparent and painted in sequence, the overlapping region would show through to both shapes underneath in a way that does not match what opacity on the group is supposed to mean. Producing the first result and not the second is a compositing problem, not just a drawing problem: it requires painting a group's contents somewhere isolated, then combining that result with everything else, rather than painting each shape directly onto the final canvas as it is encountered.

7. Why rendering an SVG is genuinely hard

Given all of the above, it is possible to state precisely what "hard" means here. It is not that any single operation, drawing a filled shape, is difficult on its own; graphics APIs make that trivial. The difficulty is that an SVG document accumulates several kinds of complexity that all have to be resolved correctly, and in the right order, before a single pixel can be painted with confidence that it is correct. The rest of this section walks through each source of difficulty in turn.

7a. Parsing text into a usable model

The starting point is text: angle brackets and attribute strings. You cannot draw from characters. Before anything else can happen, the document has to be parsed, turning a sequence of bytes into some in-memory structure that code can actually query: "what are this element's children," "what does this attribute say," "what shape is this."

That sounds like a formality, but the specific structure chosen to hold the parsed document has consequences that ripple through everything downstream of it. Does the parser build a full tree of objects that mirrors the XML nesting one-to-one, with each node able to look up its parent, its children, and its attributes directly? Or does it produce something flatter and more compact, at the cost of making those relationships less direct to query? Either choice is workable, but it is not a decision that can be revisited cheaply later: the model built at parse time is what every later stage (style resolution, geometry processing, painting) has to walk and query, so its shape determines how expensive or how simple those later stages turn out to be.

7b. Resolving what a shape actually looks like

Once there is a queryable model, the next problem is the one raised in section 5 and 5a: an element's real, final style is computed, not read directly off the tag. Getting from "the attributes and CSS rules written in the document" to "the one fill color and stroke color this shape should be painted with" requires walking the cascade (attribute, inline style, <style> rule, resolving specificity and !important), then applying inheritance (falling back to the parent's already-resolved value for inheritable properties, and to a fixed initial value for everything else), and then handling special cases like currentColor, a keyword that means "use whatever this element's own resolved color property evaluates to." None of this is optional or skippable for a conformant renderer, because any SVG in the wild might exercise all of it at once, on the same element.

7c. Order, grouping, and compositing

Painting is order-dependent: later shapes draw on top of earlier ones, so the sequence in which elements appear in the document is part of the document's meaning, not an implementation detail a renderer is free to reorder. That alone is manageable. What makes it harder is the group-level compositing behavior described in section 6: an effect like group opacity, a mask, or a clip does not apply shape-by-shape as the group is walked. It applies to the group's contents as a single, already-painted unit. That means a renderer sometimes cannot paint a group's children directly onto the final canvas as it encounters them; it has to paint them somewhere isolated first, apply the group-level effect to that isolated result, and only then composite the result into the final image. Deciding when that isolation is actually necessary, and doing it without becoming needlessly expensive for the common case of a plain, effect-free group, is itself a nontrivial piece of engineering.

7d. Geometry that must be converted

The shapes in section 3b are authored in forms convenient for a human or a design tool to write, not in forms a rasterizer can consume directly. A cubic Bezier curve, C x1 y1 x2 y2 x y, is a smooth mathematical curve defined by an equation; painting it as pixels usually means approximating that curve with a sequence of short straight-line segments fine enough that the result looks smooth, a process generally called flattening. Elliptical arcs are worse: the A command's parameters (radii, rotation, and two flag bits) describe the arc endpoint-to-endpoint, in a form that has to be reparameterized into center, start angle, and sweep angle before the underlying math can even be evaluated, before flattening can begin. None of this geometry can be handed to a paint routine as-is; it has to be converted first, and the conversions themselves have edge cases (degenerate radii, zero-length segments) that a correct implementation has to account for.

7e. References and cycles

Several of the features from section 6, <use>, gradients, clip paths, work by reference: an element points at another element elsewhere in the document by its id, rather than containing that other element inline. That reference might point at something defined later in the document than the element doing the referencing (a forward reference), it might point at one definition shared by many referencing elements (which a renderer should generally not have to re-process from scratch for each reference), and, because nothing in the syntax prevents it, a chain of references can point back at itself. A <use> that (directly or indirectly, through a chain of further <use> elements) ends up referencing itself would send a naive renderer into infinite recursion. Handling references correctly means resolving forward references, sharing resolved definitions where the document does, and detecting cycles before they cause unbounded recursion, none of which is visible from looking at any single element in isolation.

8. The shape of any renderer: parse → model → resolve → paint

Pull the threads of section 7 together and a shared shape emerges, independent of what language or platform is doing the rendering. An SVG is, at bottom, a tree of path commands and attributes written as text. Turning that text into pixels always means passing it through the same broad stages: parse the text into some in-memory model, resolve that model's ambiguities (styles via cascade and inheritance, geometry via flattening and reparameterization, references via lookup and cycle detection), and only then paint, honoring document order and the group-level compositing effects that apply across multiple shapes at once.

Parse, model, resolve, paint. Every renderer, regardless of the specific data structures or APIs it is built on, is doing some version of these four things, in roughly this order, because the source format demands it: you cannot resolve a cascade you have not parsed, you cannot flatten a curve you have not resolved the coordinates for, and you cannot paint in the right order without a model that preserves that order. The next post in this series looks at what these stages look like when you have to build them on a specific platform, where the available graphics primitives shape which parts of this pipeline are cheap and which are not.