|
| 1 | +package com.demcha.compose.document.svg; |
| 2 | + |
| 3 | +import com.demcha.compose.document.api.Beta; |
| 4 | +import com.demcha.compose.document.style.DocumentPathSegment; |
| 5 | + |
| 6 | +import java.util.ArrayList; |
| 7 | +import java.util.List; |
| 8 | + |
| 9 | +/** |
| 10 | + * Parsed SVG path data (<code><path d="…"></code>), lowered to the |
| 11 | + * canonical {@link DocumentPathSegment} set and normalized into the unit |
| 12 | + * box with the y-axis flipped to the PDF orientation. |
| 13 | + * |
| 14 | + * <p>The full SVG 1.1 path grammar is supported: absolute and relative |
| 15 | + * {@code M L H V C S Q T A Z}, implicit command repetition (including the |
| 16 | + * lineto chain after a moveto), quadratic curves (converted exactly to |
| 17 | + * cubics), smooth shorthands ({@code S}/{@code T} control-point |
| 18 | + * reflection), and elliptical arcs (converted deterministically to cubic |
| 19 | + * spans of at most 90° each via the W3C endpoint-to-center algorithm). |
| 20 | + * Everything a vector editor exports as a {@code d} string lands here as |
| 21 | + * lines, cubics and closes — ready for native PDF curve rendering.</p> |
| 22 | + * |
| 23 | + * <p>Normalization uses the supplied viewBox when one is given (the usual |
| 24 | + * icon workflow, keeping the icon's designed padding) or the tight bounding |
| 25 | + * box of the parsed geometry otherwise. SVG's y-down user space is flipped |
| 26 | + * to the y-up convention of {@code DocumentPathSegment}; fills keep SVG's |
| 27 | + * default non-zero winding semantics.</p> |
| 28 | + * |
| 29 | + * <pre>{@code |
| 30 | + * SvgPath heart = SvgPath.parse(MATERIAL_HEART_D, 0, 0, 24, 24); |
| 31 | + * flow.addPath(path -> path |
| 32 | + * .size(64, 64 / heart.aspectRatio()) |
| 33 | + * .svg(heart) |
| 34 | + * .fillColor(crimson)); |
| 35 | + * }</pre> |
| 36 | + * |
| 37 | + * <p><b>Beta:</b> the SVG surface is new in 1.8.0 and marked {@link Beta} |
| 38 | + * while it hardens against real-world files — the API may still adjust in a |
| 39 | + * minor release based on feedback.</p> |
| 40 | + * |
| 41 | + * @author Artem Demchyshyn |
| 42 | + * @since 1.8.0 |
| 43 | + */ |
| 44 | +@Beta |
| 45 | +public final class SvgPath { |
| 46 | + |
| 47 | + private static final double EPS = 1e-9; |
| 48 | + |
| 49 | + private final List<DocumentPathSegment> segments; |
| 50 | + private final double sourceWidth; |
| 51 | + private final double sourceHeight; |
| 52 | + |
| 53 | + private SvgPath(List<DocumentPathSegment> segments, double sourceWidth, double sourceHeight) { |
| 54 | + this.segments = List.copyOf(segments); |
| 55 | + this.sourceWidth = sourceWidth; |
| 56 | + this.sourceHeight = sourceHeight; |
| 57 | + } |
| 58 | + |
| 59 | + /** |
| 60 | + * Parses SVG path data and normalizes it against the tight bounding box |
| 61 | + * of the parsed geometry (anchor and control points). |
| 62 | + * |
| 63 | + * @param d SVG path data, e.g. {@code "M0 0 C20 40 40 40 60 0 Z"} |
| 64 | + * @return parsed, normalized path |
| 65 | + * @throws IllegalArgumentException if the data is empty, does not start |
| 66 | + * with a moveto, or contains a syntax |
| 67 | + * error (the message carries the |
| 68 | + * character position) |
| 69 | + */ |
| 70 | + public static SvgPath parse(String d) { |
| 71 | + List<double[]> ops = new SvgPathParser(d).parse(); |
| 72 | + double[] box = tightBox(ops); |
| 73 | + return normalize(ops, box[0], box[1], box[2], box[3]); |
| 74 | + } |
| 75 | + |
| 76 | + /** |
| 77 | + * Parses SVG path data and normalizes it against the given viewBox — |
| 78 | + * the usual workflow for icons, where the viewBox preserves the icon's |
| 79 | + * designed padding inside its frame. |
| 80 | + * |
| 81 | + * @param d SVG path data |
| 82 | + * @param minX viewBox min-x |
| 83 | + * @param minY viewBox min-y |
| 84 | + * @param width viewBox width; must be finite and positive |
| 85 | + * @param height viewBox height; must be finite and positive |
| 86 | + * @return parsed, normalized path |
| 87 | + * @throws IllegalArgumentException on syntax errors or a non-positive box |
| 88 | + */ |
| 89 | + public static SvgPath parse(String d, double minX, double minY, double width, double height) { |
| 90 | + if (!(width > 0) || Double.isInfinite(width) || !(height > 0) || Double.isInfinite(height)) { |
| 91 | + throw new IllegalArgumentException( |
| 92 | + "viewBox must have finite positive dimensions: " + width + " x " + height); |
| 93 | + } |
| 94 | + return normalize(new SvgPathParser(d).parse(), minX, minY, width, height); |
| 95 | + } |
| 96 | + |
| 97 | + /** |
| 98 | + * Returns the normalized segments (unit box, y-up), ready for |
| 99 | + * {@code PathBuilder.svg(...)} or a {@code PathNode}. |
| 100 | + * |
| 101 | + * @return immutable normalized segment list |
| 102 | + */ |
| 103 | + public List<DocumentPathSegment> segments() { |
| 104 | + return segments; |
| 105 | + } |
| 106 | + |
| 107 | + /** |
| 108 | + * Returns the source box width in SVG user units (viewBox width or tight |
| 109 | + * bounding-box width). |
| 110 | + * |
| 111 | + * @return source width |
| 112 | + */ |
| 113 | + public double sourceWidth() { |
| 114 | + return sourceWidth; |
| 115 | + } |
| 116 | + |
| 117 | + /** |
| 118 | + * Returns the source box height in SVG user units. |
| 119 | + * |
| 120 | + * @return source height |
| 121 | + */ |
| 122 | + public double sourceHeight() { |
| 123 | + return sourceHeight; |
| 124 | + } |
| 125 | + |
| 126 | + /** |
| 127 | + * Returns the width-to-height ratio of the source box, for sizing the |
| 128 | + * target {@code PathNode} proportionally. |
| 129 | + * |
| 130 | + * @return {@code sourceWidth() / sourceHeight()} |
| 131 | + */ |
| 132 | + public double aspectRatio() { |
| 133 | + return sourceWidth / sourceHeight; |
| 134 | + } |
| 135 | + |
| 136 | + // ------------------------------------------------------------------ |
| 137 | + // Normalization (y-flip into the unit box) |
| 138 | + // ------------------------------------------------------------------ |
| 139 | + |
| 140 | + /** Op encoding: [0]=kind (0 move, 1 line, 2 cubic, 3 close), then coords. */ |
| 141 | + private static SvgPath normalize(List<double[]> ops, |
| 142 | + double minX, double minY, double width, double height) { |
| 143 | + // Degenerate extents (a purely horizontal or vertical path with a |
| 144 | + // tight box) keep coordinates finite by centring on the flat axis. |
| 145 | + boolean flatX = width < EPS; |
| 146 | + boolean flatY = height < EPS; |
| 147 | + double w = flatX ? 1.0 : width; |
| 148 | + double h = flatY ? 1.0 : height; |
| 149 | + double topY = minY + height; |
| 150 | + |
| 151 | + List<DocumentPathSegment> out = new ArrayList<>(ops.size()); |
| 152 | + for (double[] op : ops) { |
| 153 | + switch ((int) op[0]) { |
| 154 | + case 0 -> out.add(DocumentPathSegment.moveTo(nx(op[1], minX, w, flatX), ny(op[2], topY, h, flatY))); |
| 155 | + case 1 -> out.add(DocumentPathSegment.lineTo(nx(op[1], minX, w, flatX), ny(op[2], topY, h, flatY))); |
| 156 | + case 2 -> out.add(DocumentPathSegment.cubicTo( |
| 157 | + nx(op[1], minX, w, flatX), ny(op[2], topY, h, flatY), |
| 158 | + nx(op[3], minX, w, flatX), ny(op[4], topY, h, flatY), |
| 159 | + nx(op[5], minX, w, flatX), ny(op[6], topY, h, flatY))); |
| 160 | + default -> out.add(DocumentPathSegment.close()); |
| 161 | + } |
| 162 | + } |
| 163 | + return new SvgPath(out, width < EPS ? 1.0 : width, height < EPS ? 1.0 : height); |
| 164 | + } |
| 165 | + |
| 166 | + private static double nx(double x, double minX, double w, boolean flat) { |
| 167 | + return flat ? 0.5 : (x - minX) / w; |
| 168 | + } |
| 169 | + |
| 170 | + private static double ny(double y, double topY, double h, boolean flat) { |
| 171 | + return flat ? 0.5 : (topY - y) / h; |
| 172 | + } |
| 173 | + |
| 174 | + private static double[] tightBox(List<double[]> ops) { |
| 175 | + double minX = Double.POSITIVE_INFINITY; |
| 176 | + double minY = Double.POSITIVE_INFINITY; |
| 177 | + double maxX = Double.NEGATIVE_INFINITY; |
| 178 | + double maxY = Double.NEGATIVE_INFINITY; |
| 179 | + for (double[] op : ops) { |
| 180 | + for (int i = 1; i + 1 < op.length; i += 2) { |
| 181 | + minX = Math.min(minX, op[i]); |
| 182 | + maxX = Math.max(maxX, op[i]); |
| 183 | + minY = Math.min(minY, op[i + 1]); |
| 184 | + maxY = Math.max(maxY, op[i + 1]); |
| 185 | + } |
| 186 | + } |
| 187 | + if (minX > maxX) { |
| 188 | + throw new IllegalArgumentException("SVG path data contains no drawable geometry"); |
| 189 | + } |
| 190 | + return new double[]{minX, minY, maxX - minX, maxY - minY}; |
| 191 | + } |
| 192 | +} |
0 commit comments