diff --git a/CHANGES.md b/CHANGES.md index 04ddc309..89fe4c85 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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) diff --git a/manual.pdf b/manual.pdf index bce278b9..fdd08703 100644 Binary files a/manual.pdf and b/manual.pdf differ diff --git a/src/draw.typ b/src/draw.typ index 3443d926..1e750a65 100644 --- a/src/draw.typ +++ b/src/draw.typ @@ -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 diff --git a/src/draw/shapes.typ b/src/draw/shapes.typ index d4baea6f..ffdc1d7d 100644 --- a/src/draw/shapes.typ +++ b/src/draw/shapes.typ @@ -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 { @@ -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", "", [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, + ) + },) +} diff --git a/tests/shapes/svg-path/ref/1.png b/tests/shapes/svg-path/ref/1.png new file mode 100644 index 00000000..33318644 Binary files /dev/null and b/tests/shapes/svg-path/ref/1.png differ diff --git a/tests/shapes/svg-path/test.typ b/tests/shapes/svg-path/test.typ new file mode 100644 index 00000000..d6cd13b8 --- /dev/null +++ b/tests/shapes/svg-path/test.typ @@ -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]) +})