svg_path

Package Version Hex Docs

Utilities for working with SVG d and transform attributes, encompassing parsing, serialization, and geometric manipulation of paths, subpaths, subpath segments, and transform matrices.

gleam add svg_path@0
import svg_path/parse
import svg_path/serialize

pub fn tidy_path_data(input: String) -> String {
  let assert Ok(path) = parse.path(input)
  serialize.path(path)
}
import gleam/result
import svg_path
import svg_path/parse
import svg_path/serialize
import svg_path/transform

pub fn prepare_for_arc_averse_consumer(
  input: String,
) -> Result(String, parse.Error) {
  use path <- result.try(parse.path(input))

  let assert Ok(path) =
    path
    |> transform.scale_path(factor: 2.0)

  path
  |> svg_path.path_arcs_to_cubic_beziers
  |> serialize.path
  |> Ok
}

Core Model

The root svg_path module represents SVG path data with Path and Subpath types, built on top of lower-level Segment and Point types.

Points

A Point is borrowed from the vec package:

pub type Point =
  Vec2(Float)

Use svg_path.point to create points without importing vec directly:

svg_path.point(10.0, 20.0)

Segments

A Segment is one drawing instruction with explicit start and end points. These are the public segment variants:

svg_path.Line(start:, end:)
svg_path.QuadraticBezier(start:, control:, end:)
svg_path.CubicBezier(start:, control1:, control2:, end:)
svg_path.Arc(start:, radius:, x_axis_rotation:, large_arc:, sweep:, end:)

Segments can be evaluated and split by their local parameter t, where 0.0 is the segment start and 1.0 is the segment end:

svg_path.segment_point(segment, at: 0.5)
svg_path.segment_derivative(segment, at: 0.5)
svg_path.split_segment(segment, at: 0.5)
svg_path.sub_segment(segment, from: 0.25, to: 0.75)
svg_path.sub_segments(segment, between: [0.25, 0.75, 0.5])

These helpers work for lines, quadratic Beziers, cubic Beziers, and arcs. Values outside 0.0..1.0 silently extrapolate along the same segment. Use _inside variants of the same functions, such as segment_point_inside, to force errors instead.

Subpaths

A Subpath has a start point, a list of end-to-end segments, and a flag for topological closure. Its constructor is opaque:

pub opaque type Subpath {
  Subpath(start: Point, segments: List(Segment), closed: Bool)
}

The first segment, when present, must start at start, and adjacent segments must meet end-to-start. The closed field records whether the subpath is topologically closed. When a non-empty subpath is closed, its last segment must end at start; empty subpaths may also be closed. These invariants are guaranteed by keeping the type opaque. A Subpath’s serialization ends in Z/z if and only if closed == True.

Use svg_path.subpath to construct an open subpath from a nonempty list of contiguous segments, and svg_path.set_closed to change whether a subpath is topologically closed; note that set_closed(_, True) may result in an error, but set_closed(_, False) may not:

svg_path.subpath(segments)                  // -> Result(Subpath, svg_path.Error)
svg_path.set_closed(subpath, closed: Bool)  // -> Result(Subpath, svg_path.Error)

Construction succeeds when the required segment endpoints meet. Initialize empty move-only subpaths with empty_subpath(at:) where at gives the start of the subpath.

In the following example the segments return to their starting point geometrically, but the subpath only becomes topologically closed after set_closed:

import gleam/result
import svg_path

pub fn closed_triangle() -> Result(svg_path.Subpath, svg_path.Error) {
  let a = svg_path.point(0.0, 0.0)
  let b = svg_path.point(10.0, 0.0)
  let c = svg_path.point(5.0, 10.0)

  use subpath <- result.try(svg_path.subpath([
    svg_path.Line(start: a, end: b),
    svg_path.Line(start: b, end: c),
    svg_path.Line(start: c, end: a),
  ]))

  io.println(svg_path.serialize_subpath(subpath))
  // -> "M 0 0 H 10 L 5 10"

  use subpath <- result.try(svg_path.set_closed(subpath, closed: True))

  io.println(svg_path.serialize_subpath(subpath))
  // -> "M 0 0 H 10 L 5 10 Z"
}

Use svg_path.clean_subpath(subpath) to remove zero-length segments from a Subpath while preserving one zero-length segment if cleanup would otherwise remove every segment.

Paths

Path is a list of Subpath:

pub type Path {
  Path(subpaths: List(Subpath))
}

Construct paths directly via the public variant:

svg_path.Path(subpaths: [subpath])

Use combine_paths to concatenate the subpaths from several paths, preserving empty subpaths. Use clean_combine_paths when you want the combined result to also drop empty subpaths and clean zero-length lines:

svg_path.combine_paths([first, second])
svg_path.clean_combine_paths([first, second])

A Path may consist of an empty list of subpaths, and a Subpath may consist of an empty list of segments. Empty paths serialize to the empty string. Empty subpaths serialize as move-only subpaths, with Z/z appended when closed.

Use path_start and path_end to get the endpoints of a full path. Empty paths return EmptyPath; paths with subpaths use the first subpath’s start and the last subpath’s end, including empty subpaths:

svg_path.path_start(path)
svg_path.path_end(path)

Matching Endpoints

Helper functions in the root module let users employ an EndpointPolicy option to specify different types of error-recovery behavior for non-matching endpoints:

svg_path.Strict
svg_path.Wiggle
svg_path.Bridge
svg_path.WiggleThenBridge
svg_path.Custom(fn(previous, next) { #(previous, next) })

Strict requires exact endpoint equality. Wiggle moves nearby endpoints together within the package’s default wiggle tolerance of 0.000000001, while preserving horizontal and vertical straight-line segments. Bridge keeps existing endpoints in place and inserts a straight line segment when needed. WiggleThenBridge, as the name implies, first tries Wiggle before falling back on Bridge. Custom gives callers a hook for bespoke endpoint reconciliation.

The behavior of option-free functions and constructors is EndpointPolicy.Strict. These include:

svg_path.subpath(segments)
svg_path.append_segment(subpath, segment)
svg_path.join([first_subpath, second_subpath])
svg_path.splice(subpath, start:, delete:, insert:)
svg_path.set_closed(subpath, closed: Bool)

These functions preserve Segment lists exactly while returning a Discontinuous error payload when segment endpoints fail to match up by exact floating point equality. The Discontinuous error payload names the index at which discontinuity occurs as well as the position and distance between the endpoints involved:

Discontinuous(
  previous_index: Int,
  next_index: Int,
  expected: Point,
  got: Point,
  distance: Float,
)

This is often enough to tell whether upstream geometry missed by floating-point noise or by a real modeling mistake.

The _with variants of constructor and subpath-modifying functions enable the specification of a non-Strict endpoint policy:

svg_path.subpath_with(segments, policy: svg_path.Wiggle)
svg_path.append_segment_with(subpath, segment, policy: svg_path.Bridge)
svg_path.join_with([first_subpath, second_subpath], policy: svg_path.WiggleThenBridge)
svg_path.splice_with(subpath, start:, delete:, insert:, policy: svg_path.Wiggle)
svg_path.set_closed_with(subpath, closed: Bool, policy: svg_path.Bridge)

Custom Endpoint Policies

Custom receives each non-matching adjacent pair as previous and next, and returns replacement segments for that pair. It is called only when the two endpoints do not already match. After all custom reconciliation has run, the result is validated normally, so custom policies still return the usual construction errors if they leave the subpath discontinuous.

For example, a custom policy can move the start of each incoming line to the previous segment’s end point:

let policy =
  svg_path.Custom(fn(previous, next) {
    case next {
      svg_path.Line(end:, ..) -> {
        #(previous, svg_path.Line(start: svg_path.segment_end(previous), end:))
      }
      _ -> #(previous, next)
    }
  })

When closing a subpath with set_closed_with, the adjacent pair is the last segment followed by the first segment. The returned pair is used to close that wraparound boundary, and the final subpath must still validate as both continuous and closed.

Use the assert_ functions for hand-authored/static geometry where invalid continuity is a programmer error:

svg_path.assert_subpath(segments)
svg_path.assert_append_segment(subpath, segment)
svg_path.assert_join([first_subpath, second_subpath])
svg_path.assert_join_with([first_subpath, second_subpath], policy: svg_path.WiggleThenBridge)
svg_path.assert_splice(subpath, start:, delete:, insert:)
svg_path.assert_set_closed(subpath, closed: Bool)

Joining Subpaths

join combines open subpaths into one open subpath. With the default Strict policy, each subpath’s end point must exactly equal the next subpath’s start point. Empty open subpaths can act as identity values when their start points line up. join([]) returns EmptySubpath.

svg_path.join([first_subpath, second_subpath, third_subpath])

Closed subpaths are rejected rather than implicitly opened. This keeps closedness as explicit topology: if you want to discard it, use set_closed(subpath, closed: False) first.

Use join_with when you want another endpoint policy:

svg_path.join_with([first_subpath, second_subpath], policy: svg_path.Wiggle)
svg_path.join_with([first_subpath, second_subpath], policy: svg_path.Bridge)

Splicing Subpaths

splice replaces a range of segments while preserving the subpath invariant. start is a zero-based segment index, delete is the number of segments to remove, and insert is the replacement list.

svg_path.splice(subpath, start: 2, delete: 1, insert: replacement_segments)

If start + delete extends past the end of the subpath, everything from start onward is deleted. Negative start, negative delete, and start greater than the subpath length return InvalidSplice.

With the default Strict policy, the edited subpath must still be continuous, otherwise Discontinuous is returned with segment indices, points, and distance. Closed subpaths preserve their closed state. If the splice result is nonempty, the subpath start is updated to the first resulting segment’s start point. If the splice result is empty, the previous start point is preserved.

Use splice_with when the splice should use a different endpoint policy:

svg_path.splice_with(
  subpath,
  start: 2,
  delete: 1,
  insert: replacement_segments,
  policy: svg_path.Wiggle,
)

Opening Closed Subpaths

open_at breaks open a closed subpath at a segment index and returns a single open subpath. The indexed segment becomes the first segment of the result:

svg_path.open_at(closed_subpath, index: 2)

Negative indices count from the end. The accepted index range is inclusive: -length <= index <= length, where length is the number of segments in the closed subpath. After this range check, the index is taken modulo length, so -length, 0, and length all open at the first segment.

The error behavior is intentionally specific:

Converting Arcs to Beziers

Some SVG consumers and geometry workflows prefer to avoid elliptical Arc segments. Use the _arcs_to_cubic_beziers function family to replace arcs with cubic Bezier curves while preserving lines, quadratic Beziers, and existing cubic Beziers:

svg_path.segment_arcs_to_cubic_beziers(segment)
svg_path.subpath_arcs_to_cubic_beziers(subpath)
svg_path.path_arcs_to_cubic_beziers(path)

Elliptical arcs are approximated with one or more cubic Beziers, split into chunks of at most a quarter turn. The conversion preserves subpath closed/open state. If an arc is degenerate, it falls back to the straight-line cubic Bezier between the arc endpoints.

There is no tolerance option for this conversion. The approximation policy is deterministic: each arc chunk spans no more than 90 degrees. This is the common practical SVG arc-to-cubic approximation and is usually more than adequate for rendering and interchange.

If you want every segment represented as cubic Bezier curves, use the stricter helpers instead. Lines and quadratic Beziers are converted exactly.

svg_path.segment_to_cubic_beziers(segment)
svg_path.subpath_to_cubic_beziers(subpath)
svg_path.path_to_cubic_beziers(path)

Geometry Helpers

The root module provides a few geometry helpers that work directly with the Segment, Subpath, and Path model.

Bounding Boxes

Use segment_bounding_box, subpath_bounding_box, and path_bounding_box to compute exact axis-aligned bounding boxes:

import svg_path

pub fn box_path(path: svg_path.Path) -> Result(svg_path.BoundingBox, svg_path.Error) {
  svg_path.path_bounding_box(path)
}

Use bounding_box_width, bounding_box_height, bounding_box_center, and bounding_box_diameter to measure a BoundingBox. The diameter is the taxicab diameter: width plus height.

Line, quadratic Bezier, cubic Bezier, and arc extrema are included. Empty subpaths return EmptySubpath; empty paths return EmptyPath; paths whose subpaths are all empty return EmptySubpaths.

For callers working at the lower-level curve modules, svg_path/bezier exposes bezier_bounding_box, and svg_path/ellipse exposes arc_bounding_box.

Segment Minimization

Use segment_minimize to find the segment parameter where a scalar function of the segment point is minimized:

import svg_path

pub fn lowest_point(segment: svg_path.Segment) -> Result(Float, svg_path.Error) {
  svg_path.segment_minimize(segment, measure: fn(point) {
    point.y
  })
}

The returned value is a segment parameter in 0.0..1.0. You can pass it to segment_point or split_segment.

Minimization is numerical and sampling-based. Each sampled window is refined with golden-section search, so it does not require a derivative of the measured function. Use segment_minimize_with and MinimizeOptions to tune samples, tolerance, and max_iterations.

Segment Distances

Use segment_distance to measure the shortest distance from a point to a segment:

import svg_path

pub fn distance_to_segment(
  point: svg_path.Point,
  segment: svg_path.Segment,
) -> Result(Float, svg_path.Error) {
  svg_path.segment_distance(point, to: segment)
}

Lines are measured exactly. Quadratic Beziers, cubic Beziers, and arcs are measured by finding stationary points of squared distance over the segment parameter range 0.0..1.0. Use segment_distance_with and DistanceOptions to tune samples, tolerance, and max_iterations.

Segment Crossings

Use segment_crossings to find parameter values where a scalar predicate changes sign along a segment:

import svg_path

pub fn horizontal_crossings(
  segment: svg_path.Segment,
  y: Float,
) -> Result(List(Float), svg_path.Error) {
  svg_path.segment_crossings(segment, where: fn(point) {
    point.y -. y
  })
}

The returned values are segment parameters in 0.0..1.0. You can pass them to segment_point or split_segment.

Crossing detection is numerical and sampling-based. It finds sign-change crossings visible at the configured sampling resolution, plus endpoint/sample values that are already close to zero. It does not promise tangent roots or multiple crossings hidden inside one sample window. Use segment_crossings_with and CrossingOptions to tune samples, tolerance, and max_iterations.

The scalar solver behind this lives in svg_path/root.gleam as a small self-contained bisection helper for bracketed Float -> Float functions.

Segment Intersections

Use segment_intersections to find point intersections between two segments:

import svg_path

pub fn crossings(
  left: svg_path.Segment,
  right: svg_path.Segment,
) -> Result(List(svg_path.SegmentIntersection), svg_path.Error) {
  svg_path.segment_intersections(left, right)
}

Each SegmentIntersection contains the intersection point plus the local parameters on both segments:

svg_path.SegmentIntersection(left_t:, right_t:, point:)

The result represents finite point intersections only. Segments that overlap in more than one point, such as partially overlapping collinear lines, return OverlappingSegments. Use segment_intersections_with and IntersectionOptions to tune tolerance and max_depth for curved segment intersection detection.

Convex Hulls

The svg_path/convex_hull module computes a closed hull for a single segment.

import svg_path
import svg_path/convex_hull

pub fn hull(
  segment: svg_path.Segment,
) -> Result(svg_path.Subpath, convex_hull.HullError) {
  convex_hull.segment_hull(segment)
}

Lines, quadratic Beziers, and ordinary arcs are handled semantically. Lines produce a two-line closed hull, while quadratic Beziers and arcs produce the original primitive plus the chord joining its endpoints. Cubic Beziers use a cubic-specific numerical solver.

PathError means the generated pieces could not be turned into a valid closed Subpath. The other HullError values are reserved for cubic solver consistency failures, so the function reports an error rather than guessing at a hull.

For a whole continuous subpath, use subpath_hull:

pub fn hull(
  subpath: svg_path.Subpath,
) -> Result(svg_path.Subpath, convex_hull.HullError) {
  convex_hull.subpath_hull(subpath)
}

This returns a closed Subpath containing the convex hull of all segments in the input. Internally each segment is first converted to a segment hull, then those convex loops are unioned together.

For a path with multiple subpaths, use path_hull:

convex_hull.path_hull(path)

Empty subpaths are ignored, and the result is still a single closed Subpath.

Parsing

svg_path/parse accepts normal SVG path data syntax, including:

import gleam/result
import svg_path/parse
import svg_path/serialize

pub fn canonicalize() -> Result(String, parse.Error) {
  use path <- result.try(parse.path("M0,0 10,10z"))

  Ok(serialize.path(path))
}

The parsed object is not just a token stream. It is normalized into this package’s path model. For example, an implicit line after M becomes a Line segment internally.

Closepath is also represented semantically. If parsing Z needs a straight line back to the subpath start, the parser inserts that line and marks the subpath closed. If the subpath is already back at its start, no extra line is inserted; the subpath is just marked closed.

Path Serialization

svg_path/serialize emits canonical SVG path data.

By default it uses:

import svg_path/parse
import svg_path/serialize

pub fn tidy_path_data(input: String) -> String {
  let assert Ok(path) = parse.path(input)

  serialize.path(path)
}

If you want a complete SVG document for debugging or examples, use svg_path/svg with a view box, per-path style strings, and optional styled text labels. This is a deliberately small helper for quick drawings, not a full rendering layer:

import svg_path/svg

pub fn debug_svg(
  things: svg.ThingsToDraw,
  box: svg_path.BoundingBox,
) -> String {
  svg.document(things, view_box: box)
}

Serialization options can use relative commands, commas inside coordinate pairs, smaller whitespace, rounded numbers, fixed decimal places, omitted repeated command letters, and left-padded numbers for visual alignment. The lower-level decimal controls are split into LeftDecimalOptions and RightDecimalOptions.

import svg_path/parse
import svg_path/serialize

pub fn compact_path_data(input: String) -> String {
  let assert Ok(path) = parse.path(input)
  let options =
    serialize.relative_decimal_options(2)
    |> serialize.minimize_whitespace
    |> serialize.repeat_commands(False)
    |> serialize.with_left_padding(serialize.AutoLeftPadding)

  serialize.path_with_options(path, options:)
}

Repeated Command Letters

SVG allows repeated commands of the same type to omit later command letters. Pass False to repeat_commands to use this form.

serialize.default_options()
|> serialize.repeat_commands(False)

For example, repeated line commands may serialize as:

M 0 0 L 10 10 20 20 30 30

instead of:

M 0 0 L 10 10 L 20 20 L 30 30

Newlines

Use with_newlines to choose where the serializer inserts newlines:

serialize.default_options()
|> serialize.with_newlines(serialize.AtSubpaths)

OneLine keeps the path data on one line. AtSubpaths puts each subpath on its own line:

M 0 0 L 10 10 L 20 20 Z
M 100 100 L 110 110 L 120 120 Z

AtSegments puts each segment on its own line. With repeated command letters enabled, each line starts with its command:

M 0 0
L 10 10
L 20 20
Z

The one unusual combination is AtSegments with repeat_commands(False). There, each emitted command letter is followed by a newline, repeated commands are omitted, and M/m always starts a new line. This can be combined with fixed-width decimal formatting for visual alignment:

serialize.fixed_decimal_options(2)
|> serialize.with_left_padding(serialize.AutoLeftPadding)
|> serialize.with_commas(True)
|> serialize.repeat_commands(False)
|> serialize.with_newlines(serialize.AtSegments)
M
  20.00, -30.00 C
 -15.00,  40.00   80.00, -90.00  140.00,  20.00
 260.00,  30.00 -320.00,  45.00  480.00, -60.00
 600.50, -70.25  720.00,  80.00  840.00, -90.00

Left Padding

RightDecimalOptions controls the fractional side of serialized numbers:

LeftDecimalOptions controls the whole-number side:

Use with_left_padding to align serialized numbers visually:

serialize.fixed_decimal_options(1)
|> serialize.with_left_padding(serialize.AutoLeftPadding)

For more explicit control, use with_left_decimals and with_right_decimals:

serialize.default_options()
|> serialize.with_left_decimals(serialize.AutoLeftPadding)
|> serialize.with_right_decimals(serialize.Fixed(2))

Zero-Length Subpaths, Closures, and Move-Only Subpaths

SVG distinguishes move-only subpaths from zero-length drawing subpaths. The subpath consisting only of the command M 0,0 has a current point but no drawing segment, whereas M 0,0 L 0,0 has a zero-length line segment. User agents can render these differently: with stroke-linecap: round or stroke-linecap: square, for example, the zero-length line can produce a visible mark while the move-only subpath remains invisible. SVG 2 describes this in its notes on zero-length path segments and stroke line caps.

The repository includes a small browser probe:

Zero-length closepath probe

The upper half of that file compares these two cases:

<path
  d="M 90,50"
  style="fill: none; stroke: blue; stroke-width: 24; stroke-linecap: round;"
/>
<path
  d="M 260,50 L 260,50"
  style="fill: none; stroke: blue; stroke-width: 24; stroke-linecap: round;"
/>

<path
  d="M 90,120"
  style="fill: none; stroke: blue; stroke-width: 24; stroke-linecap: square;"
/>
<path
  d="M 260,120 L 260,120"
  style="fill: none; stroke: blue; stroke-width: 24; stroke-linecap: square;"
/>

For that reason, serialize.subpath preserves zero-length final lines, and clean_subpath keeps one zero-length line if a subpath consists only of zero-length lines. This preserves the difference between a move-only subpath and a zero-length drawing subpath.

Closed subpaths serialize with Z. By default, if a closed subpath ends with a non-zero-length straight line back to the subpath start, the serializer drops that final line command and uses Z to represent the closure.

As per our knowledge of the SVG 2 specification and the observable behavior of current user agents, we draw no semantic distinction between the inclusion of a final explicit nonzero-jump L command followed by Z versus direct termination of a subpath by Z. Both subpath strings map to the same internal representation including a final nonzero Line() segment and a closed: True field.

However, zero-length final lines are different. If the final segment is Line(A, A), the serializer keeps it visible:

M 0 0 H 0 Z

The same rule applies in relative mode:

m 10 10 h 10 h -10 h 0 Z

The final h 0 remains visible because it is a zero-length line.

Removing Zero-Length Line Segments

Serialization is not a general cleanup pass. It only uses Z to avoid a redundant non-zero-length final closing line.

To remove zero-length straight lines from a subpath, use clean_subpath. To clean a whole path, use clean_path; it removes empty subpaths and runs clean_subpath on each remaining subpath.

import svg_path

pub fn clean(subpath: svg_path.Subpath) -> svg_path.Subpath {
  svg_path.clean_subpath(subpath)
}

pub fn clean_all(path: svg_path.Path) -> svg_path.Path {
  svg_path.clean_path(path)
}

clean_subpath removes zero-length Line segments while preserving the subpath’s closed/open state. If a subpath consists only of zero-length lines, one zero-length line is retained so it remains a zero-length drawing subpath.

Transforming Paths

svg_path/transform applies SVG-style affine transforms to segments, subpaths, and paths.

import svg_path/parse
import svg_path/serialize
import svg_path/transform

pub fn move_path_data(input: String) -> String {
  let assert Ok(path) = parse.path(input)
  let matrix = transform.translate(x: 10.0, y: 20.0)
  let assert Ok(path) = transform.path(path, by: matrix)

  serialize.path(path)
}

Transforms use the SVG six-value affine matrix:

matrix(a b c d e f)

which corresponds to:

x' = a*x + c*y + e
y' = b*x + d*y + f

Matrix values can be constructed and inspected as tuples:

import svg_path/transform

pub fn inspect_transform() -> #(Float, Float, Float, Float, Float, Float) {
  transform.rotate(degrees: 30.0)
  |> transform.to_tuple
}

Use chain(first:, then:) when thinking in application order. Use multiply(left:, right:) when thinking in matrix multiplication order.

import svg_path/transform

pub fn scale_then_move() -> transform.Matrix {
  let scale = transform.scale(factor: 2.0)
  let move = transform.translate(x: 10.0, y: 20.0)

  // Applying scale, then move, is move * scale.
  transform.chain(first: scale, then: move)
  // transform.multiply(left: move, right: scale)
}

Transforms can also be applied about a point, or about one of the nine anchor points on a segment, subpath, or path bounding box:

TopLeft      TopCenter      TopRight
CenterLeft   Center         CenterRight
BottomLeft   BottomCenter   BottomRight
import svg_path
import svg_path/transform

pub fn flip_path_horizontally(
  path: svg_path.Path,
) -> Result(svg_path.Path, transform.Error) {
  path
  |> transform.path_about_anchor(
    by: transform.scale_xy(x: -1.0, y: 1.0),
    anchor: transform.Center,
  )
}

Transform Attributes

SVG transform attributes can be parsed and serialized separately from paths.

import svg_path/transform/parse
import svg_path/transform/serialize

pub fn tidy_transform_attribute(input: String) -> String {
  let assert Ok(matrix) = parse.attribute(input)

  serialize.to_string(matrix)
}

The transform parser accepts normal SVG transform syntax, including compound attributes such as:

translate(10)scale(2) skewX(3)

Transform serialization prefers readable SVG forms when the matrix can be recognized clearly:

translate(10 20)
translate(10 20)scale(2)
rotate(30)
translate(10 20)rotate(30)scale(2 3)

If no clearer representation is available, it falls back to:

matrix(a b c d e f)

Use force_matrix when you want the raw matrix form even if a shorter transform expression could be detected.

import svg_path/transform
import svg_path/transform/serialize

pub fn raw_transform_attribute() -> String {
  transform.translate(x: 10.0, y: 20.0)
  |> serialize.to_string_with_options(
    options: serialize.default_options() |> serialize.force_matrix,
  )
}

Inspecting Paths

svg_path/inspect prints path data structures for debugging and tests. It is not the SVG d serializer.

Human-readable structural inspection:

import svg_path
import svg_path/inspect

pub fn inspect_line() -> String {
  svg_path.Line(
    start: svg_path.point(0.0, 0.0),
    end: svg_path.point(12.0, 10.0),
  )
  |> inspect.segment
}

Example output:

Line(start=0,0 end=12,10)

Copy-pasteable Gleam inspection:

import svg_path
import svg_path/inspect

pub fn inspect_code(path: svg_path.Path) -> String {
  inspect.path_code(path)
}

Example output:

svg_path.Path([
  svg_path.assert_subpath([
    svg_path.Line(start: svg_path.point(0.0, 0.0), end: svg_path.point(12.0, 10.0))
  ])
])

Inspection options support decimal rounding, fixed decimal places, and left-padding for visual alignment. As with serialization, lower-level decimal controls are split into LeftDecimalOptions and RightDecimalOptions, with the same constructors.

import svg_path
import svg_path/inspect

pub fn inspect_aligned(path: svg_path.Path) -> String {
  let options =
    inspect.fixed_decimal_options(1)
    |> inspect.with_left_padding(inspect.AutoLeftPadding)

  inspect.path_code_with_options(path, options:)
}

AutoLeftPadding pre-scans the value being inspected and chooses a shared left-side width for the numbers in that output. LeftPadding(Int) lets you choose the width yourself. Use Succinct to disable left padding.

Converting Matrices From matrix_gleam

svg_path does not depend on matrix_gleam, but the tuple helpers make the conversion small if your application uses both packages.

import matrix/mat3f
import svg_path/transform

pub fn to_mat3f(matrix: transform.Matrix) -> mat3f.Mat3f {
  let #(a, b, c, d, e, f) = transform.to_tuple(matrix)

  mat3f.new(
    a, b, 0.0,
    c, d, 0.0,
    e, f, 1.0,
  )
}
import matrix/mat3f
import svg_path/transform

pub type MatrixConversionError {
  NonAffineMatrix
}

pub fn from_mat3f(
  matrix: mat3f.Mat3f,
) -> Result(transform.Matrix, MatrixConversionError) {
  case matrix.x.z == 0.0 && matrix.y.z == 0.0 && matrix.z.z == 1.0 {
    False -> Error(NonAffineMatrix)
    True -> {
      Ok(transform.from_tuple(#(
        matrix.x.x,
        matrix.x.y,
        matrix.y.x,
        matrix.y.y,
        matrix.z.x,
        matrix.z.y,
      )))
    }
  }
}

Further documentation can be found at https://hexdocs.pm/svg_path.

Development

gleam test
gleam docs build
Search Document