Skip to content

Commit 041e622

Browse files
committed
shapes: Add svg-path element
1 parent f22e375 commit 041e622

File tree

6 files changed

+244
-2
lines changed

6 files changed

+244
-2
lines changed

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
leaks elements (like `scope` does) (#1004)
1515
- Fixed a bug that prevented the leakage of elements of `scope` elements inside
1616
a `group` (#930)
17+
- Added a new element `svg-path` that accepts a list of a subset of SVG
18+
commands to construct paths
1719

1820
# 0.4.2
1921
- The `tree` element now has a `anchor:` argument to position the tree (#929)

manual.pdf

19.3 KB
Binary file not shown.

src/draw.typ

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#import "draw/grouping.typ": intersections, group, scope, anchor, copy-anchors, set-ctx, get-ctx, for-each-anchor, on-layer, hide, floating
22
#import "draw/transformations.typ": set-transform, rotate, translate, scale, set-origin, move-to, set-viewport
33
#import "draw/styling.typ": set-style, fill, stroke, register-mark
4-
#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
4+
#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
55
#import "draw/projection.typ": ortho, on-xy, on-xz, on-yz
66
#import "draw/util.typ": assert-version, register-coordinate-resolver

src/draw/shapes.typ

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -917,7 +917,7 @@
917917

918918
let (from-x, from-y, ..) = from
919919
let (to-x, to-y, ..) = to
920-
920+
921921
// Resolve shift parameter
922922
let shift = style.at("shift", default: 0)
923923
if type(shift) == dictionary {
@@ -2022,3 +2022,160 @@
20222022
})
20232023
return ctx
20242024
}
2025+
2026+
/// Create a new path from a SVG-like list of commands.
2027+
///
2028+
/// The following commands are supported (uppercase command names use absolute coordinates, lowercase use relative coordinates)
2029+
/// - `("l", coordinate)` line to `coordinate`
2030+
/// - `("h", number)` Horizontal line
2031+
/// - `("v", number)` Vertical line
2032+
/// - `("m", coordinate)` Move to `coordinate`
2033+
/// - `("c", ctrl-coordinate-a, ctrl-coordinate-b, coordinate)` Cubic bezier curve to `coordinate` with two control points a and b
2034+
/// - `("q", ctrl-coordinate, coordinate)` Quadratic bezier curve
2035+
/// - `("z",)` Close the current path
2036+
/// - `("anchor", "<anchor-name>", [coordinate=(0, 0)])` named anchor.
2037+
/// If the anchor coordinate is unset, the default `(0, 0, 0)` is used.
2038+
/// The anchor named "default" serves as origin for the `anchor:` argument.
2039+
///
2040+
/// ```example
2041+
/// svg-path(("h", 2),
2042+
/// ("anchor", "here"),
2043+
/// ("c", (0, 1), (0, 0), (-1, 0)),
2044+
/// ("v", -0.5),
2045+
/// ("h", -1),
2046+
/// ("z",), name: "svg")
2047+
/// circle("svg.here", fill: white, radius: 0.1cm)
2048+
/// ```
2049+
///
2050+
/// - name (none, string):
2051+
/// - anchor (none, coordinate):
2052+
/// - ..commands-style (any): Path commands and style keys
2053+
#let svg-path(name: none, anchor: none, ..commands-style) = {
2054+
let style = commands-style.named()
2055+
let commands = commands-style.pos().map(cmd => {
2056+
if type(cmd) == str {
2057+
(cmd,)
2058+
} else {
2059+
cmd
2060+
}
2061+
})
2062+
2063+
assert.ne(commands, (),
2064+
message: "Empty svg-path commands")
2065+
2066+
return (ctx => {
2067+
let paths = ()
2068+
2069+
let origin = (0.0, 0.0, 0.0)
2070+
let current = ()
2071+
2072+
// Dictionary of user anchors
2073+
let anchors = (:)
2074+
2075+
for ((cmd, ..args)) in commands {
2076+
assert(cmd in ("m", "M", "l", "L", "c", "C", "h", "H", "v", "V", "z", "Z", "q", "Q", "anchor", "Anchor"),
2077+
message: "Unknown svg-path command: " + repr(cmd))
2078+
2079+
// Transform lower-case commands to relative coordinates
2080+
let is-relative = cmd in ("m", "l", "c", "h", "v", "q", "anchor")
2081+
let wrap-coordinate = if is-relative {
2082+
x => (rel: x)
2083+
} else {
2084+
x => x
2085+
}
2086+
2087+
// The name of the current anchor command
2088+
let current-name = none
2089+
2090+
if cmd in ("h", "H") {
2091+
assert.eq(args.len(), 1)
2092+
let (x, ..rest) = args
2093+
args = ((x, 0.0, 0.0),)
2094+
cmd = if cmd == "h" { "l" } else { "L" }
2095+
} else if cmd in ("v", "V") {
2096+
assert.eq(args.len(), 1)
2097+
let (y, ..rest) = args
2098+
args = ((0.0, y, 0.0),)
2099+
cmd = if cmd == "v" { "l" } else { "L" }
2100+
} else if cmd in ("anchor", "Anchor") {
2101+
current-name = args.at(0)
2102+
args = if args.len() != 1 {
2103+
args.slice(1)
2104+
} else {
2105+
((0, 0, 0),)
2106+
}
2107+
}
2108+
2109+
// Save the current coordinate before
2110+
// resolving the list of arguments.
2111+
let prev-pt = ctx.prev.pt
2112+
2113+
(ctx, ..args) = coordinate.resolve(ctx, ..args.map(wrap-coordinate))
2114+
2115+
if cmd in ("z", "Z") {
2116+
assert.eq(args.len(), 0)
2117+
if current != () {
2118+
paths.push(path-util.make-subpath(origin, current, closed: cmd == "z"))
2119+
}
2120+
2121+
current = ()
2122+
}
2123+
2124+
if cmd in ("m", "M", "l", "L") {
2125+
if cmd in ("m", "M") {
2126+
assert.eq(args.len(), 1)
2127+
origin = args.at(0, default: (0, 0, 0))
2128+
args.pop()
2129+
}
2130+
2131+
current += args.map(pt => ("l", pt))
2132+
} else if cmd in ("c", "C") {
2133+
assert.eq(args.len(), 3)
2134+
let (c1, c2, pt) = args
2135+
current.push(("c", c1, c2, pt))
2136+
} else if cmd in ("q", "Q") {
2137+
assert.eq(args.len(), 2)
2138+
let (c1, pt) = args
2139+
let (_, pt, c1, c2) = bezier_.quadratic-to-cubic(prev-pt, pt, c1)
2140+
current.push(("c", c1, c2, pt))
2141+
} else if cmd in ("anchor", "Anchor") {
2142+
assert.eq(args.len(), 1)
2143+
assert(current-name not in (none, ""))
2144+
anchors.insert(current-name, args.at(0))
2145+
}
2146+
}
2147+
2148+
if current != () {
2149+
paths.push(path-util.make-subpath(origin, current, closed: false))
2150+
}
2151+
2152+
let style = styles.resolve(ctx.style, merge: style)
2153+
let drawables = drawable.path(paths, stroke: style.stroke, fill: style.fill, fill-rule: style.fill-rule)
2154+
2155+
let (transform, anchors) = anchor_.setup(
2156+
key => anchors.at(key),
2157+
anchors.keys(),
2158+
default: if "default" in anchors { "default" } else { none },
2159+
name: name,
2160+
offset-anchor: anchor,
2161+
transform: ctx.transform,
2162+
// For border anchors we would need a radius + a "center" anchor.
2163+
// border-anchors: "center" in anchors,
2164+
path-anchors: true,
2165+
path: drawables,
2166+
)
2167+
2168+
if mark_.check-mark(style.mark) {
2169+
drawables = mark_.place-marks-along-path(ctx, style.mark, transform, drawables)
2170+
} else {
2171+
drawables = drawable.apply-transform(transform, drawables)
2172+
}
2173+
2174+
return (
2175+
ctx: ctx,
2176+
name: name,
2177+
anchors: anchors,
2178+
drawables: drawables,
2179+
)
2180+
},)
2181+
}

tests/shapes/svg-path/ref/1.png

9.42 KB
Loading

tests/shapes/svg-path/test.typ

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#set page(width: auto, height: auto)
2+
#import "/src/lib.typ": *
3+
#import "/tests/helper.typ": *
4+
5+
#import draw: svg-path, set-style, content
6+
7+
#test-case({
8+
svg-path(
9+
("L", (1,0)),
10+
)
11+
})
12+
13+
#test-case({
14+
svg-path(
15+
("H", 1),
16+
)
17+
})
18+
19+
#test-case({
20+
svg-path(
21+
("V", 1),
22+
)
23+
})
24+
25+
#test-case({
26+
svg-path(
27+
("C", (0,1), (1,1), (1,0)),
28+
)
29+
})
30+
31+
#test-case({
32+
svg-path(
33+
("Q", (1/2,1), (1,0)),
34+
)
35+
})
36+
37+
#test-case({
38+
svg-path(
39+
("h", 1),
40+
("v", 1),
41+
("h", -1),
42+
"z"
43+
)
44+
})
45+
46+
#test-case({
47+
svg-path(
48+
("h", 1),
49+
("c", (1,0), (0,1), (-1,0)),
50+
("h", -1),
51+
"z",
52+
)
53+
})
54+
55+
// Test that marks work
56+
#test-case({
57+
svg-path(
58+
("h", 1),
59+
("c", (1,0), (0,1), (-1,0)),
60+
("h", -1),
61+
mark: (start: ">", end: ">")
62+
)
63+
})
64+
65+
// Test anchors
66+
#test-case({
67+
draw.circle((0,0), stroke: red, radius: 0.3cm)
68+
svg-path(
69+
("anchor", "default", (0,0)),
70+
("h", 1),
71+
("anchor", "a"),
72+
("c", (1,0), (0,1), (-1,0)),
73+
("anchor", "b", (0, 0)),
74+
("h", -1),
75+
("anchor", "c", (2, -1/2)),
76+
name: "svg", anchor: "b"
77+
)
78+
79+
set-style(content: (frame: "circle", padding: 0.01, fill: white))
80+
content("svg.a", [A])
81+
content("svg.b", [B])
82+
content("svg.c", [C])
83+
})

0 commit comments

Comments
 (0)