An SVG reader for Common Lisp: the part of SVG that icons and simple vector art use, read into shapes of curves with their fills and strokes.
  • Common Lisp 100%
Find a file
dxtr 81b5f63581
All checks were successful
CI / test (push) Successful in 1m11s
Move the pins on the workspace's libraries
To the latest commits of cl-ft2 and cl-ffi, whose allocate boxes no
pointer, of Pyssel's step 25.
2026-10-19 21:31:32 +02:00
.forgejo/workflows Add cl-svgread/ft2: drawings rasterized with FreeType 2026-10-18 00:20:21 +02:00
ft2 Refuse an image too large to make before making it 2026-10-18 17:09:30 +02:00
src Read numbers in time linear in their length 2026-10-18 17:00:38 +02:00
tests Refuse an image too large to make before making it 2026-10-18 17:09:30 +02:00
.gitattributes Add cl-svgread: SVG icons and vector art read into shapes 2026-10-17 23:11:24 +02:00
.gitignore Add cl-svgread: SVG icons and vector art read into shapes 2026-10-17 23:11:24 +02:00
cl-svgread.asd Add cl-svgread/ft2: drawings rasterized with FreeType 2026-10-18 00:20:21 +02:00
CLAUDE.md Keep shapes and their boxes within what single-floats hold 2026-10-18 16:44:24 +02:00
LICENSE Add cl-svgread: SVG icons and vector art read into shapes 2026-10-17 23:11:24 +02:00
README.org Refuse an image too large to make before making it 2026-10-18 17:09:30 +02:00
stiftfile Move the pins on the workspace's libraries 2026-10-19 21:31:32 +02:00

cl-svgread

A reader for the part of SVG that icons and simple vector art use, for Common Lisp: paths and the basic shapes, filled and stroked with solid colours, with opacity and transforms. It resolves styles, inheritance, units and transforms, as resvg's parser does, and gives each shape its curves as plain arrays in its own coordinates, the transform to the drawing's, its fill and its stroke. It strokes and rasterizes nothing itself.

(asdf:load-system :cl-svgread)

(let* ((drawing (cl-svgread:read-svg
                 "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'>
                    <rect x='2' y='2' width='12' height='12' rx='2' fill='#4a90d9'/>
                    <path d='M5 8 L7 10 L11 6' fill='none' stroke='currentColor'
                          stroke-width='1.5' stroke-linecap='round'/>
                  </svg>"))
       (check (aref (cl-svgread:drawing-shapes drawing) 1)))
  (list (cl-svgread:drawing-width drawing)
        (cl-svgread:drawing-height drawing)
        (length (cl-svgread:drawing-shapes drawing))
        (cl-svgread:shape-fill check)
        (cl-svgread:shape-stroke check)
        (cl-svgread:shape-stroke-width check)
        (cl-svgread:shape-stroke-line-cap check)
        (cl-svgread:path-contours (cl-svgread:shape-path check))))
;; => (16.0 16.0 2 NIL :CURRENT-COLOR 1.5 :ROUND
;;     (((:LINE 5.0 8.0 7.0 10.0) (:LINE 7.0 10.0 11.0 6.0))))

The tick is stroked with :current-color, SVG's currentColor, which no element of the icon sets: whoever draws it chooses the colour, as a theme colours its icons.

The optional system cl-svgread/ft2 rasterizes drawings into images with FreeType's stroker and rasterizer, through cl-ft2 (see Rasterizing). cl-svgread itself needs only xmls, which reads the XML.

Reading

read-svg reads one SVG document and returns its drawing. It takes:

  • a pathname, naming an SVG file, decoded as UTF-8, or as US-ASCII or ISO-8859-1 where its XML declaration says so;
  • a string of SVG text: (read-svg "<svg ...>...</svg>"). A string is never a file name; to read a file named by a string, pass (pathname string);
  • a stream, of characters or of octets, read from where it is to its end;
  • a vector of octets, decoded as a file is.

Malformed input signals an svg-error, and a part of SVG that cl-svgread leaves out signals an svg-warning (see Errors and warnings). A missing file signals a file-error, as open does.

Drawings

  • drawing-width and drawing-height are the drawing's size: its width and height attributes in px, the view box's size where they are left out, or 100 by 100 without either.
  • drawing-shapes is a simple vector of its shapes in paint order, the first under the rest. Shapes of groups and of use elements are in their place; what SVG does not render where it is, such as the contents of defs, or what has display: none, is not there.
  • drawing-view-box is the view box as given, four single-floats #(min-x min-y width height), or nil. The shapes' transforms already map it onto the drawing, so it is there to read, not to apply.

The drawing covers 0, 0 to its width and height. What lies outside it is not part of the drawing, and a renderer clips it away.

Shapes

Each path element and basic shape becomes a shape:

  • shape-path is its path, in the shape's own coordinates, and shape-transform the transform from them to the drawing's: the shape's transform, its groups', and the view box's. A transform is six single-floats #(a b c d e f), in the order of SVG's matrix(a b c d e f), which maps x, y to a x + c y + e, b x + d y + f; that is also the order of cl-gmath's mat3x2.
  • shape-fill is a paint: nil for none, a color, or :current-color. A colour is four single-floats from 0 to 1, red, green, blue and alpha, in sRGB with straight alpha. :current-color stands for SVG's currentColor where no element set the colour property; where one did, the colour is there instead.
  • shape-fill-opacity, from 0 to 1, multiplies the alpha of the fill's colour. It is the product of the shape's fill-opacity and opacity and of the opacity of every group around it.
  • shape-fill-rule is :nonzero or :even-odd.
  • shape-stroke and shape-stroke-opacity are the stroke's paint and opacity, as for the fill. shape-stroke-width is in the shape's own coordinates; shape-stroke-line-join is :miter, :miter-clip, :round or :bevel; shape-stroke-line-cap is :butt, :round or :square; shape-stroke-miter-limit is the ratio of a miter's length to the stroke's width, at least 1. A stroke of no width is nil.
  • shape-paint-order is :fill-stroke, or :stroke-fill where paint-order puts the stroke under the fill.
  • shape-id is the element's id, or nil.
  • shape-bounds returns the box around what the shape paints, in the drawing's coordinates, stroke included.

A stroke belongs in the shape's own coordinates: stroke the path there, then transform the stroke's outline. A transform that stretches one way more than the other then stretches the stroke with it, as SVG draws it.

Paths

A path holds a shape's curves as FreeType's FT_Outline holds a glyph's, in plain arrays:

(let ((path (cl-svgread:shape-path
             (aref (cl-svgread:drawing-shapes
                    (cl-svgread:read-svg "<svg><path d='M 0 0 L 4 0 Q 4 4 0 4 Z'/></svg>"))
                   0))))
  (list (cl-svgread:path-points path)
        (cl-svgread:path-tags path)
        (cl-svgread:path-contour-ends path)
        (cl-svgread:path-closed path)))
;; => (#(0.0 0.0 4.0 0.0 4.0 4.0 0.0 4.0) #(1 1 0 1) #(3) #*1)
  • path-points holds two single-floats for each point, x then y.
  • path-tags holds an octet for each point: +on-curve+ for a point the path passes through, +quadratic-control+ for the control point of a quadratic Bézier curve, +cubic-control+ for each of a cubic one's two. They have FreeType's values, 1, 0 and 2, so the arrays pass to FreeType as they are.
  • path-contour-ends holds the index of each contour's last point.
  • path-closed is a bit vector, 1 for each closed contour.

A contour starts on the curve. A closed one ends where it started: after its last point comes a segment back to its first, a line, or a curve through the control points it ends with. A contour of one point is a subpath of no length, which round and square caps draw as a dot.

Lines and quadratic and cubic curves stay as the SVG gives them. Arcs, and the rounded corners, circles and ellipses made of them, become cubic curves, one for each quarter turn or less, which stray from the true ellipse by at most 0.0273 % of its larger radius.

path-contours returns the contours as lists of segments with explicit start points, the format cl-sdf and cl-textrender use: (:line x0 y0 x1 y1), (:quadratic x0 y0 cx cy x1 y1) and (:cubic x0 y0 c1x c1y c2x c2y x1 y1). Its second value says which contours are closed. SVG fills an open contour as if a line closed it; (path-contours path :closed t) gives the contours as a fill sees them. path-bounds returns the box around a path's points, optionally transformed, or signals an svg-error where a transform takes the box beyond what single-floats hold, and path-point-count and path-contour-count count them.

What it reads

  • Elements: svg, the document and nested ones; g and a; path, rect, circle, ellipse, line, polyline and polygon; defs; use, of any of them and of symbol elements; title, desc and metadata, which draw nothing. Elements of other namespaces, such as an editor's, are skipped quietly.
  • Path data: all of it, absolute and relative: moves, lines, horizontal and vertical lines, cubic and quadratic curves and their smooth forms, arcs and close path, with commands repeated by giving more numbers and numbers packed as tightly as the grammar allows.
  • Geometry: x, y, width, height, rx and ry, which may be auto, cx, cy, r, x1, y1, x2, y2 and points; transform, with every transform function; viewBox and preserveAspectRatio, with every alignment, meet and slice.
  • Properties, as presentation attributes and in the style attribute, which wins over them, inherited as SVG inherits them, with inherit, initial and unset: fill, fill-opacity, fill-rule, stroke, stroke-width, stroke-opacity, stroke-linecap, stroke-linejoin, stroke-miterlimit, opacity, color, display, visibility and paint-order.
  • Colours: the named colours of SVG and CSS, transparent, #rgb, #rgba, #rrggbb and #rrggbbaa, rgb(), rgba(), hsl() and hsla() with numbers or percentages in the comma or the space syntax, currentColor and none. currentColor inherits as itself and takes the colour of the shape it paints, as CSS Color 4 has it.
  • Units: numbers, px, and in, cm, mm, Q, pt and pc at 96 px to the inch; percentages of the viewport's width, height, or diagonal over the square root of 2, as SVG says. Numbers, whatever their exponent, and lengths in user units, are at most 10^30, and a number below 10^-45 is zero; a length beyond 10^30, as a unit or a percentage of a large viewport may make one, is malformed. The transform from an element's coordinates to the drawing's is held to the same limit at every step of composing it, from the outermost group in: an element whose transforms go beyond it is left out with an svg-error, even where a transform further in would scale back down, since a file that scales up past 10^30 and back is absurd enough to refuse. So is what a shape paints, its stroke included: a shape that reaches beyond 10^30 from the drawing's origin is left out with an svg-error, so every box shape-bounds gives fits single-floats.

What it leaves out

Text, images, gradients and patterns, filters, clip paths, masks, markers, dashes, blend modes, vector effects, CSS transforms, animation, scripts, style sheets, conditional content, and lengths in units relative to a font or a window. Each is left out with an svg-warning that names it, where it would change the drawing: Inkscape writes stroke-dasharray:none and mix-blend-mode:normal on everything, which change nothing and pass quietly. A paint server's fallback colour stands in for it, or none. Properties that do nothing to shapes, such as the text properties, are ignored, as are names that are no property, as CSS ignores them.

Two parts of SVG are approximated, with a warning where it shows:

  • Group opacity. SVG paints a group with opacity, and a shape with both a fill and a stroke, as one layer faded as a whole. cl-svgread multiplies the opacity into each fill and stroke instead, so where they overlap, they show darker than SVG draws them; and a shape's fill shows through its stroke. Where none of them overlap the result is exact, and read-svg warns only where they do. Give the shapes their own fill-opacity and stroke-opacity, which are exact.
  • Clipping. Nested svg elements and symbols clip to their viewports, which cl-svgread does not. It warns where their shapes reach outside.

Errors and warnings

Every error is an svg-error, whose report says what is wrong and where:

(handler-case (cl-svgread:read-svg "<svg><g id='arrow'><path d='M 0 0 L 4'/></g></svg>")
  (cl-svgread:svg-error (error) (princ-to-string error)))
;; => "The d attribute of the <path> in <g id=\"arrow\"> is malformed at
;;     character 10: expected a number for L, but the value ends."

An element is named by its id, or by its place among its parent's children of its name, such as "the 2nd <path> in <g id="layer">", and by the file's name when a file is read; svg-error-location returns that part alone, and svg-error-message the whole message.

Where SVG's error rules let a reader go on, the error offers a continue restart: a malformed attribute is left out, path data and points are kept up to the error, a use element that refers to nothing, or to an element that holds it, is left out, an element transformed beyond what cl-svgread draws is left out, and so is a shape that reaches beyond it, and a shape whose stroke is too wide to draw is drawn without it. Reading as leniently as a web browser does is then:

(handler-bind ((cl-svgread:svg-error #'continue)
               (cl-svgread:svg-warning #'muffle-warning))
  (cl-svgread:read-svg #p"art.svg"))

Input that is not SVG, such as malformed XML, offers no restart.

What is left out or approximated signals an svg-warning, with svg-warning-message and svg-warning-location, and reading goes on. Muffle the warnings with muffle-warning, or collect them in a handler-bind.

Threads

read-svg keeps no state between calls, so any number of threads may read at once. A drawing, its shapes and their paths never change once read, and may be shared between threads; do not modify the arrays they return.

Rasterizing

The optional system cl-svgread/ft2 draws a drawing into an image, with FreeType's stroker and rasterizer through cl-ft2:

(asdf:load-system :cl-svgread/ft2)

(multiple-value-bind (image width height)
    (cl-svgread/ft2:rasterize
     (cl-svgread:read-svg "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'>
                             <circle cx='8' cy='8' r='6' fill='currentColor'/>
                           </svg>")
     :scale 1.5 :current-color '(0.9 0.9 0.9 1))
  (list width height (array-dimensions image)
        (loop for channel below 4 collect (aref image 12 (+ (* 4 12) channel)))))
;; => (24 24 (24 96) (230 230 230 255))

rasterize returns the image, its width and its height:

  • :scale is how many pixels a unit of the drawing's size takes, such as a user interface's scale: an icon 16 units square at 1.5 fills 24 by 24 pixels. The image is the drawing's size times the scale, rounded up, unless :width and :height give its size; the drawing is then drawn at that scale from the top left corner all the same.
  • :current-color is the colour of :current-color paints, an rgba: four reals from 0 to 1, red, green, blue and alpha, as a list or a vector, opaque black by default. Declare a colour of your own as a cl-svgread/ft2:rgba, and the compiler checks what it can of it.
  • The image is a 2D array of (unsigned-byte 8), one row of the image to each of its rows, four octets to a pixel, red, green, blue and alpha: the layout a 4-channel cl-atlas atlas takes. Colours are sRGB, composited as SVG composites them, over a transparent background, with straight alpha, as image files hold them, or with :alpha :premultiplied, as GPU blending and filtering want them.

Fills are rasterized by their fill rule. A stroke is made in the shape's own coordinates, at the resolution of the image, and transformed with the shape, so that a transform that stretches one way more than the other stretches the stroke too. A subpath of no length with round or square caps is drawn as a dot, as SVG draws it. FreeType draws curves as short lines, a few hundredths of a pixel inside them, which loses a circle of 24 pixels' radius 0.6 % of its area.

Each call makes a FreeType library of its own, so any number of threads may rasterize at once, sharing drawings. A shape that lies farther than 8388608 pixels from the image, whose stroke, its caps and miters included, would be made farther than that from its origin, whose transform :scale takes a unit of farther than that, or that holds more than 32767 points, is beyond what FreeType rasterizes, and signals an svg-error that says what to change. An image is at most 8388608 pixels, as 4096 by 2048 has, enough for 3840 by 2160: a larger one, which would take much of a Lisp's heap, is refused before anything is made, with an svg-error that gives the largest :scale that fits.

FreeType does this work for now: a stroker and rasterizer in Lisp may take its place. FreeType is used only in ft2/freetype.lisp, through a function that fills a path into coverage, one that strokes a path into another, and a macro that makes the FreeType library they use, so that one in Lisp can replace that file and leave the rest as it is.

Development

Dependencies are pinned in the stiftfile with stift, and the tests use Parachute:

stift test

The tests of cl-svgread/ft2 need FreeType, 2.13.1 or newer (on Debian or Ubuntu, libfreetype6), as cl-ft2 does.

License

MIT. See LICENSE.