Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
leaks elements (like `scope` does) (#1004)
- Fixed a bug that prevented the leakage of elements of `scope` elements inside
a `group` (#930)
- Added a new element `svg-path` that accepts a subset of SVG
commands to construct paths

# 0.4.2
- The `tree` element now has a `anchor:` argument to position the tree (#929)
Expand Down
Binary file modified manual.pdf
Binary file not shown.
2 changes: 1 addition & 1 deletion src/draw.typ
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#import "draw/grouping.typ": intersections, group, scope, anchor, copy-anchors, set-ctx, get-ctx, for-each-anchor, on-layer, hide, floating
#import "draw/transformations.typ": set-transform, rotate, translate, scale, set-origin, move-to, set-viewport
#import "draw/styling.typ": set-style, fill, stroke, register-mark
#import "draw/shapes.typ": circle, circle-through, arc, arc-through, mark, line, grid, content, rect, bezier, bezier-through, catmull, hobby, merge-path, polygon, compound-path, n-star, rect-around
#import "draw/shapes.typ": circle, circle-through, arc, arc-through, mark, line, grid, content, rect, bezier, bezier-through, catmull, hobby, merge-path, polygon, compound-path, n-star, rect-around, svg-path
#import "draw/projection.typ": ortho, on-xy, on-xz, on-yz
#import "draw/util.typ": assert-version, register-coordinate-resolver
159 changes: 158 additions & 1 deletion src/draw/shapes.typ
Original file line number Diff line number Diff line change
Expand Up @@ -917,7 +917,7 @@

let (from-x, from-y, ..) = from
let (to-x, to-y, ..) = to

// Resolve shift parameter
let shift = style.at("shift", default: 0)
if type(shift) == dictionary {
Expand Down Expand Up @@ -2022,3 +2022,160 @@
})
return ctx
}

/// Create a new path from a SVG-like list of commands.
///
/// The following commands are supported (uppercase command names use absolute coordinates, lowercase use relative coordinates)
/// - `("l", coordinate)` line to `coordinate`
/// - `("h", number)` Horizontal line
/// - `("v", number)` Vertical line
/// - `("m", coordinate)` Move to `coordinate`
/// - `("c", ctrl-coordinate-a, ctrl-coordinate-b, coordinate)` Cubic bezier curve to `coordinate` with two control points a and b
/// - `("q", ctrl-coordinate, coordinate)` Quadratic bezier curve
/// - `("z",)` Close the current path
/// - `("anchor", "<anchor-name>", [coordinate=(0, 0)])` named anchor.
/// If the anchor coordinate is unset, the default `(0, 0, 0)` is used.
/// The anchor named "default" serves as origin for the `anchor:` argument.
///
/// ```example
/// svg-path(("h", 2),
/// ("anchor", "here"),
/// ("c", (0, 1), (0, 0), (-1, 0)),
/// ("v", -0.5),
/// ("h", -1),
/// ("z",), name: "svg")
/// circle("svg.here", fill: white, radius: 0.1cm)
/// ```
///
/// - name (none, string):
/// - anchor (none, coordinate):
/// - ..commands-style (any): Path commands and style keys
#let svg-path(name: none, anchor: none, ..commands-style) = {
let style = commands-style.named()
let commands = commands-style.pos().map(cmd => {
if type(cmd) == str {
(cmd,)
} else {
cmd
}
})

assert.ne(commands, (),
message: "Empty svg-path commands")

return (ctx => {
let paths = ()

let origin = (0.0, 0.0, 0.0)
let current = ()

// Dictionary of user anchors
let anchors = (:)

for ((cmd, ..args)) in commands {
assert(cmd in ("m", "M", "l", "L", "c", "C", "h", "H", "v", "V", "z", "Z", "q", "Q", "anchor", "Anchor"),
message: "Unknown svg-path command: " + repr(cmd))

// Transform lower-case commands to relative coordinates
let is-relative = cmd in ("m", "l", "c", "h", "v", "q", "anchor")
let wrap-coordinate = if is-relative {
x => (rel: x)
} else {
x => x
}

// The name of the current anchor command
let current-name = none

if cmd in ("h", "H") {
assert.eq(args.len(), 1)
let (x, ..rest) = args
args = ((x, 0.0, 0.0),)
cmd = if cmd == "h" { "l" } else { "L" }
} else if cmd in ("v", "V") {
assert.eq(args.len(), 1)
let (y, ..rest) = args
args = ((0.0, y, 0.0),)
cmd = if cmd == "v" { "l" } else { "L" }
} else if cmd in ("anchor", "Anchor") {
current-name = args.at(0)
args = if args.len() != 1 {
args.slice(1)
} else {
((0, 0, 0),)
}
}

// Save the current coordinate before
// resolving the list of arguments.
let prev-pt = ctx.prev.pt

(ctx, ..args) = coordinate.resolve(ctx, ..args.map(wrap-coordinate))

if cmd in ("z", "Z") {
assert.eq(args.len(), 0)
if current != () {
paths.push(path-util.make-subpath(origin, current, closed: cmd == "z"))
}

current = ()
}

if cmd in ("m", "M", "l", "L") {
if cmd in ("m", "M") {
assert.eq(args.len(), 1)
origin = args.at(0, default: (0, 0, 0))
args.pop()
}

current += args.map(pt => ("l", pt))
} else if cmd in ("c", "C") {
assert.eq(args.len(), 3)
let (c1, c2, pt) = args
current.push(("c", c1, c2, pt))
} else if cmd in ("q", "Q") {
assert.eq(args.len(), 2)
let (c1, pt) = args
let (_, pt, c1, c2) = bezier_.quadratic-to-cubic(prev-pt, pt, c1)
current.push(("c", c1, c2, pt))
} else if cmd in ("anchor", "Anchor") {
assert.eq(args.len(), 1)
assert(current-name not in (none, ""))
anchors.insert(current-name, args.at(0))
}
}

if current != () {
paths.push(path-util.make-subpath(origin, current, closed: false))
}

let style = styles.resolve(ctx.style, merge: style)
let drawables = drawable.path(paths, stroke: style.stroke, fill: style.fill, fill-rule: style.fill-rule)

let (transform, anchors) = anchor_.setup(
key => anchors.at(key),
anchors.keys(),
default: if "default" in anchors { "default" } else { none },
name: name,
offset-anchor: anchor,
transform: ctx.transform,
// For border anchors we would need a radius + a "center" anchor.
// border-anchors: "center" in anchors,
path-anchors: true,
path: drawables,
)

if mark_.check-mark(style.mark) {
drawables = mark_.place-marks-along-path(ctx, style.mark, transform, drawables)
} else {
drawables = drawable.apply-transform(transform, drawables)
}

return (
ctx: ctx,
name: name,
anchors: anchors,
drawables: drawables,
)
},)
}
Binary file added tests/shapes/svg-path/ref/1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 83 additions & 0 deletions tests/shapes/svg-path/test.typ
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#set page(width: auto, height: auto)
#import "/src/lib.typ": *
#import "/tests/helper.typ": *

#import draw: svg-path, set-style, content

#test-case({
svg-path(
("L", (1,0)),
)
})

#test-case({
svg-path(
("H", 1),
)
})

#test-case({
svg-path(
("V", 1),
)
})

#test-case({
svg-path(
("C", (0,1), (1,1), (1,0)),
)
})

#test-case({
svg-path(
("Q", (1/2,1), (1,0)),
)
})

#test-case({
svg-path(
("h", 1),
("v", 1),
("h", -1),
"z"
)
})

#test-case({
svg-path(
("h", 1),
("c", (1,0), (0,1), (-1,0)),
("h", -1),
"z",
)
})

// Test that marks work
#test-case({
svg-path(
("h", 1),
("c", (1,0), (0,1), (-1,0)),
("h", -1),
mark: (start: ">", end: ">")
)
})

// Test anchors
#test-case({
draw.circle((0,0), stroke: red, radius: 0.3cm)
svg-path(
("anchor", "default", (0,0)),
("h", 1),
("anchor", "a"),
("c", (1,0), (0,1), (-1,0)),
("anchor", "b", (0, 0)),
("h", -1),
("anchor", "c", (2, -1/2)),
name: "svg", anchor: "b"
)

set-style(content: (frame: "circle", padding: 0.01, fill: white))
content("svg.a", [A])
content("svg.b", [B])
content("svg.c", [C])
})
Loading