Merge changes from topic "gitiles-source-submodule" * changes: Mark war-provided libraries as neverlink inside the Gerrit tree Make webassets packaging repo-path independent
diff --git a/java/com/google/gitiles/Renderer.java b/java/com/google/gitiles/Renderer.java index c8c1ba6..0087e04 100644 --- a/java/com/google/gitiles/Renderer.java +++ b/java/com/google/gitiles/Renderer.java
@@ -99,7 +99,7 @@ .put("gitiles.FAVICON_32_URL", "favicon-32x32.png") .put("gitiles.FAVICON_16_URL", "favicon-16x16.png") .put("gitiles.APPLE_TOUCH_ICON_URL", "apple-touch-icon.png") - .build(); + .buildOrThrow(); protected static Function<String, URL> fileUrlMapper() { return fileUrlMapper(""); @@ -140,7 +140,7 @@ for (URL u : customTemplates) { b.put(u.toString(), u); } - templates = b.build(); + templates = b.buildOrThrow(); Map<String, String> allGlobals = Maps.newHashMap(); for (Map.Entry<String, String> e : STATIC_URL_GLOBALS.entrySet()) { @@ -152,12 +152,7 @@ } public HashCode getTemplateHash(String soyFile) { - HashCode h = hashes.get(soyFile); - if (h == null) { - h = computeTemplateHash(soyFile); - hashes.put(soyFile, h); - } - return h; + return hashes.computeIfAbsent(soyFile, this::computeTemplateHash); } HashCode computeTemplateHash(String soyFile) { @@ -239,8 +234,8 @@ @Override public void close() throws IOException { - try (OutputStream o = out) { - o.write(tail); + try (out) { + out.write(tail); } } }; @@ -259,7 +254,7 @@ } ImmutableMap.Builder<String, Object> ij = ImmutableMap.<String, Object>builder() - .put("staticUrls", staticUrls.build()) + .put("staticUrls", staticUrls.buildOrThrow()) .put("SITE_TITLE", siteTitle) .put("THEME_INIT_SCRIPT", THEME_INIT_SCRIPT) .put("THEME_TOGGLE_SCRIPT", THEME_TOGGLE_SCRIPT); @@ -267,7 +262,7 @@ if (nonce.isPresent()) { ij.put("csp_nonce", nonce.get()); } - return getSauce().renderTemplate(templateName).setIj(ij.build()); + return getSauce().renderTemplate(templateName).setIj(ij.buildOrThrow()); } protected abstract SoySauce getSauce();
diff --git a/java/com/google/gitiles/blame/cache/BlameCache.java b/java/com/google/gitiles/blame/cache/BlameCache.java index 7cb7bad..2dc7216 100644 --- a/java/com/google/gitiles/blame/cache/BlameCache.java +++ b/java/com/google/gitiles/blame/cache/BlameCache.java
@@ -20,6 +20,7 @@ import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Repository; +/** Cache for blame information across repositories and commits. */ public interface BlameCache { /** * Gets the blame of a path at a given commit. @@ -33,8 +34,7 @@ * * @return the blame of a path at a given commit. */ - default List<Region> get( - Repository repo, ObjectId commitId, String path, Set<ObjectId> ignoreIds) + default List<Region> get(Repository repo, ObjectId commitId, String path, Set<ObjectId> ignoreIds) throws IOException { if (ignoreIds == null || ignoreIds.isEmpty()) { return get(repo, commitId, path);
diff --git a/java/com/google/gitiles/blame/cache/BlameCacheImpl.java b/java/com/google/gitiles/blame/cache/BlameCacheImpl.java index 1bed1c2..44d4617 100644 --- a/java/com/google/gitiles/blame/cache/BlameCacheImpl.java +++ b/java/com/google/gitiles/blame/cache/BlameCacheImpl.java
@@ -55,6 +55,7 @@ return builder.weigher((k, v) -> v.size()); } + /** Cache key identifying a path at a commit with optional ignored revisions. */ public static class Key { private final ObjectId commitId; private final String path; @@ -84,8 +85,7 @@ @Override public boolean equals(Object o) { - if (o instanceof Key) { - Key k = (Key) o; + if (o instanceof Key k) { return Objects.equals(commitId, k.commitId) && Objects.equals(path, k.path) && Objects.equals(ignoreIds, k.ignoreIds); @@ -125,6 +125,10 @@ this(defaultBuilder()); } + public BlameCacheImpl(CacheBuilder<? super Key, ? super List<Region>> builder) { + this.cache = builder.build(); + } + public Cache<Key, List<Region>> getCache() { return cache; } @@ -133,18 +137,13 @@ return () -> loadBlame(key, repo); } - public BlameCacheImpl(CacheBuilder<? super Key, ? super List<Region>> builder) { - this.cache = builder.build(); - } - @Override public List<Region> get(Repository repo, ObjectId commitId, String path) throws IOException { return get(repo, commitId, path, ImmutableSet.of()); } @Override - public List<Region> get( - Repository repo, ObjectId commitId, String path, Set<ObjectId> ignoreIds) + public List<Region> get(Repository repo, ObjectId commitId, String path, Set<ObjectId> ignoreIds) throws IOException { try { Key key = new Key(commitId, path, ignoreIds); @@ -202,7 +201,7 @@ } } - public static List<Region> loadRegions(BlameGenerator gen) throws IOException { + public static ImmutableList<Region> loadRegions(BlameGenerator gen) throws IOException { Map<ObjectId, PooledCommit> commits = Maps.newHashMap(); Interner<String> strings = Interners.newStrongInterner(); int lineCount = gen.getResultContents().size(); @@ -222,8 +221,8 @@ new PersonIdent( strings.intern(author.getName()), strings.intern(author.getEmailAddress()), - author.getWhen(), - author.getTimeZone())); + author.getWhenAsInstant(), + author.getZoneId())); commits.put(pc.commit, pc); } path = strings.intern(path);
diff --git a/java/com/google/gitiles/doc/MarkdownConfig.java b/java/com/google/gitiles/doc/MarkdownConfig.java index d16380a..8490c19 100644 --- a/java/com/google/gitiles/doc/MarkdownConfig.java +++ b/java/com/google/gitiles/doc/MarkdownConfig.java
@@ -16,11 +16,13 @@ import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; +import java.util.Objects; import java.util.Set; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.lib.Config.SectionParser; import org.eclipse.jgit.util.StringUtils; +/** Configuration options for markdown parsing and rendering in Gitiles. */ public class MarkdownConfig { public static final int IMAGE_LIMIT = 256 << 10; @@ -82,7 +84,8 @@ if (safeHtml) { f = cfg.getStringList("markdown", null, "allowiframe"); } - allowAnyIFrame = f.length == 1 && Boolean.TRUE.equals(StringUtils.toBooleanOrNull(f[0])); + allowAnyIFrame = + f.length == 1 && Objects.equals(StringUtils.toBooleanOrNull(f[0]), Boolean.TRUE); if (allowAnyIFrame) { allowIFrame = ImmutableList.of(); } else { @@ -109,12 +112,12 @@ toc = on("toc", p.toc, enable, disable); mermaid = on("mermaid", p.mermaid, enable, disable); - allowAnyIFrame = safeHtml ? p.allowAnyIFrame : false; + allowAnyIFrame = safeHtml && p.allowAnyIFrame; allowIFrame = safeHtml ? p.allowIFrame : ImmutableList.of(); } private static boolean on(String key, boolean val, Set<String> enable, Set<String> disable) { - return enable.contains(key) ? true : disable.contains(key) ? false : val; + return enable.contains(key) || (disable.contains(key) ? false : val); } boolean isIFrameAllowed(String src) {
diff --git a/java/com/google/gitiles/doc/MarkdownToHtml.java b/java/com/google/gitiles/doc/MarkdownToHtml.java index d36e18b..4afe8b3 100644 --- a/java/com/google/gitiles/doc/MarkdownToHtml.java +++ b/java/com/google/gitiles/doc/MarkdownToHtml.java
@@ -17,15 +17,19 @@ import static com.google.gitiles.doc.MarkdownUtil.getInnerText; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Ascii; import com.google.common.base.MoreObjects; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.html.types.LegacyConversions; import com.google.common.html.types.SafeHtml; +import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.gitiles.GitilesView; import com.google.gitiles.ThreadSafePrettifyParser; import com.google.gitiles.doc.html.HtmlBuilder; import com.google.gitiles.doc.html.SoyHtmlBuilder; -import java.util.Collections; import java.util.List; +import java.util.Optional; import javax.annotation.Nullable; import org.commonmark.ext.front.matter.YamlFrontMatterBlock; import org.commonmark.ext.gfm.strikethrough.Strikethrough; @@ -76,6 +80,7 @@ return new Builder(); } + /** A builder for {@link MarkdownToHtml}. */ public static class Builder { private String requestUri; private GitilesView view; @@ -87,36 +92,43 @@ Builder() {} + @CanIgnoreReturnValue public Builder setRequestUri(@Nullable String uri) { requestUri = uri; return this; } + @CanIgnoreReturnValue public Builder setGitilesView(@Nullable GitilesView view) { this.view = view; return this; } + @CanIgnoreReturnValue public Builder setConfig(@Nullable MarkdownConfig config) { this.config = config; return this; } + @CanIgnoreReturnValue public Builder setFilePath(@Nullable String filePath) { this.filePath = Strings.emptyToNull(filePath); return this; } + @CanIgnoreReturnValue public Builder setReader(ObjectReader reader) { this.reader = reader; return this; } + @CanIgnoreReturnValue public Builder setRootTree(RevTree tree) { this.root = tree; return this; } + @CanIgnoreReturnValue public Builder setHtmlSanitizer(HtmlSanitizer htmlSanitizer) { this.htmlSanitizer = MoreObjects.firstNonNull(htmlSanitizer, HtmlSanitizer.DISABLED); return this; @@ -150,7 +162,8 @@ return html; } - private static @Nullable ImageLoader newImageLoader(Builder b) { + @Nullable + private static ImageLoader newImageLoader(Builder b) { if (b.reader != null && b.view != null && b.config != null && b.root != null) { return new ImageLoader(b.reader, b.view, b.config, b.root); } @@ -171,7 +184,8 @@ } /** Render the document AST to sanitized HTML. */ - public @Nullable SafeHtml toSoyHtml(Node node) { + @Nullable + public SafeHtml toSoyHtml(Node node) { if (node != null) { SoyHtmlBuilder out = new SoyHtmlBuilder(); renderToHtml(out, node); @@ -244,11 +258,12 @@ .close("span") .close("a"); // github markdown compatibility - if (!id.equals(id.toLowerCase())) { + String lowerId = Ascii.toLowerCase(id); + if (!id.equals(lowerId)) { html.open("a") .attribute("class", "h") - .attribute("name", id.toLowerCase()) - .attribute("href", "#" + id.toLowerCase()) + .attribute("name", lowerId) + .attribute("href", "#" + lowerId) .open("span") .close("span") .close("a"); @@ -275,15 +290,6 @@ } } - private static boolean isInTightList(Paragraph c) { - Block b = c.getParent(); // b is probably a ListItem - if (b != null) { - Block a = b.getParent(); - return a instanceof ListBlock && ((ListBlock) a).isTight(); - } - return false; - } - @Override public void visit(BlockQuote node) { wrapChildren("blockquote", node); @@ -292,8 +298,8 @@ @Override public void visit(OrderedList node) { html.open("ol"); - if (node.getStartNumber() != 1) { - html.attribute("start", Integer.toString(node.getStartNumber())); + if (node.getMarkerStartNumber() != 1) { + html.attribute("start", Integer.toString(node.getMarkerStartNumber())); } visitChildren(node); html.close("ol"); @@ -312,10 +318,10 @@ @Override public void visit(FencedCodeBlock node) { if (config != null && config.mermaid && isMermaid(node.getInfo())) { - java.util.Optional<String> svg = SimpleMermaidRenderer.renderToSvg(node.getLiteral()); + Optional<String> svg = SimpleMermaidRenderer.renderToSvg(node.getLiteral()); if (svg.isPresent()) { html.open("div").attribute("class", "mermaid-container"); - html.append(com.google.common.html.types.LegacyConversions.riskilyAssumeSafeHtml(svg.get())); + html.append(LegacyConversions.riskilyAssumeSafeHtml(svg.get())); html.close("div"); return; } @@ -323,15 +329,157 @@ codeInPre(node.getInfo(), node.getLiteral()); } - private static boolean isMermaid(@Nullable String info) { - return info != null && "mermaid".equalsIgnoreCase(info.trim()); - } - @Override public void visit(IndentedCodeBlock node) { codeInPre(null, node.getLiteral()); } + @Override + public void visit(Code node) { + html.open("code").attribute("class", "code").appendAndEscape(node.getLiteral()).close("code"); + } + + @Override + public void visit(Emphasis node) { + wrapChildren("em", node); + } + + @Override + public void visit(StrongEmphasis node) { + wrapChildren("strong", node); + } + + @Override + public void visit(Link node) { + html.open("a") + .attribute("href", href(node.getDestination())) + .attribute("title", node.getTitle()); + visitChildren(node); + html.close("a"); + } + + @Override + public void visit(LinkReferenceDefinition node) { + // Ignored in rendered output + } + + @Override + public void visit(Image node) { + html.open("img") + .attribute("src", image(node.getDestination())) + .attribute("title", node.getTitle()) + .attribute("alt", getInnerText(node)); + } + + public void visit(TableBlock node) { + wrapChildren("table", node); + } + + private void visit(TableRow node) { + wrapChildren("tr", node); + } + + private void visit(TableCell cell) { + String tag = cell.isHeader() ? "th" : "td"; + html.open(tag); + TableCell.Alignment alignment = cell.getAlignment(); + if (alignment != null) { + html.attribute("align", toHtml(alignment)); + } + visitChildren(cell); + html.close(tag); + } + + private void visit(SmartQuoted node) { + switch (node.getType()) { + case DOUBLE -> { + html.entity("“"); + visitChildren(node); + html.entity("”"); + } + case SINGLE -> { + html.entity("‘"); + visitChildren(node); + html.entity("’"); + } + } + } + + @Override + public void visit(Text node) { + html.appendAndEscape(node.getLiteral()); + } + + @Override + public void visit(SoftLineBreak node) { + html.space(); + } + + @Override + public void visit(HardLineBreak node) { + html.open("br"); + } + + @Override + public void visit(ThematicBreak thematicBreak) { + html.open("hr"); + } + + @Override + public void visit(HtmlInline node) { + // Discard inline HTML, as it's always partial tags. + } + + @Override + public void visit(HtmlBlock node) { + html.append(htmlSanitizer.sanitize(node.getLiteral())); + } + + @Override + public void visit(CustomNode node) { + switch (node) { + case NamedAnchor na -> visit(na); + case SmartQuoted sq -> visit(sq); + case Strikethrough st -> wrapChildren("del", st); + case TableBody tb -> wrapChildren("tbody", tb); + case TableCell tc -> visit(tc); + case TableHead th -> wrapChildren("thead", th); + case TableRow tr -> visit(tr); + default -> throw new IllegalArgumentException("cannot render " + node.getClass()); + } + } + + @Override + public void visit(CustomBlock node) { + switch (node) { + case BlockNote bn -> visit(bn); + case IframeBlock ib -> visit(ib); + case MultiColumnBlock mcb -> visit(mcb); + case MultiColumnBlock.Column col -> visit(col); + case TableBlock tb -> visit(tb); + case TocBlock tb -> toc.format(); + case YamlFrontMatterBlock yfmb -> { + // YAML front matter is document metadata: omit the whole block. We + // intentionally do not recurse into it, so its YamlFrontMatterNode + // children are never visited and need no visit(CustomNode) handling. + } + default -> throw new IllegalArgumentException("cannot render " + node.getClass()); + } + } + + private static boolean isInTightList(Paragraph c) { + Block b = c.getParent(); // b is probably a ListItem + if (b != null) { + Block a = b.getParent(); + return a instanceof ListBlock listBlock && listBlock.isTight(); + } + return false; + } + + private static boolean isMermaid(@Nullable String info) { + return info != null && Ascii.equalsIgnoreCase("mermaid", info.trim()); + } + private void codeInPre(String lang, String text) { html.open("pre").attribute("class", "code"); text = printLeadingBlankLines(text); @@ -374,44 +522,15 @@ private List<ParseResult> parse(@Nullable String lang, String text) { if (Strings.isNullOrEmpty(lang)) { - return Collections.emptyList(); + return ImmutableList.of(); } try { return ThreadSafePrettifyParser.INSTANCE.parse(lang, text); } catch (StackOverflowError e) { - return Collections.emptyList(); + return ImmutableList.of(); } } - @Override - public void visit(Code node) { - html.open("code").attribute("class", "code").appendAndEscape(node.getLiteral()).close("code"); - } - - @Override - public void visit(Emphasis node) { - wrapChildren("em", node); - } - - @Override - public void visit(StrongEmphasis node) { - wrapChildren("strong", node); - } - - @Override - public void visit(Link node) { - html.open("a") - .attribute("href", href(node.getDestination())) - .attribute("title", node.getTitle()); - visitChildren(node); - html.close("a"); - } - - @Override - public void visit(LinkReferenceDefinition node) { - // Ignored in rendered output - } - @VisibleForTesting String href(String target) { if (target.startsWith("#") @@ -448,14 +567,6 @@ return PathResolver.relative(requestUri, dest) + anchor; } - @Override - public void visit(Image node) { - html.open("img") - .attribute("src", image(node.getDestination())) - .attribute("title", node.getTitle()) - .attribute("alt", getInnerText(node)); - } - String image(String dest) { if (HtmlBuilder.isValidHttpUri(dest) || HtmlBuilder.isImageDataUri(dest)) { return dest; @@ -465,83 +576,12 @@ return SoyConstants.IMAGE_URI_INNOCUOUS_OUTPUT; } - public void visit(TableBlock node) { - wrapChildren("table", node); - } - - private void visit(TableRow node) { - wrapChildren("tr", node); - } - - private void visit(TableCell cell) { - String tag = cell.isHeader() ? "th" : "td"; - html.open(tag); - TableCell.Alignment alignment = cell.getAlignment(); - if (alignment != null) { - html.attribute("align", toHtml(alignment)); - } - visitChildren(cell); - html.close(tag); - } - private static String toHtml(TableCell.Alignment alignment) { - switch (alignment) { - case LEFT: - return "left"; - case CENTER: - return "center"; - case RIGHT: - return "right"; - default: - throw new IllegalArgumentException("unsupported alignment " + alignment); - } - } - - private void visit(SmartQuoted node) { - switch (node.getType()) { - case DOUBLE: - html.entity("“"); - visitChildren(node); - html.entity("”"); - break; - case SINGLE: - html.entity("‘"); - visitChildren(node); - html.entity("’"); - break; - default: - throw new IllegalArgumentException("unsupported quote " + node.getType()); - } - } - - @Override - public void visit(Text node) { - html.appendAndEscape(node.getLiteral()); - } - - @Override - public void visit(SoftLineBreak node) { - html.space(); - } - - @Override - public void visit(HardLineBreak node) { - html.open("br"); - } - - @Override - public void visit(ThematicBreak thematicBreak) { - html.open("hr"); - } - - @Override - public void visit(HtmlInline node) { - // Discard inline HTML, as it's always partial tags. - } - - @Override - public void visit(HtmlBlock node) { - html.append(htmlSanitizer.sanitize(node.getLiteral())); + return switch (alignment) { + case LEFT -> "left"; + case CENTER -> "center"; + case RIGHT -> "right"; + }; } private void wrapChildren(String tag, Node node) { @@ -555,48 +595,4 @@ c.accept(this); } } - - @Override - public void visit(CustomNode node) { - if (node instanceof NamedAnchor) { - visit((NamedAnchor) node); - } else if (node instanceof SmartQuoted) { - visit((SmartQuoted) node); - } else if (node instanceof Strikethrough) { - wrapChildren("del", node); - } else if (node instanceof TableBody) { - wrapChildren("tbody", node); - } else if (node instanceof TableCell) { - visit((TableCell) node); - } else if (node instanceof TableHead) { - wrapChildren("thead", node); - } else if (node instanceof TableRow) { - visit((TableRow) node); - } else { - throw new IllegalArgumentException("cannot render " + node.getClass()); - } - } - - @Override - public void visit(CustomBlock node) { - if (node instanceof BlockNote) { - visit((BlockNote) node); - } else if (node instanceof IframeBlock) { - visit((IframeBlock) node); - } else if (node instanceof MultiColumnBlock) { - visit((MultiColumnBlock) node); - } else if (node instanceof MultiColumnBlock.Column) { - visit((MultiColumnBlock.Column) node); - } else if (node instanceof TableBlock) { - visit((TableBlock) node); - } else if (node instanceof TocBlock) { - toc.format(); - } else if (node instanceof YamlFrontMatterBlock) { - // YAML front matter is document metadata: omit the whole block. We - // intentionally do not recurse into it, so its YamlFrontMatterNode - // children are never visited and need no visit(CustomNode) handling. - } else { - throw new IllegalArgumentException("cannot render " + node.getClass()); - } - } }
diff --git a/java/com/google/gitiles/doc/SimpleMermaidRenderer.java b/java/com/google/gitiles/doc/SimpleMermaidRenderer.java index da7aca5..140fc44 100644 --- a/java/com/google/gitiles/doc/SimpleMermaidRenderer.java +++ b/java/com/google/gitiles/doc/SimpleMermaidRenderer.java
@@ -14,6 +14,12 @@ package com.google.gitiles.doc; +import static com.google.common.base.Strings.isNullOrEmpty; +import static com.google.common.base.Strings.nullToEmpty; +import static com.google.common.primitives.Doubles.max; + +import com.google.common.base.Ascii; +import com.google.common.collect.Iterables; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; @@ -25,7 +31,6 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.TreeMap; @@ -37,11 +42,12 @@ * Server-side AST parser, layout engine, and SVG renderer for Mermaid flowchart and graph diagrams. * * <p>Implements a pure streaming character-scanner AST parser without regex splits, hierarchical - * Sugiyama DAG layout with cycle breaking, crossing reduction, arbitrary nested subgraphs, - * dynamic edge clearances, bidirectional curved paths, and responsive SVG emission. + * Sugiyama DAG layout with cycle breaking, crossing reduction, arbitrary nested subgraphs, dynamic + * edge clearances, bidirectional curved paths, and responsive SVG emission. */ -public class SimpleMermaidRenderer { +public final class SimpleMermaidRenderer { + /** Graph layout flow direction. */ public enum Direction { LR, TD, @@ -50,6 +56,7 @@ BT } + /** Visual shape of a graph node. */ public enum NodeShape { RECTANGLE, ROUNDED, @@ -62,6 +69,7 @@ FLAG } + /** Stroke styling of a connecting edge. */ public enum EdgeStroke { SOLID, DASHED, @@ -72,6 +80,7 @@ // AST Model Objects // ========================================================================= + /** A node in the Mermaid diagram. */ public static class Node { public final String id; public String label; @@ -87,8 +96,8 @@ public Subgraph parentSubgraph; public double barycenter = 0; public boolean isVirtual = false; - public @Nullable String customFill; - public @Nullable String customStroke; + @Nullable public String customFill; + @Nullable public String customStroke; public Node(String id) { this.id = id; @@ -102,6 +111,7 @@ } } + /** A logical subgraph or cluster containing nodes and nested subgraphs. */ public static class Subgraph { public final String id; public String title; @@ -115,8 +125,8 @@ public double y; public double width; public double height; - public @Nullable String customFill; - public @Nullable String customStroke; + @Nullable public String customFill; + @Nullable public String customStroke; public Subgraph(String id, String title) { this.id = id; @@ -124,6 +134,7 @@ } } + /** A directional or bidirectional edge between nodes. */ public static class Edge { public final String fromId; public final String toId; @@ -142,6 +153,7 @@ } } + /** An edge between subgraphs. */ public static class SubgraphEdge { public final String fromSgId; public final String toSgId; @@ -159,6 +171,7 @@ } } + /** Parsed Mermaid graph structure containing nodes, edges, and subgraphs. */ public static class MermaidGraph { public Direction direction = Direction.TD; public final Map<String, Node> nodes = new LinkedHashMap<>(); @@ -184,16 +197,25 @@ return node; } - public @Nullable Subgraph lookupSubgraph(String name) { - if (name == null) return null; + @Nullable + public Subgraph lookupSubgraph(String name) { + if (name == null) { + return null; + } String clean = name.trim(); Subgraph sg = subgraphsMap.get(clean); - if (sg != null) return sg; + if (sg != null) { + return sg; + } sg = subgraphsMap.get(stripWhitespace(clean)); - if (sg != null) return sg; - sg = subgraphsMap.get(clean.toLowerCase()); - if (sg != null) return sg; - sg = subgraphsMap.get(stripWhitespace(clean).toLowerCase()); + if (sg != null) { + return sg; + } + sg = subgraphsMap.get(clean.toLowerCase(Locale.ROOT)); + if (sg != null) { + return sg; + } + sg = subgraphsMap.get(stripWhitespace(clean).toLowerCase(Locale.ROOT)); return sg; } } @@ -207,7 +229,7 @@ int pos; CharScanner(String text) { - this.text = text != null ? text : ""; + this.text = nullToEmpty(text); this.pos = 0; } @@ -230,8 +252,10 @@ } boolean startsWithIgnoreCase(String prefix) { - if (text.length() - pos < prefix.length()) return false; - return text.substring(pos, pos + prefix.length()).equalsIgnoreCase(prefix); + if (text.length() - pos < prefix.length()) { + return false; + } + return Ascii.equalsIgnoreCase(text.substring(pos, pos + prefix.length()), prefix); } void skip(String prefix) { @@ -282,7 +306,9 @@ void skipLine() { while (!isEof()) { char c = text.charAt(pos++); - if (c == '\n') break; + if (c == '\n') { + break; + } } } @@ -352,7 +378,7 @@ } Optional<MermaidGraph> graphOpt = parse(mermaidCode); - if (!graphOpt.isPresent()) { + if (graphOpt.isEmpty()) { return Optional.empty(); } @@ -373,7 +399,9 @@ // Scan for diagram type and direction while (!s.isEof()) { s.skipWhitespaceAndNewlines(); - if (s.isEof()) break; + if (s.isEof()) { + break; + } if (s.startsWith("%%")) { s.skipLine(); @@ -382,7 +410,7 @@ if (s.tryConsumeIgnoreCase("graph") || s.tryConsumeIgnoreCase("flowchart")) { s.skipWhitespace(); - String dirStr = s.scanIdentifier().toUpperCase(); + String dirStr = s.scanIdentifier().toUpperCase(Locale.ROOT); try { if (!dirStr.isEmpty()) { graph.direction = Direction.valueOf(dirStr); @@ -421,7 +449,9 @@ // Parse diagram statements into AST while (!s.isEof()) { s.skipWhitespaceAndNewlines(); - if (s.isEof()) break; + if (s.isEof()) { + break; + } if (s.startsWith("%%")) { s.skipLine(); @@ -451,7 +481,7 @@ if (s.tryConsumeIgnoreCase("direction")) { s.skipWhitespace(); - String dirStr = s.scanIdentifier().toUpperCase(); + String dirStr = s.scanIdentifier().toUpperCase(Locale.ROOT); if (!subgraphStack.isEmpty() && !dirStr.isEmpty()) { try { subgraphStack.peek().direction = Direction.valueOf(dirStr); @@ -491,19 +521,26 @@ private static void parseSubgraphHeader( CharScanner s, MermaidGraph graph, Deque<Subgraph> subgraphStack) { s.skipWhitespace(); - String sgId, sgTitle; + String sgId; + String sgTitle; // Check for `subgraph "Title Only"` if (s.startsWith("\"")) { s.skip("\""); int start = s.pos; - while (!s.isEof() && !s.startsWith("\"")) s.advance(); + while (!s.isEof() && !s.startsWith("\"")) { + s.advance(); + } sgTitle = s.text.substring(start, s.pos); s.skip("\""); sgId = "sg_" + graph.allSubgraphs.size(); } else { int start = s.pos; - while (!s.isEof() && !s.startsWith("[") && !s.startsWith("\"") && s.peek() != '\n' && s.peek() != ';') { + while (!s.isEof() + && !s.startsWith("[") + && !s.startsWith("\"") + && s.peek() != '\n' + && s.peek() != ';') { s.advance(); } String rawName = s.text.substring(start, s.pos).trim(); @@ -514,12 +551,16 @@ boolean quoted = s.tryConsume("\""); int tstart = s.pos; if (quoted) { - while (!s.isEof() && !s.startsWith("\"]") && !s.startsWith("\"")) s.advance(); + while (!s.isEof() && !s.startsWith("\"]") && !s.startsWith("\"")) { + s.advance(); + } sgTitle = s.text.substring(tstart, s.pos); s.skip("\""); s.skip("]"); } else { - while (!s.isEof() && !s.startsWith("]")) s.advance(); + while (!s.isEof() && !s.startsWith("]")) { + s.advance(); + } sgTitle = s.text.substring(tstart, s.pos); s.skip("]"); } @@ -541,8 +582,8 @@ subgraphStack.push(sg); graph.subgraphsMap.put(sgId, sg); graph.subgraphsMap.put(sgTitle, sg); - graph.subgraphsMap.put(sgId.toLowerCase(), sg); - graph.subgraphsMap.put(sgTitle.toLowerCase(), sg); + graph.subgraphsMap.put(sgId.toLowerCase(Locale.ROOT), sg); + graph.subgraphsMap.put(sgTitle.toLowerCase(Locale.ROOT), sg); graph.allSubgraphs.add(sg); } @@ -558,7 +599,9 @@ int start = s.pos; while (!s.isEof()) { char c = s.peek(); - if (c == '\n' || c == '\r' || c == ';') break; + if (c == '\n' || c == '\r' || c == ';') { + break; + } s.pos++; } String rest = s.text.substring(start, s.pos); @@ -579,7 +622,7 @@ String part = rest.substring(p, nextSep).trim(); int colonIdx = part.indexOf(':'); if (colonIdx != -1) { - String key = part.substring(0, colonIdx).trim().toLowerCase(); + String key = part.substring(0, colonIdx).trim().toLowerCase(Locale.ROOT); String val = part.substring(colonIdx + 1).trim(); if (key.equals("fill")) { fill = val; @@ -599,19 +642,29 @@ Subgraph sg = graph.lookupSubgraph(targetId); if (sg != null) { - if (fill != null) sg.customFill = fill; - if (stroke != null) sg.customStroke = stroke; + if (fill != null) { + sg.customFill = fill; + } + if (stroke != null) { + sg.customStroke = stroke; + } } Node n = graph.nodes.get(targetId); if (n != null) { - if (fill != null) n.customFill = fill; - if (stroke != null) n.customStroke = stroke; + if (fill != null) { + n.customFill = fill; + } + if (stroke != null) { + n.customStroke = stroke; + } } } private static boolean isValidCssColor(@Nullable String val) { - if (val == null || val.isEmpty()) return false; - String v = val.trim().toLowerCase(); + if (isNullOrEmpty(val)) { + return false; + } + String v = val.trim().toLowerCase(Locale.ROOT); if (v.startsWith("javascript:") || v.startsWith("data:") || v.contains("url(") @@ -633,7 +686,9 @@ private static void parseStatement( CharScanner s, MermaidGraph graph, @Nullable Subgraph currentSubgraph) { List<RawNodeToken> prevGroup = scanNodeGroup(s); - if (prevGroup.isEmpty()) return; + if (prevGroup.isEmpty()) { + return; + } for (RawNodeToken token : prevGroup) { applyNodeToken(token, graph, currentSubgraph); @@ -641,13 +696,19 @@ while (!s.isEof()) { char c = s.peek(); - if (c == ';' || c == '\n' || c == '\r') break; + if (c == ';' || c == '\n' || c == '\r') { + break; + } RawEdgeToken edge = scanEdgeToken(s); - if (edge == null) break; + if (edge == null) { + break; + } List<RawNodeToken> nextGroup = scanNodeGroup(s); - if (nextGroup.isEmpty()) break; + if (nextGroup.isEmpty()) { + break; + } for (RawNodeToken token : nextGroup) { applyNodeToken(token, graph, currentSubgraph); @@ -687,7 +748,9 @@ private static List<RawNodeToken> scanNodeGroup(CharScanner s) { List<RawNodeToken> group = new ArrayList<>(); RawNodeToken first = scanNodeToken(s); - if (first == null) return group; + if (first == null) { + return group; + } group.add(first); while (!s.isEof()) { @@ -753,7 +816,7 @@ StringBuilder cur = new StringBuilder(); for (String w : words) { - if (cur.length() == 0) { + if (cur.isEmpty()) { cur.append(w); } else if (cur.length() + 1 + w.length() <= Math.max(targetLen + 4, 18)) { cur.append(" ").append(w); @@ -768,12 +831,17 @@ return result; } - private static @Nullable RawNodeToken scanNodeToken(CharScanner s) { + @Nullable + private static RawNodeToken scanNodeToken(CharScanner s) { s.skipWhitespace(); - if (s.isEof()) return null; + if (s.isEof()) { + return null; + } String id = s.scanIdentifier(); - if (id.isEmpty()) return null; + if (id.isEmpty()) { + return null; + } s.skipWhitespace(); NodeShape shape = NodeShape.RECTANGLE; @@ -830,9 +898,12 @@ return new RawNodeToken(id, shape, label != null ? cleanLabel(label) : null); } - private static @Nullable RawEdgeToken scanEdgeToken(CharScanner s) { + @Nullable + private static RawEdgeToken scanEdgeToken(CharScanner s) { s.skipWhitespace(); - if (s.isEof()) return null; + if (s.isEof()) { + return null; + } // 1. Infix labels: -- label -->, -- "label" -->, == label ==>, -. label .->, -- label --- if ((s.startsWith("-- ") || s.startsWith("--\"") || s.startsWith("--\t")) @@ -846,7 +917,9 @@ } String label = cleanLabel(s.text.substring(start, s.pos)); boolean arrow = s.tryConsume("-->"); - if (!arrow) s.skip("---"); + if (!arrow) { + s.skip("---"); + } return new RawEdgeToken(EdgeStroke.SOLID, arrow, label); } @@ -861,7 +934,9 @@ } String label = cleanLabel(s.text.substring(start, s.pos)); boolean arrow = s.tryConsume("==>"); - if (!arrow) s.skip("==="); + if (!arrow) { + s.skip("==="); + } return new RawEdgeToken(EdgeStroke.THICK, arrow, label); } @@ -874,7 +949,9 @@ } String label = cleanLabel(s.text.substring(start, s.pos)); boolean arrow = s.tryConsume(".->"); - if (!arrow) s.skip(".-"); + if (!arrow) { + s.skip(".-"); + } return new RawEdgeToken(EdgeStroke.DASHED, arrow, label); } @@ -914,7 +991,9 @@ } private static String cleanLabel(String raw) { - if (raw == null) return ""; + if (raw == null) { + return ""; + } String s = raw.trim(); if (s.startsWith("\"") && s.endsWith("\"") && s.length() >= 2) { s = s.substring(1, s.length() - 1); @@ -928,7 +1007,9 @@ } private static String stripWhitespace(String s) { - if (s == null) return ""; + if (s == null) { + return ""; + } StringBuilder sb = new StringBuilder(s.length()); for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); @@ -943,7 +1024,7 @@ private static List<String> parseLabelLines(String label) { List<String> lines = new ArrayList<>(); - if (label == null || label.isEmpty()) { + if (isNullOrEmpty(label)) { lines.add(""); return lines; } @@ -974,7 +1055,7 @@ double tw = maxLineLen * 7.5; double th = n.labelLines.size() * 18.0; n.width = Math.max(90, tw * 1.5 + 36); - n.height = Math.max(50, Math.max(th * 2.2 + 24, n.width * 0.65)); + n.height = max(50, th * 2.2 + 24, n.width * 0.65); } else if (n.shape == NodeShape.CYLINDER) { n.width = Math.max(80, maxLineLen * 7.5 + 32); n.height = Math.max(50, n.labelLines.size() * 18 + 26); @@ -1064,11 +1145,13 @@ } else if (!comp.subgraphs.isEmpty()) { layoutCompoundComponent(graph, graph.direction, isHorizontal, comp); } else { - layoutBySugiyamaDAG(isHorizontal, comp.nodes, comp.edges); + layoutBySugiyamaDag(isHorizontal, comp.nodes, comp.edges); } - double cMinX = Double.MAX_VALUE, cMinY = Double.MAX_VALUE; - double cMaxX = Double.MIN_VALUE, cMaxY = Double.MIN_VALUE; + double cMinX = Double.MAX_VALUE; + double cMinY = Double.MAX_VALUE; + double cMaxX = Double.MIN_VALUE; + double cMaxY = Double.MIN_VALUE; for (Node n : comp.nodes.values()) { cMinX = Math.min(cMinX, n.x); cMinY = Math.min(cMinY, n.y); @@ -1137,8 +1220,10 @@ } // Compute bounding box - double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE; - double maxX = Double.MIN_VALUE, maxY = Double.MIN_VALUE; + double minX = Double.MAX_VALUE; + double minY = Double.MAX_VALUE; + double maxX = Double.MIN_VALUE; + double maxY = Double.MIN_VALUE; for (Node n : graph.nodes.values()) { minX = Math.min(minX, n.x); @@ -1235,10 +1320,8 @@ return renderSvg(graph, isHorizontal, totalWidth, totalHeight); } - private static void layoutBySugiyamaDAG( - boolean isHorizontal, - Map<String, Node> allNodes, - List<Edge> edges) { + private static void layoutBySugiyamaDag( + boolean isHorizontal, Map<String, Node> allNodes, List<Edge> edges) { // 1. Cycle Breaking via DFS Map<String, List<Edge>> adj = new HashMap<>(); @@ -1254,7 +1337,7 @@ Map<String, Integer> color = new HashMap<>(); for (String id : allNodes.keySet()) { if (color.getOrDefault(id, 0) == 0) { - findCyclesDFS(id, adj, color); + findCyclesDfs(id, adj, color); } } @@ -1287,7 +1370,9 @@ int minOutLayer = Integer.MAX_VALUE; for (Edge e : edges) { if (!e.isBackEdge) { - if (e.toId.equals(n.id)) inCount++; + if (e.toId.equals(n.id)) { + inCount++; + } if (e.fromId.equals(n.id)) { Node dst = allNodes.get(e.toId); if (dst != null) { @@ -1340,7 +1425,9 @@ for (int l = 1; l <= maxLayer; l++) { List<Node> currentLayer = layerMap.get(l); List<Node> prevLayer = layerMap.get(l - 1); - if (currentLayer == null) continue; + if (currentLayer == null) { + continue; + } for (Node n : currentLayer) { double sum = 0; int count = 0; @@ -1365,7 +1452,7 @@ if (e.toId.equals(n.id) && !e.isBackEdge) { Node pred; if (!e.virtualNodes.isEmpty()) { - pred = e.virtualNodes.get(e.virtualNodes.size() - 1); + pred = Iterables.getLast(e.virtualNodes); } else { pred = allNodes.get(e.fromId); } @@ -1436,8 +1523,7 @@ } } - for (int i = 0; i < nodes.size(); i++) { - Node n = nodes.get(i); + for (Node n : nodes) { n.x = curX; n.y = curY + (maxH - n.height) / 2.0; curX += n.width + 32; @@ -1508,8 +1594,7 @@ } } - for (int i = 0; i < nodes.size(); i++) { - Node n = nodes.get(i); + for (Node n : nodes) { n.x = curX + (maxW - n.width) / 2.0; n.y = curY; curY += n.height + 24; @@ -1534,7 +1619,7 @@ } } - private static void findCyclesDFS( + private static void findCyclesDfs( String u, Map<String, List<Edge>> adj, Map<String, Integer> color) { color.put(u, 1); // Gray List<Edge> uEdges = adj.get(u); @@ -1545,20 +1630,23 @@ if (vColor == 1) { e.isBackEdge = true; } else if (vColor == 0) { - findCyclesDFS(v, adj, color); + findCyclesDfs(v, adj, color); } } } color.put(u, 2); // Black } - private static @Nullable String getSubgraphSampleNodeId(Subgraph sg) { + @Nullable + private static String getSubgraphSampleNodeId(Subgraph sg) { if (!sg.nodes.isEmpty()) { return sg.nodes.get(0).id; } for (Subgraph child : sg.children) { String id = getSubgraphSampleNodeId(child); - if (id != null) return id; + if (id != null) { + return id; + } } return null; } @@ -1575,10 +1663,10 @@ private static class LayoutUnit { final String id; - final @Nullable Subgraph subgraph; - final @Nullable Node node; - double width; - double height; + @Nullable final Subgraph subgraph; + @Nullable final Node node; + final double width; + final double height; double x; double y; int layer = 0; @@ -1601,8 +1689,7 @@ } } - private static void registerNodesToUnit( - Subgraph sg, LayoutUnit u, Map<String, LayoutUnit> map) { + private static void registerNodesToUnit(Subgraph sg, LayoutUnit u, Map<String, LayoutUnit> map) { for (Node n : sg.nodes) { map.put(n.id, u); } @@ -1616,7 +1703,9 @@ List<LayoutUnit> units, Map<String, LayoutUnit> unitMap, List<Edge> unitEdges) { - if (units.size() <= 1) return; + if (units.size() <= 1) { + return; + } // 1. Cycle Breaking Map<String, List<Edge>> uAdj = new HashMap<>(); @@ -1631,7 +1720,7 @@ Map<String, Integer> uColor = new HashMap<>(); for (LayoutUnit u : units) { if (uColor.getOrDefault(u.id, 0) == 0) { - findCyclesDFS(u.id, uAdj, uColor); + findCyclesDfs(u.id, uAdj, uColor); } } @@ -1666,7 +1755,9 @@ if (!ue.isBackEdge) { LayoutUnit src = unitMap.get(ue.fromId); LayoutUnit dst = unitMap.get(ue.toId); - if (dst != null && dst.id.equals(u.id) && (src == null || !src.id.equals(u.id))) inCount++; + if (dst != null && dst.id.equals(u.id) && (src == null || !src.id.equals(u.id))) { + inCount++; + } if (src != null && src.id.equals(u.id) && dst != null && !dst.id.equals(u.id)) { minOutLayer = Math.min(minOutLayer, dst.layer); } @@ -1686,7 +1777,9 @@ for (int l = 1; l <= maxLayer; l++) { List<LayoutUnit> currentLayer = layerMap.get(l); List<LayoutUnit> prevLayer = layerMap.get(l - 1); - if (currentLayer == null) continue; + if (currentLayer == null) { + continue; + } for (LayoutUnit u : currentLayer) { double sum = 0; int count = 0; @@ -1869,8 +1962,7 @@ LayoutUnit u2 = nodeToUnit.get(e.toId); if (u1 != null && u2 != null && !u1.id.equals(u2.id)) { String key = u1.id + "->" + u2.id; - if (!seenUnitEdges.contains(key)) { - seenUnitEdges.add(key); + if (seenUnitEdges.add(key)) { Edge ue = new Edge(u1.id, u2.id, e.label, e.stroke, e.arrow); unitEdges.add(ue); } @@ -1886,8 +1978,7 @@ LayoutUnit u2 = s2Id != null ? nodeToUnit.get(s2Id) : unitMap.get("sg_" + sg2.id); if (u1 != null && u2 != null && !u1.id.equals(u2.id)) { String key = u1.id + "->" + u2.id; - if (!seenUnitEdges.contains(key)) { - seenUnitEdges.add(key); + if (seenUnitEdges.add(key)) { Edge ue = new Edge(u1.id, u2.id, se.label, se.stroke, se.arrow); unitEdges.add(ue); } @@ -1983,8 +2074,7 @@ LayoutUnit u2 = nodeToUnit.get(e.toId); if (u1 != null && u2 != null && !u1.id.equals(u2.id)) { String key = u1.id + "->" + u2.id; - if (!seenUnitEdges.contains(key)) { - seenUnitEdges.add(key); + if (seenUnitEdges.add(key)) { Edge ue = new Edge(u1.id, u2.id, e.label, e.stroke, e.arrow); unitEdges.add(ue); } @@ -2000,8 +2090,7 @@ LayoutUnit u2 = s2Id != null ? nodeToUnit.get(s2Id) : unitMap.get("sg_" + sg2.id); if (u1 != null && u2 != null && !u1.id.equals(u2.id)) { String key = u1.id + "->" + u2.id; - if (!seenUnitEdges.contains(key)) { - seenUnitEdges.add(key); + if (seenUnitEdges.add(key)) { Edge ue = new Edge(u1.id, u2.id, se.label, se.stroke, se.arrow); unitEdges.add(ue); } @@ -2013,8 +2102,10 @@ layoutUnits(isHorizontal, units, unitMap, unitEdges); // 6. Assign relative coordinates inside sg and compute sg dimensions - double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE; - double maxX = Double.MIN_VALUE, maxY = Double.MIN_VALUE; + double minX = Double.MAX_VALUE; + double minY = Double.MAX_VALUE; + double maxX = Double.MIN_VALUE; + double maxY = Double.MIN_VALUE; for (LayoutUnit u : units) { minX = Math.min(minX, u.x); minY = Math.min(minY, u.y); @@ -2038,11 +2129,16 @@ sg.height = (maxY - minY) + padding * 2 + headerH; } - private static @Nullable Subgraph lookupSubgraphInTree(Subgraph root, String id) { - if (root.id.equals(id)) return root; + @Nullable + private static Subgraph lookupSubgraphInTree(Subgraph root, String id) { + if (root.id.equals(id)) { + return root; + } for (Subgraph child : root.children) { Subgraph res = lookupSubgraphInTree(child, id); - if (res != null) return res; + if (res != null) { + return res; + } } return null; } @@ -2059,10 +2155,13 @@ } } - private static Direction getSubgraphEffectiveDirection(@Nullable Subgraph sg, Direction fallback) { + private static Direction getSubgraphEffectiveDirection( + @Nullable Subgraph sg, Direction fallback) { Subgraph cur = sg; while (cur != null) { - if (cur.direction != null) return cur.direction; + if (cur.direction != null) { + return cur.direction; + } cur = cur.parent; } return fallback; @@ -2073,24 +2172,29 @@ // ========================================================================= private static String renderSvg( - MermaidGraph graph, - boolean isHorizontal, - double width, - double height) { + MermaidGraph graph, boolean isHorizontal, double width, double height) { StringBuilder svg = new StringBuilder(4096); svg.append( - String.format(Locale.ROOT, - "<svg class=\"mermaid-svg\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 %.0f %.0f\" style=\"max-width: %.0fpx; width: 100%%; height: auto;\">\n", - width, height, width)); + String.format( + Locale.ROOT, + "<svg class=\"mermaid-svg\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 %.0f" + + " %.0f\" style=\"max-width: %.0fpx; width: 100%%; height: auto;\">\n", + width, + height, + width)); svg.append(" <defs>\n"); svg.append( - " <marker id=\"mermaid-arrow\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\n"); + " <marker id=\"mermaid-arrow\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\"" + + " markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\n"); svg.append(" <path d=\"M 0 1.5 L 10 5 L 0 8.5 z\" fill=\"#64748b\" />\n"); svg.append(" </marker>\n"); - svg.append(" <filter id=\"node-shadow\" x=\"-5%\" y=\"-5%\" width=\"115%\" height=\"120%\">\n"); - svg.append(" <feDropShadow dx=\"0\" dy=\"1.5\" stdDeviation=\"2\" flood-color=\"#0f172a\" flood-opacity=\"0.06\" />\n"); + svg.append( + " <filter id=\"node-shadow\" x=\"-5%\" y=\"-5%\" width=\"115%\" height=\"120%\">\n"); + svg.append( + " <feDropShadow dx=\"0\" dy=\"1.5\" stdDeviation=\"2\" flood-color=\"#0f172a\"" + + " flood-opacity=\"0.06\" />\n"); svg.append(" </filter>\n"); svg.append(" </defs>\n"); @@ -2143,14 +2247,25 @@ String fill = sg.customFill != null ? sg.customFill : (depth % 2 == 0 ? "#fafafa" : "#f8fafc"); String stroke = sg.customStroke != null ? sg.customStroke : "#cbd5e1"; svg.append( - String.format(Locale.ROOT, - " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"8\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" stroke-dasharray=\"4,4\" />\n", - sg.x, sg.y, sg.width, sg.height, fill, stroke)); + String.format( + Locale.ROOT, + " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"8\" fill=\"%s\"" + + " stroke=\"%s\" stroke-width=\"1.5\" stroke-dasharray=\"4,4\" />\n", + sg.x, + sg.y, + sg.width, + sg.height, + fill, + stroke)); if (sg.title != null && !sg.title.isEmpty()) { svg.append( - String.format(Locale.ROOT, - " <text x=\"%.1f\" y=\"%.1f\" font-size=\"12\" font-weight=\"600\" fill=\"#334155\">%s</text>\n", - sg.x + 14, sg.y + 18, escapeXml(sg.title))); + String.format( + Locale.ROOT, + " <text x=\"%.1f\" y=\"%.1f\" font-size=\"12\" font-weight=\"600\"" + + " fill=\"#334155\">%s</text>\n", + sg.x + 14, + sg.y + 18, + escapeXml(sg.title))); } } @@ -2166,67 +2281,168 @@ String stroke = n.customStroke != null ? n.customStroke : "#64748b"; // Shape Geometry - if (n.shape == NodeShape.CIRCLE) { - double r = n.width / 2.0; - svg.append( - String.format(Locale.ROOT, - " <circle cx=\"%.1f\" cy=\"%.1f\" r=\"%.1f\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", - n.x + r, n.y + r, r, fill, stroke)); - } else if (n.shape == NodeShape.DIAMOND) { - double cx = n.x + n.width / 2.0; - double cy = n.y + n.height / 2.0; - svg.append( - String.format(Locale.ROOT, - " <polygon points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", - cx, n.y, n.x + n.width, cy, cx, n.y + n.height, n.x, cy, fill, stroke)); - } else if (n.shape == NodeShape.HEXAGON) { - double h2 = n.height / 2.0; - double indent = 16; - svg.append( - String.format(Locale.ROOT, - " <polygon points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", - n.x + indent, n.y, - n.x + n.width - indent, n.y, - n.x + n.width, n.y + h2, - n.x + n.width - indent, n.y + n.height, - n.x + indent, n.y + n.height, - n.x, n.y + h2, fill, stroke)); - } else if (n.shape == NodeShape.CYLINDER) { - double ry = 7.0; - double rxCyl = n.width / 2.0; - double h = n.height; - svg.append( - String.format(Locale.ROOT, - " <path d=\"M %.1f %.1f a %.1f,%.1f 0 1,0 %.1f,0 a %.1f,%.1f 0 1,0 -%.1f,0 l 0,%.1f a %.1f,%.1f 0 0,0 %.1f,0 l 0,-%.1f Z\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", - n.x, n.y + ry, rxCyl, ry, n.width, rxCyl, ry, n.width, h - ry * 2, rxCyl, ry, n.width, h - ry * 2, fill, stroke)); - svg.append( - String.format(Locale.ROOT, - " <path d=\"M %.1f %.1f a %.1f,%.1f 0 0,0 %.1f,0\" fill=\"none\" stroke=\"%s\" stroke-width=\"1.5\" />\n", - n.x, n.y + ry, rxCyl, ry, n.width, stroke)); - } else if (n.shape == NodeShape.FLAG) { - double notch = 12; - svg.append( - String.format(Locale.ROOT, - " <polygon points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", - n.x, n.y, n.x + n.width, n.y, n.x + n.width - notch, n.y + n.height / 2.0, n.x + n.width, n.y + n.height, n.x, n.y + n.height, fill, stroke)); - } else if (n.shape == NodeShape.SUBROUTINE) { - svg.append( - String.format(Locale.ROOT, - " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"4\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", - n.x, n.y, n.width, n.height, fill, stroke)); - svg.append( - String.format(Locale.ROOT, - " <line x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"%s\" stroke-width=\"1.5\" />\n", - n.x + 10, n.y, n.x + 10, n.y + n.height, stroke)); - svg.append( - String.format(Locale.ROOT, - " <line x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"%s\" stroke-width=\"1.5\" />\n", - n.x + n.width - 10, n.y, n.x + n.width - 10, n.y + n.height, stroke)); - } else { - svg.append( - String.format(Locale.ROOT, - " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"%.1f\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", - n.x, n.y, n.width, n.height, rx, fill, stroke)); + switch (n.shape) { + case NodeShape.CIRCLE -> { + double r = n.width / 2.0; + svg.append( + String.format( + Locale.ROOT, + " <circle cx=\"%.1f\" cy=\"%.1f\" r=\"%.1f\" fill=\"%s\" stroke=\"%s\"" + + " stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", + n.x + r, + n.y + r, + r, + fill, + stroke)); + } + case NodeShape.DIAMOND -> { + double cx = n.x + n.width / 2.0; + double cy = n.y + n.height / 2.0; + svg.append( + String.format( + Locale.ROOT, + " <polygon points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\" fill=\"%s\"" + + " stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", + cx, + n.y, + n.x + n.width, + cy, + cx, + n.y + n.height, + n.x, + cy, + fill, + stroke)); + } + case NodeShape.HEXAGON -> { + double h2 = n.height / 2.0; + double indent = 16; + svg.append( + String.format( + Locale.ROOT, + " <polygon points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\"" + + " fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\"" + + " />\n", + n.x + indent, + n.y, + n.x + n.width - indent, + n.y, + n.x + n.width, + n.y + h2, + n.x + n.width - indent, + n.y + n.height, + n.x + indent, + n.y + n.height, + n.x, + n.y + h2, + fill, + stroke)); + } + case NodeShape.CYLINDER -> { + double ry = 7.0; + double rxCyl = n.width / 2.0; + double h = n.height; + svg.append( + String.format( + Locale.ROOT, + " <path d=\"M %.1f %.1f a %.1f,%.1f 0 1,0 %.1f,0 a %.1f,%.1f 0 1,0 -%.1f,0 l" + + " 0,%.1f a %.1f,%.1f 0 0,0 %.1f,0 l 0,-%.1f Z\" fill=\"%s\" stroke=\"%s\"" + + " stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", + n.x, + n.y + ry, + rxCyl, + ry, + n.width, + rxCyl, + ry, + n.width, + h - ry * 2, + rxCyl, + ry, + n.width, + h - ry * 2, + fill, + stroke)); + svg.append( + String.format( + Locale.ROOT, + " <path d=\"M %.1f %.1f a %.1f,%.1f 0 0,0 %.1f,0\" fill=\"none\" stroke=\"%s\"" + + " stroke-width=\"1.5\" />\n", + n.x, + n.y + ry, + rxCyl, + ry, + n.width, + stroke)); + } + case NodeShape.FLAG -> { + double notch = 12; + svg.append( + String.format( + Locale.ROOT, + " <polygon points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\"" + + " fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\"" + + " />\n", + n.x, + n.y, + n.x + n.width, + n.y, + n.x + n.width - notch, + n.y + n.height / 2.0, + n.x + n.width, + n.y + n.height, + n.x, + n.y + n.height, + fill, + stroke)); + } + case NodeShape.SUBROUTINE -> { + svg.append( + String.format( + Locale.ROOT, + " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"4\" fill=\"%s\"" + + " stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n", + n.x, + n.y, + n.width, + n.height, + fill, + stroke)); + svg.append( + String.format( + Locale.ROOT, + " <line x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"%s\"" + + " stroke-width=\"1.5\" />\n", + n.x + 10, + n.y, + n.x + 10, + n.y + n.height, + stroke)); + svg.append( + String.format( + Locale.ROOT, + " <line x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"%s\"" + + " stroke-width=\"1.5\" />\n", + n.x + n.width - 10, + n.y, + n.x + n.width - 10, + n.y + n.height, + stroke)); + } + case null, default -> + svg.append( + String.format( + Locale.ROOT, + " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"%.1f\"" + + " fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\"" + + " filter=\"url(#node-shadow)\" />\n", + n.x, + n.y, + n.width, + n.height, + rx, + fill, + stroke)); } // Node Text using structured AST labelLines @@ -2236,22 +2452,35 @@ if (n.labelLines.size() == 1) { svg.append( - String.format(Locale.ROOT, - " <text x=\"%.1f\" y=\"%.1f\" font-size=\"12\" font-weight=\"500\" fill=\"#0f172a\" text-anchor=\"middle\" dominant-baseline=\"central\">%s</text>\n", - cx, n.y + textYOffset + n.height / 2.0, escapeXml(n.labelLines.get(0).trim()))); + String.format( + Locale.ROOT, + " <text x=\"%.1f\" y=\"%.1f\" font-size=\"12\" font-weight=\"500\" fill=\"#0f172a\"" + + " text-anchor=\"middle\" dominant-baseline=\"central\">%s</text>\n", + cx, + n.y + textYOffset + n.height / 2.0, + escapeXml(n.labelLines.get(0).trim()))); } else { svg.append( - String.format(Locale.ROOT, + String.format( + Locale.ROOT, " <text x=\"%.1f\" y=\"%.1f\" font-size=\"12\" text-anchor=\"middle\">\n", - cx, startTextY)); + cx, + startTextY)); for (int i = 0; i < n.labelLines.size(); i++) { String weight = i == 0 ? "600" : "400"; String textColor = i == 0 ? "#0f172a" : "#475569"; String fontSize = i == 0 ? "12" : "10.5"; svg.append( - String.format(Locale.ROOT, - " <tspan x=\"%.1f\" dy=\"%s\" font-size=\"%s\" font-weight=\"%s\" fill=\"%s\">%s</tspan>\n", - cx, i == 0 ? "0" : "16", fontSize, weight, textColor, escapeXml(n.labelLines.get(i).trim()))); + String.format( + Locale.ROOT, + " <tspan x=\"%.1f\" dy=\"%s\" font-size=\"%s\" font-weight=\"%s\"" + + " fill=\"%s\">%s</tspan>\n", + cx, + i == 0 ? "0" : "16", + fontSize, + weight, + textColor, + escapeXml(n.labelLines.get(i).trim()))); } svg.append(" </text>\n"); } @@ -2269,7 +2498,10 @@ String strokeWidth = se.stroke == EdgeStroke.THICK ? "2.5" : "1.5"; String marker = se.arrow ? "marker-end=\"url(#mermaid-arrow)\" " : ""; - double startX, startY, endX, endY; + double startX; + double startY; + double endX; + double endY; if (isHorizontal) { startX = sg1.x + sg1.width; startY = sg1.y + sg1.height / 2.0; @@ -2281,20 +2513,38 @@ boolean blocked = false; double maxBottom = Math.max(sg1.y + sg1.height, sg2.y + sg2.height); for (Node n : graph.nodes.values()) { - if (n.x >= startX - 10 && n.x + n.width <= endX + 10 && n.y <= maxY && n.y + n.height >= minY) { + if (n.x >= startX - 10 + && n.x + n.width <= endX + 10 + && n.y <= maxY + && n.y + n.height >= minY) { blocked = true; maxBottom = Math.max(maxBottom, n.y + n.height); } } if (blocked) { - double labelW = (se.label != null && !se.label.trim().isEmpty()) ? se.label.trim().length() * 6.5 + 12 : 20; + double labelW = + (se.label != null && !se.label.trim().isEmpty()) + ? se.label.trim().length() * 6.5 + 12 + : 20; double loopOffset = Math.max(35.0, labelW / 2.0 + 20.0); double cpY = maxBottom + loopOffset; svg.append( - String.format(Locale.ROOT, - " <path d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", - startX, startY, startX + 20, cpY, endX - 20, cpY, endX, endY, strokeWidth, strokeDash, marker)); + String.format( + Locale.ROOT, + " <path d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\"" + + " stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", + startX, + startY, + startX + 20, + cpY, + endX - 20, + cpY, + endX, + endY, + strokeWidth, + strokeDash, + marker)); if (se.label != null && !se.label.trim().isEmpty()) { double midX = (startX + endX) / 2.0; renderEdgeLabelBadge(svg, midX, cpY, se.label.trim()); @@ -2312,20 +2562,38 @@ boolean blocked = false; double maxRight = Math.max(sg1.x + sg1.width, sg2.x + sg2.width); for (Node n : graph.nodes.values()) { - if (n.y >= startY - 10 && n.y + n.height <= endY + 10 && n.x <= maxX && n.x + n.width >= minX) { + if (n.y >= startY - 10 + && n.y + n.height <= endY + 10 + && n.x <= maxX + && n.x + n.width >= minX) { blocked = true; maxRight = Math.max(maxRight, n.x + n.width); } } if (blocked) { - double labelW = (se.label != null && !se.label.trim().isEmpty()) ? se.label.trim().length() * 6.5 + 12 : 20; + double labelW = + (se.label != null && !se.label.trim().isEmpty()) + ? se.label.trim().length() * 6.5 + 12 + : 20; double loopOffset = Math.max(35.0, labelW / 2.0 + 20.0); double cpX = maxRight + loopOffset; svg.append( - String.format(Locale.ROOT, - " <path d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", - startX, startY, cpX, startY + 20, cpX, endY - 20, endX, endY, strokeWidth, strokeDash, marker)); + String.format( + Locale.ROOT, + " <path d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\"" + + " stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", + startX, + startY, + cpX, + startY + 20, + cpX, + endY - 20, + endX, + endY, + strokeWidth, + strokeDash, + marker)); if (se.label != null && !se.label.trim().isEmpty()) { double midY = (startY + endY) / 2.0; renderEdgeLabelBadge(svg, cpX, midY, se.label.trim()); @@ -2335,9 +2603,17 @@ } svg.append( - String.format(Locale.ROOT, - " <line x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", - startX, startY, endX, endY, strokeWidth, strokeDash, marker)); + String.format( + Locale.ROOT, + " <line x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"#64748b\"" + + " stroke-width=\"%s\" %s%s/>\n", + startX, + startY, + endX, + endY, + strokeWidth, + strokeDash, + marker)); if (se.label != null && !se.label.trim().isEmpty()) { double midX = (startX + endX) / 2.0; @@ -2347,19 +2623,20 @@ } private static void renderEdge( - StringBuilder svg, - boolean isHorizontal, - MermaidGraph graph, - Node src, - Node dst, - Edge e) { + StringBuilder svg, boolean isHorizontal, MermaidGraph graph, Node src, Node dst, Edge e) { String strokeDash = e.stroke == EdgeStroke.DASHED ? "stroke-dasharray=\"4,4\" " : ""; String strokeWidth = e.stroke == EdgeStroke.THICK ? "2.5" : "1.5"; String marker = e.arrow ? "marker-end=\"url(#mermaid-arrow)\" " : ""; - double x1, y1, x2, y2; - double cp1x, cp1y, cp2x, cp2y; + double x1; + double y1; + double x2; + double y2; + double cp1x; + double cp1y; + double cp2x; + double cp2y; if (!isHorizontal) { if (src.id.equals(dst.id)) { @@ -2394,7 +2671,9 @@ } } double labelW = - (e.label != null && !e.label.trim().isEmpty()) ? (e.label.trim().length() * 6.5 + 16) : 0; + (e.label != null && !e.label.trim().isEmpty()) + ? (e.label.trim().length() * 6.5 + 16) + : 0; double loopOffset = Math.max(45.0, labelW / 2.0 + 36.0) + (maxRight - Math.min(x1, x2)) * 0.4; cp1x = maxRight + loopOffset; @@ -2414,24 +2693,37 @@ py.add(dst.y); StringBuilder pathD = new StringBuilder(); - pathD.append(String.format(Locale.ROOT,"M %.1f %.1f", px.get(0), py.get(0))); + pathD.append(String.format(Locale.ROOT, "M %.1f %.1f", px.get(0), py.get(0))); for (int i = 0; i < px.size() - 1; i++) { - double xA = px.get(i), yA = py.get(i); - double xB = px.get(i + 1), yB = py.get(i + 1); + double xA = px.get(i); + double yA = py.get(i); + double xB = px.get(i + 1); + double yB = py.get(i + 1); double dy = yB - yA; pathD.append( - String.format(Locale.ROOT, + String.format( + Locale.ROOT, " C %.1f %.1f, %.1f %.1f, %.1f %.1f", - xA, yA + dy * 0.5, xB, yB - dy * 0.5, xB, yB)); + xA, + yA + dy * 0.5, + xB, + yB - dy * 0.5, + xB, + yB)); } svg.append( - String.format(Locale.ROOT, + String.format( + Locale.ROOT, " <path d=\"%s\" fill=\"none\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", - pathD.toString(), strokeWidth, strokeDash, marker)); + pathD, + strokeWidth, + strokeDash, + marker)); if (e.label != null && !e.label.trim().isEmpty()) { Node firstV = e.virtualNodes.get(0); - renderEdgeLabelBadge(svg, firstV.x + firstV.width / 2.0, firstV.y + firstV.height / 2.0, e.label.trim()); + renderEdgeLabelBadge( + svg, firstV.x + firstV.width / 2.0, firstV.y + firstV.height / 2.0, e.label.trim()); } return; } else { @@ -2478,8 +2770,7 @@ } } double labelH = 18.0; - double loopOffset = - Math.max(45.0, labelH + 36.0) + (Math.max(y1, y2) - minTop) * 0.4; + double loopOffset = Math.max(45.0, labelH + 36.0) + (Math.max(y1, y2) - minTop) * 0.4; cp1x = x1; cp1y = minTop - loopOffset; cp2x = x2; @@ -2497,24 +2788,37 @@ py.add(dst.y + dst.height / 2.0); StringBuilder pathD = new StringBuilder(); - pathD.append(String.format(Locale.ROOT,"M %.1f %.1f", px.get(0), py.get(0))); + pathD.append(String.format(Locale.ROOT, "M %.1f %.1f", px.get(0), py.get(0))); for (int i = 0; i < px.size() - 1; i++) { - double xA = px.get(i), yA = py.get(i); - double xB = px.get(i + 1), yB = py.get(i + 1); + double xA = px.get(i); + double yA = py.get(i); + double xB = px.get(i + 1); + double yB = py.get(i + 1); double dx = xB - xA; pathD.append( - String.format(Locale.ROOT, + String.format( + Locale.ROOT, " C %.1f %.1f, %.1f %.1f, %.1f %.1f", - xA + dx * 0.5, yA, xB - dx * 0.5, yB, xB, yB)); + xA + dx * 0.5, + yA, + xB - dx * 0.5, + yB, + xB, + yB)); } svg.append( - String.format(Locale.ROOT, + String.format( + Locale.ROOT, " <path d=\"%s\" fill=\"none\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", - pathD.toString(), strokeWidth, strokeDash, marker)); + pathD, + strokeWidth, + strokeDash, + marker)); if (e.label != null && !e.label.trim().isEmpty()) { Node firstV = e.virtualNodes.get(0); - renderEdgeLabelBadge(svg, firstV.x + firstV.width / 2.0, firstV.y + firstV.height / 2.0, e.label.trim()); + renderEdgeLabelBadge( + svg, firstV.x + firstV.width / 2.0, firstV.y + firstV.height / 2.0, e.label.trim()); } return; } else { @@ -2531,9 +2835,21 @@ } svg.append( - String.format(Locale.ROOT, - " <path d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", - x1, y1, cp1x, cp1y, cp2x, cp2y, x2, y2, strokeWidth, strokeDash, marker)); + String.format( + Locale.ROOT, + " <path d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\"" + + " stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n", + x1, + y1, + cp1x, + cp1y, + cp2x, + cp2y, + x2, + y2, + strokeWidth, + strokeDash, + marker)); if (e.label != null && !e.label.trim().isEmpty()) { // Evaluate Cubic Bézier midpoint at t = 0.5 @@ -2543,22 +2859,34 @@ } } - private static void renderEdgeLabelBadge(StringBuilder svg, double midX, double midY, String label) { + private static void renderEdgeLabelBadge( + StringBuilder svg, double midX, double midY, String label) { double textLen = label.length() * 6.5; double rectW = textLen + 12; double rectH = 18; svg.append( - String.format(Locale.ROOT, - " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"3\" fill=\"#ffffff\" fill-opacity=\"0.95\" />\n", - midX - rectW / 2.0, midY - rectH / 2.0, rectW, rectH)); + String.format( + Locale.ROOT, + " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"3\" fill=\"#ffffff\"" + + " fill-opacity=\"0.95\" />\n", + midX - rectW / 2.0, + midY - rectH / 2.0, + rectW, + rectH)); svg.append( - String.format(Locale.ROOT, - " <text x=\"%.1f\" y=\"%.1f\" font-size=\"10.5\" fill=\"#475569\" text-anchor=\"middle\" dominant-baseline=\"central\">%s</text>\n", - midX, midY, escapeXml(label))); + String.format( + Locale.ROOT, + " <text x=\"%.1f\" y=\"%.1f\" font-size=\"10.5\" fill=\"#475569\"" + + " text-anchor=\"middle\" dominant-baseline=\"central\">%s</text>\n", + midX, + midY, + escapeXml(label))); } private static String escapeXml(String text) { - if (text == null) return ""; + if (text == null) { + return ""; + } StringBuilder sb = new StringBuilder(text.length() + 16); for (int i = 0; i < text.length(); i++) { char c = text.charAt(i); @@ -2567,24 +2895,12 @@ continue; } switch (c) { - case '&': - sb.append("&"); - break; - case '<': - sb.append("<"); - break; - case '>': - sb.append(">"); - break; - case '"': - sb.append("""); - break; - case '\'': - sb.append("'"); - break; - default: - sb.append(c); - break; + case '&' -> sb.append("&"); + case '<' -> sb.append("<"); + case '>' -> sb.append(">"); + case '"' -> sb.append("""); + case '\'' -> sb.append("'"); + default -> sb.append(c); } } return sb.toString(); @@ -2622,7 +2938,9 @@ double headerH = 22; for (Subgraph sg : subgraphs) { - if (sg.nodes.isEmpty()) continue; + if (sg.nodes.isEmpty()) { + continue; + } if (!isHorizontal) { double maxW = 0; for (Node n : sg.nodes) { @@ -2656,4 +2974,6 @@ } } } + + private SimpleMermaidRenderer() {} }
diff --git a/javatests/com/google/gitiles/blame/cache/BlameCacheTest.java b/javatests/com/google/gitiles/blame/cache/BlameCacheTest.java index 10ff25d..5138ab4 100644 --- a/javatests/com/google/gitiles/blame/cache/BlameCacheTest.java +++ b/javatests/com/google/gitiles/blame/cache/BlameCacheTest.java
@@ -19,9 +19,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; -import java.io.IOException; import java.util.List; -import java.util.Set; import org.eclipse.jgit.internal.storage.dfs.DfsRepository; import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription; import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository; @@ -77,8 +75,7 @@ BlameCacheImpl.Key kNoIgnore = new BlameCacheImpl.Key(c1, "foo.txt"); assertThat(kNoIgnore.toString()).isEqualTo("1111111111111111111111111111111111111111:foo.txt"); - BlameCacheImpl.Key kWithIgnore = - new BlameCacheImpl.Key(c1, "foo.txt", ImmutableSet.of(c3, c2)); + BlameCacheImpl.Key kWithIgnore = new BlameCacheImpl.Key(c1, "foo.txt", ImmutableSet.of(c3, c2)); assertThat(kWithIgnore.toString()) .isEqualTo( "1111111111111111111111111111111111111111:foo.txt" @@ -105,8 +102,7 @@ RevCommit c2 = repo.commit().parent(c1).add("foo.txt", "line1_formatted\nline2_formatted\n").create(); - List<Region> regions = - blameCache.get(repo.getRepository(), c2, "foo.txt", ImmutableSet.of(c2)); + List<Region> regions = blameCache.get(repo.getRepository(), c2, "foo.txt", ImmutableSet.of(c2)); assertThat(regions).hasSize(1); assertThat(regions.get(0).getSourceCommit()).isEqualTo(c1); assertThat(regions.get(0).getStart()).isEqualTo(0); @@ -124,8 +120,7 @@ assertThat(unignored.get(0).getSourceCommit()).isEqualTo(c2); // Query 2: ignored blame - List<Region> ignored = - blameCache.get(repo.getRepository(), c2, "foo.txt", ImmutableSet.of(c2)); + List<Region> ignored = blameCache.get(repo.getRepository(), c2, "foo.txt", ImmutableSet.of(c2)); assertThat(ignored.get(0).getSourceCommit()).isEqualTo(c1); // Verify both are cached under separate keys @@ -157,7 +152,7 @@ BlameCache customCache = new BlameCache() { @Override - public List<Region> get(Repository repo, ObjectId commitId, String path) { + public ImmutableList<Region> get(Repository repo, ObjectId commitId, String path) { return ImmutableList.of(new Region(null, null, null, 0, 1)); } @@ -176,13 +171,11 @@ assertThat(res2).hasSize(1); // When ignoreIds is non-empty, default method throws UnsupportedOperationException + DfsRepository repo2 = repo.getRepository(); + ObjectId commitId = ObjectId.zeroId(); + ImmutableSet<ObjectId> ignoreIds = ImmutableSet.of(ObjectId.zeroId()); assertThrows( UnsupportedOperationException.class, - () -> - customCache.get( - repo.getRepository(), - ObjectId.zeroId(), - "foo.txt", - ImmutableSet.of(ObjectId.zeroId()))); + () -> customCache.get(repo2, commitId, "foo.txt", ignoreIds)); } }
diff --git a/javatests/com/google/gitiles/doc/DocServletTest.java b/javatests/com/google/gitiles/doc/DocServletTest.java index 27e827f..dcbeeb2 100644 --- a/javatests/com/google/gitiles/doc/DocServletTest.java +++ b/javatests/com/google/gitiles/doc/DocServletTest.java
@@ -41,14 +41,16 @@ @Test public void includesNavbar() throws Exception { String navbar = - "# Site Title\n" - + "\n" - + "[home]: index.md\n" - + "[logo]: logo.png\n" - + "\n" - + "* [Home][home]\n" - + "* [README](README.md)\n" - + "[extensions]: blocknote\n"; + """ + # Site Title + + [home]: index.md + [logo]: logo.png + + * [Home][home] + * [README](README.md) + [extensions]: blocknote + """; repo.branch("master") .commit() .add("README.md", "# page\n\nof information.") @@ -109,11 +111,13 @@ @Test public void dropsHtml() throws Exception { String markdown = - "# B. Ad\n" - + "\n" - + "<script>window.alert();</script>\n" - + "\n" - + "Non-HTML <b>is fine</b>."; + """ + # B. Ad + + <script>window.alert();</script> + + Non-HTML <b>is fine</b>.\ + """; repo.branch("master").commit().add("index.md", markdown).create(); String html = buildHtml("/repo/+doc/master/"); @@ -126,7 +130,11 @@ @Test public void namedAnchor() throws Exception { - String markdown = "# Section {#debug}\n" + "# Other <a name=\"OLD-SCHOOL\"></a>\n"; + String markdown = + """ + # Section {#debug} + # Other <a name="OLD-SCHOOL"></a> + """; repo.branch("master").commit().add("index.md", markdown).create(); String html = buildHtml("/repo/+doc/master/"); assertThat(html) @@ -154,7 +162,15 @@ @Test public void noteInList() throws Exception { String markdown = - "+ one\n\n" + " ***aside\n" + " remember this\n" + " ***\n" + "\n" + "+ two\n"; + """ + + one + + ***aside + remember this + *** + + + two + """; repo.branch("master").commit().add("index.md", markdown).create(); String html = buildHtml("/repo/+/master/index.md");
diff --git a/javatests/com/google/gitiles/doc/GitilesMarkdownTest.java b/javatests/com/google/gitiles/doc/GitilesMarkdownTest.java index 2e51b5f..73430ce 100644 --- a/javatests/com/google/gitiles/doc/GitilesMarkdownTest.java +++ b/javatests/com/google/gitiles/doc/GitilesMarkdownTest.java
@@ -16,6 +16,7 @@ import static com.google.common.truth.Truth.assertThat; +import com.google.common.collect.ImmutableSet; import com.google.common.html.types.SafeHtml; import com.google.gitiles.GitilesView; import org.commonmark.node.Node; @@ -47,16 +48,18 @@ // are never visited: rendering neither throws (as it would for an unhandled // CustomNode) nor leaks the metadata. String markdown = - "---\n" - + "title: Kittens\n" - + "tags:\n" - + " - cats\n" - + " - fluffy\n" - + "---\n" - + "\n" - + "# Heading\n" - + "\n" - + "Body text.\n"; + """ + --- + title: Kittens + tags: + - cats + - fluffy + --- + + # Heading + + Body text. + """; String html = render(markdown, /* frontMatter= */ true); assertThat(html).doesNotContain("Kittens"); assertThat(html).doesNotContain("cats"); @@ -77,26 +80,24 @@ @Test public void renderMermaidDiagram() { String md = - "```mermaid\n" - + "graph LR\n" - + " subgraph CoreGarden\n" - + " A[\"Fluffy Puppy\"]\n" - + " end\n" - + " subgraph GreenLawn\n" - + " B[\"Playful Kitten\"]\n" - + " end\n" - + " B --> A\n" - + "```\n"; + """ + ```mermaid + graph LR + subgraph CoreGarden + A["Fluffy Puppy"] + end + subgraph GreenLawn + B["Playful Kitten"] + end + B --> A + ``` + """; Config cfg = new Config(); cfg.setBoolean("markdown", null, "mermaid", true); MarkdownConfig mc = new MarkdownConfig(cfg); Node node = GitilesMarkdown.parse(mc, md); SafeHtml html = - MarkdownToHtml.builder() - .setConfig(mc) - .setFilePath("index.md") - .build() - .toSoyHtml(node); + MarkdownToHtml.builder().setConfig(mc).setFilePath("index.md").build().toSoyHtml(node); assertThat(html).isNotNull(); String htmlStr = html.getSafeHtmlString(); assertThat(htmlStr).contains("class=\"mermaid-container\""); @@ -108,32 +109,30 @@ @Test public void renderMermaidWithSubgraphAndDAG() { String md = - "```mermaid\n" - + "graph TD\n" - + " ClientApp[Little Puppy Plays] --> Extras(Sweet Kitten)\n" - + " ClientApp --> Utils(Happy Bunny)\n" - + " Utils --> ServiceDiscovery[Red Apple Berry]\n" - + " Utils --> ModelManager[Yellow Banana Snack]\n" - + " Extras --> Recognition(Fluffy Duckling)\n" - + " Recognition --> SODA(Green Frog Jump)\n" - + " Recognition --> S3(Sunny Daisy Flower)\n" - + " subgraph Play Park Garden\n" - + " Executors(Teddy Bear)\n" - + " Errors(Wooden Blocks)\n" - + " Protos(Toy Wagon)\n" - + " end\n" - + " Recognition -.-> PlayParkGarden\n" - + "```\n"; + """ + ```mermaid + graph TD + ClientApp[Little Puppy Plays] --> Extras(Sweet Kitten) + ClientApp --> Utils(Happy Bunny) + Utils --> ServiceDiscovery[Red Apple Berry] + Utils --> ModelManager[Yellow Banana Snack] + Extras --> Recognition(Fluffy Duckling) + Recognition --> SODA(Green Frog Jump) + Recognition --> S3(Sunny Daisy Flower) + subgraph Play Park Garden + Executors(Teddy Bear) + Errors(Wooden Blocks) + Protos(Toy Wagon) + end + Recognition -.-> PlayParkGarden + ``` + """; Config cfg = new Config(); cfg.setBoolean("markdown", null, "mermaid", true); MarkdownConfig mc = new MarkdownConfig(cfg); Node node = GitilesMarkdown.parse(mc, md); SafeHtml html = - MarkdownToHtml.builder() - .setConfig(mc) - .setFilePath("index.md") - .build() - .toSoyHtml(node); + MarkdownToHtml.builder().setConfig(mc).setFilePath("index.md").build().toSoyHtml(node); assertThat(html).isNotNull(); String htmlStr = html.getSafeHtmlString(); assertThat(htmlStr).contains("Play Park Garden"); @@ -148,32 +147,30 @@ @Test public void renderMermaidWithQuotedBracketsAndDiamond() { String md = - "```mermaid\n" - + "graph TD\n" - + " A[\"Happy little bunny jumps\"]\n" - + " B[\"Locate [TeddyBear] in garden\"]\n" - + " A --> B\n" - + " B --> C[\"Sing Sweet Melody\"]\n" - + " C --> D[\"Dance Around Blossom Tree\"]\n" - + " E{\"Is kitten happy & pure?\"}\n" - + " E -- Yes --> F[\"Give Tasty Cookie Treat\"]\n" - + " F --> G[\"Play With Soft Yarn Ball\"]\n" - + " E -- No --> H[\"Read Gentle Story Book\"]\n" - + " H --> I[\"Warm Cozy Blanket Nap\"]\n" - + " G --> J[\"Wake Up In Morning Sun\"]\n" - + " I --> J\n" - + " J --> K[\"Smile At Rainbow Sky\"]\n" - + "```\n"; + """ + ```mermaid + graph TD + A["Happy little bunny jumps"] + B["Locate [TeddyBear] in garden"] + A --> B + B --> C["Sing Sweet Melody"] + C --> D["Dance Around Blossom Tree"] + E{"Is kitten happy & pure?"} + E -- Yes --> F["Give Tasty Cookie Treat"] + F --> G["Play With Soft Yarn Ball"] + E -- No --> H["Read Gentle Story Book"] + H --> I["Warm Cozy Blanket Nap"] + G --> J["Wake Up In Morning Sun"] + I --> J + J --> K["Smile At Rainbow Sky"] + ``` + """; Config cfg = new Config(); cfg.setBoolean("markdown", null, "mermaid", true); MarkdownConfig mc = new MarkdownConfig(cfg); Node node = GitilesMarkdown.parse(mc, md); SafeHtml html = - MarkdownToHtml.builder() - .setConfig(mc) - .setFilePath("index.md") - .build() - .toSoyHtml(node); + MarkdownToHtml.builder().setConfig(mc).setFilePath("index.md").build().toSoyHtml(node); assertThat(html).isNotNull(); String htmlStr = html.getSafeHtmlString(); assertThat(htmlStr).contains("Is kitten happy &"); @@ -183,26 +180,24 @@ @Test public void renderMermaidWithBidirectionalEdges() { String md = - "```mermaid\n" - + "graph TD\n" - + " Puppy[\"Fluffy Puppy\"]\n" - + " Kitten[\"Playful Kitten\"]\n" - + " Bunny[\"Little Bunny\"]\n" - + " Puppy -->|Roll Ball| Kitten\n" - + " Kitten -->|Chase Toy| Bunny\n" - + " Bunny -->|Share Snack| Kitten\n" - + " Kitten -->|Give Hug| Puppy\n" - + "```\n"; + """ + ```mermaid + graph TD + Puppy["Fluffy Puppy"] + Kitten["Playful Kitten"] + Bunny["Little Bunny"] + Puppy -->|Roll Ball| Kitten + Kitten -->|Chase Toy| Bunny + Bunny -->|Share Snack| Kitten + Kitten -->|Give Hug| Puppy + ``` + """; Config cfg = new Config(); cfg.setBoolean("markdown", null, "mermaid", true); MarkdownConfig mc = new MarkdownConfig(cfg); Node node = GitilesMarkdown.parse(mc, md); SafeHtml html = - MarkdownToHtml.builder() - .setConfig(mc) - .setFilePath("index.md") - .build() - .toSoyHtml(node); + MarkdownToHtml.builder().setConfig(mc).setFilePath("index.md").build().toSoyHtml(node); assertThat(html).isNotNull(); String htmlStr = html.getSafeHtmlString(); assertThat(htmlStr).contains("Fluffy Puppy"); @@ -226,9 +221,8 @@ MarkdownConfig anyMc = new MarkdownConfig(anyCfg); assertThat(anyMc.isIFrameAllowed("https://anything.com")).isTrue(); - MarkdownConfig copied = mc.copyWithExtensions( - java.util.Collections.singleton("toc"), - java.util.Collections.singleton("autolink")); + MarkdownConfig copied = + mc.copyWithExtensions(ImmutableSet.of("toc"), ImmutableSet.of("autolink")); assertThat(copied).isNotNull(); } @@ -240,11 +234,7 @@ MarkdownConfig mc = new MarkdownConfig(cfg); Node node = GitilesMarkdown.parse(mc, md); SafeHtml html = - MarkdownToHtml.builder() - .setConfig(mc) - .setFilePath("index.md") - .build() - .toSoyHtml(node); + MarkdownToHtml.builder().setConfig(mc).setFilePath("index.md").build().toSoyHtml(node); assertThat(html).isNotNull(); String htmlStr = html.getSafeHtmlString(); // When disabled, it renders as a code pre block, not a mermaid svg container @@ -256,28 +246,27 @@ @Test public void testSecurityMermaidMarkdownEndToEndNoScriptOrIframeInjection() { String md = - "# Diagram Title\n\n" - + "```mermaid\n" - + "graph TD\n" - + " A[\"<script>alert('xss-1')</script>\"]\n" - + " B[\"<iframe src='javascript:alert(2)'></iframe>\"]\n" - + " C[\"<img src=x onerror=alert('xss-3')>\"]\n" - + " D[\"<foreignObject><iframe src='https://evil.com'></iframe></foreignObject>\"]\n" - + " A -->|\"<script>alert('edge')</script>\"| B\n" - + " B --> C --> D\n" - + " click A href \"javascript:alert('click')\"\n" - + "```\n"; + """ + # Diagram Title + + ```mermaid + graph TD + A["<script>alert('xss-1')</script>"] + B["<iframe src='javascript:alert(2)'></iframe>"] + C["<img src=x onerror=alert('xss-3')>"] + D["<foreignObject><iframe src='https://evil.com'></iframe></foreignObject>"] + A -->|"<script>alert('edge')</script>"| B + B --> C --> D + click A href "javascript:alert('click')" + ``` + """; Config cfg = new Config(); cfg.setBoolean("markdown", null, "mermaid", true); MarkdownConfig mc = new MarkdownConfig(cfg); Node node = GitilesMarkdown.parse(mc, md); SafeHtml html = - MarkdownToHtml.builder() - .setConfig(mc) - .setFilePath("index.md") - .build() - .toSoyHtml(node); + MarkdownToHtml.builder().setConfig(mc).setFilePath("index.md").build().toSoyHtml(node); assertThat(html).isNotNull(); String htmlStr = html.getSafeHtmlString(); @@ -295,7 +284,8 @@ // Verify payload is safely escaped as XML text inside SVG tspans assertThat(htmlStr).contains("<script>alert('xss-1')</script>"); - assertThat(htmlStr).contains("<iframe src='javascript:alert(2)'></iframe>"); + assertThat(htmlStr) + .contains("<iframe src='javascript:alert(2)'></iframe>"); assertThat(htmlStr).contains("<img src=x onerror=alert('xss-3')>"); }
diff --git a/javatests/com/google/gitiles/doc/SimpleMermaidRendererSecurityTest.java b/javatests/com/google/gitiles/doc/SimpleMermaidRendererSecurityTest.java index ecaa1e3..29972f6 100644 --- a/javatests/com/google/gitiles/doc/SimpleMermaidRendererSecurityTest.java +++ b/javatests/com/google/gitiles/doc/SimpleMermaidRendererSecurityTest.java
@@ -16,9 +16,11 @@ import static com.google.common.truth.Truth.assertThat; +import com.google.common.html.types.SafeHtml; import java.util.Arrays; import java.util.List; import java.util.Optional; +import org.commonmark.node.Node; import org.eclipse.jgit.lib.Config; import org.junit.Test; import org.junit.runner.RunWith; @@ -66,15 +68,19 @@ public void testForeignObjectAndEmbeddedHtmlInLabels() { List<String> payloads = Arrays.asList( - "<foreignObject><body xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert(1)</script></body></foreignObject>", + "<foreignObject><body" + + " xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert(1)</script></body></foreignObject>", "<foreignObject><iframe src=\"javascript:alert(1)\"></iframe></foreignObject>", "<foreignObject><iframe src=\"https://evil.com\"></iframe></foreignObject>", - "<foreignObject><form action=\"//evil.com\"><input type=\"password\" name=\"pass\"></form></foreignObject>", + "<foreignObject><form action=\"//evil.com\"><input type=\"password\"" + + " name=\"pass\"></form></foreignObject>", "<foreignObject><embed src=\"evil.swf\"></embed></foreignObject>", "<foreignObject><object data=\"javascript:alert(1)\"></object></foreignObject>", "<foreignObject><audio src=\"x\" onerror=\"alert(1)\"></audio></foreignObject>", "<foreignObject><video src=\"x\" onerror=\"alert(1)\"></video></foreignObject>", - "<foreignObject width=\"100\" height=\"100\"><div xmlns=\"http://www.w3.org/1999/xhtml\"><span>HTML Content</span></div></foreignObject>"); + "<foreignObject width=\"100\" height=\"100\"><div" + + " xmlns=\"http://www.w3.org/1999/xhtml\"><span>HTML" + + " Content</span></div></foreignObject>"); for (String payload : payloads) { String code = "graph TD\n A[\"" + payload.replace("\"", "\\\"") + "\"] --> B\n"; @@ -149,7 +155,8 @@ "<animate attributeName=\"xlink:href\" values=\"javascript:alert(1)\" dur=\"1s\" />", "<set onbegin=\"alert('set-begin')\" attributeName=\"x\" dur=\"1s\" />", "<set attributeName=\"onmouseover\" to=\"alert(1)\" />", - "<animateTransform attributeName=\"transform\" type=\"rotate\" from=\"0\" to=\"360\" onend=\"alert(1)\" />"); + "<animateTransform attributeName=\"transform\" type=\"rotate\" from=\"0\" to=\"360\"" + + " onend=\"alert(1)\" />"); for (String payload : payloads) { String code = "graph TD\n A[\"" + payload.replace("\"", "\\\"") + "\"] --> B\n"; @@ -167,7 +174,8 @@ Arrays.asList( "<use href=\"javascript:alert(1)\" />", "<use xlink:href=\"javascript:alert(1)\" />", - "<use href=\"data:image/svg+xml;utf8,<svg id='x' xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>#x\" />", + "<use href=\"data:image/svg+xml;utf8,<svg id='x'" + + " xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>#x\" />", "<use xlink:href=\"https://evil.com/payload.svg#icon\" />", "<image href=\"javascript:alert(1)\" />", "<image xlink:href=\"javascript:alert(1)\" />", @@ -195,7 +203,8 @@ "<style>body { background: url(\"javascript:alert(1)\"); }</style>", "<style>* { -moz-binding: url('http://evil.com/xss.xml#test'); }</style>", "<style>svg { behavior: url(xss.htc); }</style>", - "<style>@keyframes xss { from { background-image: url('javascript:alert(1)'); } } </style>", + "<style>@keyframes xss { from { background-image: url('javascript:alert(1)'); } }" + + " </style>", "<div style=\"fill:expression(alert(1))\">Styled Div</div>", "<div style=\"background-image:url(javascript:alert(1))\">Background</div>", "<div style=\"behavior:url(xss.htc)\">HTC Component</div>"); @@ -278,7 +287,9 @@ for (String payload : payloads) { String code = "graph TD\n" - + " subgraph Sg [\"" + payload.replace("\"", "\\\"") + "\"]\n" + + " subgraph Sg [\"" + + payload.replace("\"", "\\\"") + + "\"]\n" + " A[Node A]\n" + " end\n" + " A --> B\n"; @@ -293,16 +304,18 @@ @Test public void testMermaidDirectivesCannotInjectCode() { String code = - "graph TD\n" - + " A[Node A] --> B[Node B]\n" - + " click A href \"javascript:alert('click-href')\"\n" - + " click B call alert('click-call')\n" - + " click A \"javascript:alert('click-positional')\"\n" - + " style A fill:url(javascript:alert('style-fill'))\n" - + " style B stroke:url(data:image/svg+xml,<svg onload=alert(1)>)\n" - + " classDef evil fill:red,stroke:url(javascript:alert(1));\n" - + " class A evil\n" - + " linkStyle 0 stroke:url(javascript:alert(1));\n"; + """ + graph TD + A[Node A] --> B[Node B] + click A href "javascript:alert('click-href')" + click B call alert('click-call') + click A "javascript:alert('click-positional')" + style A fill:url(javascript:alert('style-fill')) + style B stroke:url(data:image/svg+xml,<svg onload=alert(1)>) + classDef evil fill:red,stroke:url(javascript:alert(1)); + class A evil + linkStyle 0 stroke:url(javascript:alert(1)); + """; SvgDoc svg = SvgDoc.render(code); svg.assertNoDangerousTags(); @@ -363,23 +376,26 @@ @Test public void testEndToEndMarkdownXssPrevention() { String md = - "# Security Audit Title\n\n" - + "```mermaid\n" - + "graph TD\n" - + " A[\"<script>alert('e2e-node')</script>\"]\n" - + " B[\"<iframe src='javascript:alert(1)'></iframe>\"]\n" - + " C[\"<img src=x onerror=alert('e2e-img')>\"]\n" - + " D[\"<foreignObject><iframe src='http://evil.com'></iframe></foreignObject>\"]\n" - + " A -->|\"<script>alert('e2e-edge')</script>\"| B\n" - + " B --> C --> D\n" - + " click A href \"javascript:alert('e2e-click')\"\n" - + "```\n"; + """ + # Security Audit Title + + ```mermaid + graph TD + A["<script>alert('e2e-node')</script>"] + B["<iframe src='javascript:alert(1)'></iframe>"] + C["<img src=x onerror=alert('e2e-img')>"] + D["<foreignObject><iframe src='http://evil.com'></iframe></foreignObject>"] + A -->|"<script>alert('e2e-edge')</script>"| B + B --> C --> D + click A href "javascript:alert('e2e-click')" + ``` + """; Config cfg = new Config(); cfg.setBoolean("markdown", null, "mermaid", true); MarkdownConfig mc = new MarkdownConfig(cfg); - org.commonmark.node.Node node = GitilesMarkdown.parse(mc, md); - com.google.common.html.types.SafeHtml html = + Node node = GitilesMarkdown.parse(mc, md); + SafeHtml html = MarkdownToHtml.builder() .setConfig(mc) .setFilePath("security_test.md") @@ -401,7 +417,8 @@ // Verify all payloads are safely encoded as XML entity text assertThat(htmlStr).contains("<script>alert('e2e-node')</script>"); - assertThat(htmlStr).contains("<iframe src='javascript:alert(1)'></iframe>"); + assertThat(htmlStr) + .contains("<iframe src='javascript:alert(1)'></iframe>"); assertThat(htmlStr).contains("<img src=x onerror=alert('e2e-img')>"); } @@ -411,7 +428,7 @@ private void assertSafeSvg(String mermaidCode, String originalPayload) { Optional<String> svgOpt = SimpleMermaidRenderer.renderToSvg(mermaidCode); - assertThat(svgOpt.isPresent()).isTrue(); + assertThat(svgOpt).isPresent(); String rawSvg = svgOpt.get();
diff --git a/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java b/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java index 093b8f0..2d32676 100644 --- a/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java +++ b/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java
@@ -29,9 +29,11 @@ public class SimpleMermaidRendererTest { @Test - public void testConstructorInstantiation() { - SimpleMermaidRenderer renderer = new SimpleMermaidRenderer(); - assertThat(renderer).isNotNull(); + public void testConstructorInstantiation() throws Exception { + java.lang.reflect.Constructor<SimpleMermaidRenderer> c = + SimpleMermaidRenderer.class.getDeclaredConstructor(); + c.setAccessible(true); + assertThat(c.newInstance()).isNotNull(); } @Test @@ -40,8 +42,8 @@ svg.assertDefs(); // Verify exactly 2 node rects and 1 edge path - assertThat(svg.getElementsByTag("rect").size()).isEqualTo(2); - assertThat(svg.getElementsByTag("path").size()).isEqualTo(2); // 1 arrow in defs + 1 edge + assertThat(svg.getElementsByTag("rect")).hasSize(2); + assertThat(svg.getElementsByTag("path")).hasSize(2); // 1 arrow in defs + 1 edge // Verify exact node texts List<String> texts = svg.getAllTextContents(); @@ -74,22 +76,33 @@ @Test public void testAllNodeShapesExactSvgElements() { String code = - "graph TD\n" - + " A[Rectangle Box]\n" - + " B(Rounded Ball)\n" - + " C([Stadium Ring])\n" - + " D[[Subroutine Cart]]\n" - + " E[(Cylinder Drum)]\n" - + " F((Circle Star))\n" - + " G{{Hexagon Block}}\n" - + " H{Diamond Kite}\n" - + " I>Asymmetric Flag]\n"; + """ + graph TD + A[Rectangle Box] + B(Rounded Ball) + C([Stadium Ring]) + D[[Subroutine Cart]] + E[(Cylinder Drum)] + F((Circle Star)) + G{{Hexagon Block}} + H{Diamond Kite} + I>Asymmetric Flag] + """; SvgDoc svg = render(code); // Exact text elements List<String> texts = svg.getAllTextContents(); - assertThat(texts).containsExactly( - "Rectangle Box", "Rounded Ball", "Stadium Ring", "Subroutine Cart", "Cylinder Drum", "Circle Star", "Hexagon Block", "Diamond Kite", "Asymmetric Flag") + assertThat(texts) + .containsExactly( + "Rectangle Box", + "Rounded Ball", + "Stadium Ring", + "Subroutine Cart", + "Cylinder Drum", + "Circle Star", + "Hexagon Block", + "Diamond Kite", + "Asymmetric Flag") .inOrder(); // Verify Diamond has a polygon with 4 vertices @@ -108,24 +121,26 @@ // Verify Subroutine has rect with 2 inner border lines List<Element> lines = svg.getElementsByTag("line"); - assertThat(lines.size()).isEqualTo(2); + assertThat(lines).hasSize(2); // Verify Cylinder has 2 paths (body + top rim arc) List<Element> paths = svg.getEdgePaths(); - assertThat(paths.size()).isEqualTo(2); + assertThat(paths).hasSize(2); } @Test public void testMultilineNodeLabelsWithTspans() { String code = - "graph TD\n" - + " A[\"Sunny Blue Sky<br/>Warm Golden Sun<br/>Soft Green Grass\"]\n" - + " B[\"Little Red Apple\"]\n" - + " A --> B\n"; + """ + graph TD + A["Sunny Blue Sky<br/>Warm Golden Sun<br/>Soft Green Grass"] + B["Little Red Apple"] + A --> B + """; SvgDoc svg = render(code); List<Element> tspans = svg.getElementsByTag("tspan"); - assertThat(tspans.size()).isEqualTo(3); + assertThat(tspans).hasSize(3); assertThat(tspans.get(0).getTextContent()).isEqualTo("Sunny Blue Sky"); assertThat(tspans.get(0).getAttribute("font-weight")).isEqualTo("600"); assertThat(tspans.get(0).getAttribute("dy")).isEqualTo("0"); @@ -146,18 +161,19 @@ @Test public void testQuotedLabelsWithBracketsAndEntities() { String code = - "graph TD\n" - + " A[\"Play with teddy bear\"]\n" - + " B[\"Find [Puppy] in cozy room\"]\n" - + " C{\"Is kitten <tiny> & 'sweet'?\"}\n" - + " A --> B --> C\n"; + """ + graph TD + A["Play with teddy bear"] + B["Find [Puppy] in cozy room"] + C{"Is kitten <tiny> & 'sweet'?"} + A --> B --> C + """; SvgDoc svg = render(code); // XML parsing confirms correct unescaping of <, >, ', & - assertThat(svg.getAllTextContents()).containsExactly( - "Play with teddy bear", - "Find [Puppy] in cozy room", - "Is kitten <tiny> & 'sweet'?") + assertThat(svg.getAllTextContents()) + .containsExactly( + "Play with teddy bear", "Find [Puppy] in cozy room", "Is kitten <tiny> & 'sweet'?") .inOrder(); } @@ -170,24 +186,26 @@ @Test public void testAllEdgeTypesAndInfixLabelsExactAttributes() { String code = - "graph LR\n" - + " A -->|Yellow Duck| B\n" - + " B ---|Blue Bird| C\n" - + " C -.->|Green Frog| D\n" - + " D ==>|Red Puppy| E\n" - + " E -- Orange Kitten --> F\n" - + " F -- Purple Bunny --- G\n" - + " G == White Lamb ==> H\n" - + " H == Pink Piggy === I\n" - + " I -. Brown Bear .-> J\n" - + " J -. Gray Mouse .- K\n" - + " K -.- L\n" - + " L === M\n" - + " M <--> N\n"; + """ + graph LR + A -->|Yellow Duck| B + B ---|Blue Bird| C + C -.->|Green Frog| D + D ==>|Red Puppy| E + E -- Orange Kitten --> F + F -- Purple Bunny --- G + G == White Lamb ==> H + H == Pink Piggy === I + I -. Brown Bear .-> J + J -. Gray Mouse .- K + K -.- L + L === M + M <--> N + """; SvgDoc svg = render(code); List<Element> edgePaths = svg.getEdgePaths(); - assertThat(edgePaths.size()).isEqualTo(13); + assertThat(edgePaths).hasSize(13); // Verify dashed stroke Element dashedEdge = edgePaths.get(2); @@ -220,33 +238,35 @@ @Test public void testSubgraphsWithTitlesAliasesAndDirectionOverrides() { String code = - "graph TD\n" - + " subgraph Playground Park\n" - + " direction LR\n" - + " E1(puppy)\n" - + " E2(kitten)\n" - + " end\n" - + " subgraph ToyHouse [\"Magic Toy House\"]\n" - + " direction INVALID_DIR\n" - + " S1[(Teddy)]\n" - + " end\n" - + " subgraph \"Music Tree Castle\"\n" - + " T1[Wooden Blocks]\n" - + " end\n" - + " subgraph MeadowHill [Sunny Meadow Hill]\n" - + " U1[Little Duck]\n" - + " end\n" - + " Baby --> E1\n" - + " E1 --> ToyHouse\n" - + " ToyHouse --> T1\n" - + " T1 --> MeadowHill\n"; + """ + graph TD + subgraph Playground Park + direction LR + E1(puppy) + E2(kitten) + end + subgraph ToyHouse ["Magic Toy House"] + direction INVALID_DIR + S1[(Teddy)] + end + subgraph "Music Tree Castle" + T1[Wooden Blocks] + end + subgraph MeadowHill [Sunny Meadow Hill] + U1[Little Duck] + end + Baby --> E1 + E1 --> ToyHouse + ToyHouse --> T1 + T1 --> MeadowHill + """; SvgDoc svg = render(code); svg.assertNoLabelNodeOverlaps(); svg.assertSubgraphsDoNotOverlap(); // Verify 4 subgraph boundary rects (stroke-dasharray="4,4") List<Element> subgraphs = svg.findSubgraphRects(); - assertThat(subgraphs.size()).isEqualTo(4); + assertThat(subgraphs).hasSize(4); for (Element sgRect : subgraphs) { assertThat(sgRect.getAttribute("stroke-dasharray")).isEqualTo("4,4"); assertThat(sgRect.getAttribute("rx")).isEqualTo("8"); @@ -261,70 +281,78 @@ @Test public void testSubgraphToSubgraphAndSubgraphToNodeEdges() { - String codeTD = - "graph TD\n" - + " subgraph SubA [\"Garden A\"]\n" - + " A1[Daisy Flower]\n" - + " end\n" - + " subgraph SubB [\"Garden B\"]\n" - + " B1[Tulip Flower]\n" - + " end\n" - + " SubA -->|Garden Link TD| SubB\n" - + " SubA -->|Flower Link| NodeC[Red Rose]\n" - + " NodeC -->|Petal Link| SubB\n"; - SvgDoc svgTD = render(codeTD); - assertThat(svgTD.findText("Garden Link TD")).isNotNull(); - assertThat(svgTD.findText("Flower Link")).isNotNull(); - assertThat(svgTD.findText("Petal Link")).isNotNull(); - assertThat(svgTD.getElementsByTag("path")).isNotEmpty(); + String codeTd = + """ + graph TD + subgraph SubA ["Garden A"] + A1[Daisy Flower] + end + subgraph SubB ["Garden B"] + B1[Tulip Flower] + end + SubA -->|Garden Link TD| SubB + SubA -->|Flower Link| NodeC[Red Rose] + NodeC -->|Petal Link| SubB + """; + SvgDoc svgTd = render(codeTd); + assertThat(svgTd.findText("Garden Link TD")).isNotNull(); + assertThat(svgTd.findText("Flower Link")).isNotNull(); + assertThat(svgTd.findText("Petal Link")).isNotNull(); + assertThat(svgTd.getElementsByTag("path")).isNotEmpty(); - String codeLR = - "graph LR\n" - + " subgraph SubA [\"Garden A\"]\n" - + " A1[Daisy Flower]\n" - + " end\n" - + " subgraph SubB [\"Garden B\"]\n" - + " B1[Tulip Flower]\n" - + " end\n" - + " SubA -->|Garden Link LR| SubB\n"; - SvgDoc svgLR = render(codeLR); - assertThat(svgLR.findText("Garden Link LR")).isNotNull(); + String codeLr = + """ + graph LR + subgraph SubA ["Garden A"] + A1[Daisy Flower] + end + subgraph SubB ["Garden B"] + B1[Tulip Flower] + end + SubA -->|Garden Link LR| SubB + """; + SvgDoc svgLr = render(codeLr); + assertThat(svgLr.findText("Garden Link LR")).isNotNull(); - String codeLRWithBlockedNode = - "graph LR\n" - + " subgraph SubA [\"Garden A\"]\n" - + " A1[Daisy Flower]\n" - + " end\n" - + " subgraph SubB [\"Garden B\"]\n" - + " B1[Tulip Flower]\n" - + " end\n" - + " SubA -->|Garden Link LR Blocked| SubB\n" - + " SubA -->|Flower Link LR| NodeC[Red Rose]\n" - + " NodeC -->|Petal Link LR| SubB\n"; - SvgDoc svgLRBlocked = render(codeLRWithBlockedNode); - assertThat(svgLRBlocked.findText("Garden Link LR Blocked")).isNotNull(); - assertThat(svgLRBlocked.findText("Flower Link LR")).isNotNull(); - assertThat(svgLRBlocked.findText("Petal Link LR")).isNotNull(); + String codeLrWithBlockedNode = + """ + graph LR + subgraph SubA ["Garden A"] + A1[Daisy Flower] + end + subgraph SubB ["Garden B"] + B1[Tulip Flower] + end + SubA -->|Garden Link LR Blocked| SubB + SubA -->|Flower Link LR| NodeC[Red Rose] + NodeC -->|Petal Link LR| SubB + """; + SvgDoc svgLrBlocked = render(codeLrWithBlockedNode); + assertThat(svgLrBlocked.findText("Garden Link LR Blocked")).isNotNull(); + assertThat(svgLrBlocked.findText("Flower Link LR")).isNotNull(); + assertThat(svgLrBlocked.findText("Petal Link LR")).isNotNull(); } @Test public void testNestedSubgraphsExactHierarchy() { String code = - "graph TD\n" - + " subgraph Sandbox [\"Play Sandbox\"]\n" - + " subgraph SandCastle [\"Sand Castle\"]\n" - + " SPA[\"Red Bucket\"]\n" - + " end\n" - + " subgraph ToyPond [\"Toy Pond\"]\n" - + " CS[\"Yellow Boat\"]\n" - + " end\n" - + " end\n" - + " SPA -->|Water Splash| CS\n"; + """ + graph TD + subgraph Sandbox ["Play Sandbox"] + subgraph SandCastle ["Sand Castle"] + SPA["Red Bucket"] + end + subgraph ToyPond ["Toy Pond"] + CS["Yellow Boat"] + end + end + SPA -->|Water Splash| CS + """; SvgDoc svg = render(code); // Exactly 3 subgraph boxes (1 outer + 2 inner) List<Element> subgraphs = svg.findSubgraphRects(); - assertThat(subgraphs.size()).isEqualTo(3); + assertThat(subgraphs).hasSize(3); assertThat(svg.findText("Play Sandbox")).isNotNull(); assertThat(svg.findText("Sand Castle")).isNotNull(); @@ -337,82 +365,94 @@ @Test public void testNestedSubgraphsHorizontal() { String code = - "graph LR\n" - + " subgraph Outer [\"Playhouse\"]\n" - + " subgraph InnerA [\"Kitten Corner\"]\n" - + " A1[Soft Pillow]\n" - + " end\n" - + " subgraph InnerB [\"Puppy Corner\"]\n" - + " B1[Squeaky Ball]\n" - + " end\n" - + " end\n" - + " A1 -->|Play Time| B1\n"; + """ + graph LR + subgraph Outer ["Playhouse"] + subgraph InnerA ["Kitten Corner"] + A1[Soft Pillow] + end + subgraph InnerB ["Puppy Corner"] + B1[Squeaky Ball] + end + end + A1 -->|Play Time| B1 + """; SvgDoc svg = render(code); assertThat(svg.findText("Play Time")).isNotNull(); } @Test public void testSubgraphWithMixedChildrenAndDirectNodes() { - String codeTD = - "graph TD\n" - + " subgraph OuterTD [\"Tree House TD\"]\n" - + " subgraph InnerTD [\"Bird Nest TD\"]\n" - + " A1[Baby Bird TD]\n" - + " end\n" - + " D1[Little Squirrel TD]\n" - + " end\n" - + " A1 --> D1\n"; - SvgDoc svgTD = render(codeTD); - assertThat(svgTD.findText("Tree House TD")).isNotNull(); - assertThat(svgTD.findText("Bird Nest TD")).isNotNull(); - assertThat(svgTD.findText("Little Squirrel TD")).isNotNull(); + String codeTd = + """ + graph TD + subgraph OuterTD ["Tree House TD"] + subgraph InnerTD ["Bird Nest TD"] + A1[Baby Bird TD] + end + D1[Little Squirrel TD] + end + A1 --> D1 + """; + SvgDoc svgTd = render(codeTd); + assertThat(svgTd.findText("Tree House TD")).isNotNull(); + assertThat(svgTd.findText("Bird Nest TD")).isNotNull(); + assertThat(svgTd.findText("Little Squirrel TD")).isNotNull(); - String codeLR = - "graph LR\n" - + " subgraph OuterLR [\"Tree House LR\"]\n" - + " subgraph InnerLR [\"Bird Nest LR\"]\n" - + " A1[Baby Bird LR]\n" - + " end\n" - + " D1[Little Squirrel LR]\n" - + " end\n" - + " A1 --> D1\n"; - SvgDoc svgLR = render(codeLR); - assertThat(svgLR.findText("Tree House LR")).isNotNull(); - assertThat(svgLR.findText("Bird Nest LR")).isNotNull(); - assertThat(svgLR.findText("Little Squirrel LR")).isNotNull(); + String codeLr = + """ + graph LR + subgraph OuterLR ["Tree House LR"] + subgraph InnerLR ["Bird Nest LR"] + A1[Baby Bird LR] + end + D1[Little Squirrel LR] + end + A1 --> D1 + """; + SvgDoc svgLr = render(codeLr); + assertThat(svgLr.findText("Tree House LR")).isNotNull(); + assertThat(svgLr.findText("Bird Nest LR")).isNotNull(); + assertThat(svgLr.findText("Little Squirrel LR")).isNotNull(); } @Test public void testNestedSubgraphWithLabeledAdjacentEdge() { - String codeTD = - "graph TD\n" - + " subgraph SubTD [\"Animal Farm TD\"]\n" - + " A[Happy Lamb]\n" - + " B[Little Pony]\n" - + " A -->|Green Grass TD| B\n" - + " end\n"; - SvgDoc svgTD = render(codeTD); - assertThat(svgTD.findText("Green Grass TD")).isNotNull(); + String codeTd = + """ + graph TD + subgraph SubTD ["Animal Farm TD"] + A[Happy Lamb] + B[Little Pony] + A -->|Green Grass TD| B + end + """; + SvgDoc svgTd = render(codeTd); + assertThat(svgTd.findText("Green Grass TD")).isNotNull(); - String codeLR = - "graph LR\n" - + " subgraph SubLR [\"Animal Farm LR\"]\n" - + " A[Happy Lamb]\n" - + " B[Little Pony]\n" - + " A -->|Green Grass LR| B\n" - + " end\n"; - SvgDoc svgLR = render(codeLR); - assertThat(svgLR.findText("Green Grass LR")).isNotNull(); + String codeLr = + """ + graph LR + subgraph SubLR ["Animal Farm LR"] + A[Happy Lamb] + B[Little Pony] + A -->|Green Grass LR| B + end + """; + SvgDoc svgLr = render(codeLr); + assertThat(svgLr.findText("Green Grass LR")).isNotNull(); } @Test public void testSugiyamaLateralAdjacentLabeledEdge() { String code = - "graph TD\n" - + " A1[Fuzzy Panda] --> B1[Baby Giraffe]\n" - + " A2[Little Koala] --> B2[Tiny Hamster]\n" - + " A1 -->|Sunny Day| A2\n" - + " B1 -->|Happy Play| B2\n"; + """ + graph TD + A1[Fuzzy Panda] --> B1[Baby Giraffe] + A2[Little Koala] --> B2[Tiny Hamster] + A1 -->|Sunny Day| A2 + B1 -->|Happy Play| B2 + """; SvgDoc svg = render(code); assertThat(svg.findText("Sunny Day")).isNotNull(); assertThat(svg.findText("Happy Play")).isNotNull(); @@ -420,78 +460,92 @@ @Test public void testBidirectionalMutualEdgesBothOrientations() { - String codeTD = - "graph TD\n" - + " A[\"Little Lamb\"]\n" - + " B[\"Sweet Bunny\"]\n" - + " A -->|Hop Down| B\n" - + " B -->|Jump Up| A\n"; - SvgDoc svgTD = render(codeTD); - assertThat(svgTD.findText("Hop Down")).isNotNull(); - assertThat(svgTD.findText("Jump Up")).isNotNull(); + String codeTd = + """ + graph TD + A["Little Lamb"] + B["Sweet Bunny"] + A -->|Hop Down| B + B -->|Jump Up| A + """; + SvgDoc svgTd = render(codeTd); + assertThat(svgTd.findText("Hop Down")).isNotNull(); + assertThat(svgTd.findText("Jump Up")).isNotNull(); // Exactly 2 mutual curved edge paths - assertThat(svgTD.getEdgePaths().size()).isEqualTo(2); + assertThat(svgTd.getEdgePaths()).hasSize(2); - String codeLR = - "graph LR\n" - + " A[\"Little Lamb\"]\n" - + " B[\"Sweet Bunny\"]\n" - + " A -->|Run Forward| B\n" - + " B -->|Run Backward| A\n"; - SvgDoc svgLR = render(codeLR); - assertThat(svgLR.findText("Run Forward")).isNotNull(); - assertThat(svgLR.findText("Run Backward")).isNotNull(); - assertThat(svgLR.getEdgePaths().size()).isEqualTo(2); + String codeLr = + """ + graph LR + A["Little Lamb"] + B["Sweet Bunny"] + A -->|Run Forward| B + B -->|Run Backward| A + """; + SvgDoc svgLr = render(codeLr); + assertThat(svgLr.findText("Run Forward")).isNotNull(); + assertThat(svgLr.findText("Run Backward")).isNotNull(); + assertThat(svgLr.getEdgePaths()).hasSize(2); } @Test public void testCycleDetectionAndLoopbackBothOrientations() { - String codeTD = - "graph TD\n" - + " A --> B\n" - + " B --> C\n" - + " C -->|Loop TD| A\n"; - SvgDoc svgTD = render(codeTD); - assertThat(svgTD.findText("Loop TD")).isNotNull(); - assertThat(svgTD.getEdgePaths().size()).isEqualTo(3); + String codeTd = + """ + graph TD + A --> B + B --> C + C -->|Loop TD| A + """; + SvgDoc svgTd = render(codeTd); + assertThat(svgTd.findText("Loop TD")).isNotNull(); + assertThat(svgTd.getEdgePaths()).hasSize(3); - String codeLR = - "graph LR\n" - + " A --> B\n" - + " B --> C\n" - + " C -->|Loop LR| A\n"; - SvgDoc svgLR = render(codeLR); - assertThat(svgLR.findText("Loop LR")).isNotNull(); - assertThat(svgLR.getEdgePaths().size()).isEqualTo(3); + String codeLr = + """ + graph LR + A --> B + B --> C + C -->|Loop LR| A + """; + SvgDoc svgLr = render(codeLr); + assertThat(svgLr.findText("Loop LR")).isNotNull(); + assertThat(svgLr.getEdgePaths()).hasSize(3); } @Test public void testSkipLayerBypassBothOrientations() { - String codeTD = - "graph TD\n" - + " A --> B\n" - + " B --> C\n" - + " A -->|Skip TD| C\n"; - SvgDoc svgTD = render(codeTD); - assertThat(svgTD.findText("Skip TD")).isNotNull(); + String codeTd = + """ + graph TD + A --> B + B --> C + A -->|Skip TD| C + """; + SvgDoc svgTd = render(codeTd); + assertThat(svgTd.findText("Skip TD")).isNotNull(); - String codeLR = - "graph LR\n" - + " A --> B\n" - + " B --> C\n" - + " A -->|Skip LR| C\n"; - SvgDoc svgLR = render(codeLR); - assertThat(svgLR.findText("Skip LR")).isNotNull(); + String codeLr = + """ + graph LR + A --> B + B --> C + A -->|Skip LR| C + """; + SvgDoc svgLr = render(codeLr); + assertThat(svgLr.findText("Skip LR")).isNotNull(); } @Test public void testHorizontalGraphWithCycleAndLongSpanEdge() { String code = - "graph LR\n" - + " A[Happy Kitten] --> B[Playful Puppy]\n" - + " B --> C[Cozy Hamster]\n" - + " C -->|Run Back| A\n" - + " A -->|Long Leap| C\n"; + """ + graph LR + A[Happy Kitten] --> B[Playful Puppy] + B --> C[Cozy Hamster] + C -->|Run Back| A + A -->|Long Leap| C + """; SvgDoc doc = render(code); assertThat(doc.findText("Happy Kitten")).isNotNull(); assertThat(doc.findText("Playful Puppy")).isNotNull(); @@ -503,12 +557,14 @@ @Test public void testNestedSubgraphWithLooseSourceCompaction() { String code = - "graph TD\n" - + " subgraph Meadow [\"Sunny Green Meadow\"]\n" - + " A[Bright Buttercup] --> B[Busy Ant]\n" - + " B --> C[Tall Oak Tree]\n" - + " D[Quiet Snail] --> C\n" - + " end\n"; + """ + graph TD + subgraph Meadow ["Sunny Green Meadow"] + A[Bright Buttercup] --> B[Busy Ant] + B --> C[Tall Oak Tree] + D[Quiet Snail] --> C + end + """; SvgDoc doc = render(code); assertThat(doc.findText("Sunny Green Meadow")).isNotNull(); assertThat(doc.findText("Bright Buttercup")).isNotNull(); @@ -520,16 +576,18 @@ @Test public void testDirectivesAndStylingIgnoredGracefully() { String code = - "graph TD\n" - + " accTitle: Cheerful Morning Playground\n" - + " accDescr: Story of fluffy puppy and kitten\n" - + " classDef default fill:#f9f,stroke:#333;\n" - + " classDef special fill:#bbf,stroke:#333;\n" - + " class A special\n" - + " style B fill:#dfd,stroke:#333;\n" - + " click A href \"https://example.com\"\n" - + " linkStyle 0 stroke:#ff3,stroke-width:4px;\n" - + " A[Little Kitten] --> B[Fluffy Bunny]\n"; + """ + graph TD + accTitle: Cheerful Morning Playground + accDescr: Story of fluffy puppy and kitten + classDef default fill:#f9f,stroke:#333; + classDef special fill:#bbf,stroke:#333; + class A special + style B fill:#dfd,stroke:#333; + click A href "https://example.com" + linkStyle 0 stroke:#ff3,stroke-width:4px; + A[Little Kitten] --> B[Fluffy Bunny] + """; SvgDoc svg = render(code); assertThat(svg.getAllTextContents()).containsExactly("Little Kitten", "Fluffy Bunny").inOrder(); } @@ -537,9 +595,11 @@ @Test public void testDisconnectedNodes() { SvgDoc svg = render("graph TD\n A[Quiet Mouse]\n B[Sleeping Turtle]\n C --> D\n"); - assertThat(svg.getAllTextContents()).containsExactly("Quiet Mouse", "Sleeping Turtle", "C", "D").inOrder(); - assertThat(svg.getElementsByTag("rect").size()).isEqualTo(4); - assertThat(svg.getEdgePaths().size()).isEqualTo(1); + assertThat(svg.getAllTextContents()) + .containsExactly("Quiet Mouse", "Sleeping Turtle", "C", "D") + .inOrder(); + assertThat(svg.getElementsByTag("rect")).hasSize(4); + assertThat(svg.getEdgePaths()).hasSize(1); } @Test @@ -550,11 +610,11 @@ @Test public void testWhitespaceAndEmptyBlocks() { - assertThat(SimpleMermaidRenderer.renderToSvg(" \n\n\t").isPresent()).isFalse(); - assertThat(SimpleMermaidRenderer.renderToSvg("graph TD\n").isPresent()).isFalse(); - assertThat(SimpleMermaidRenderer.renderToSvg("graph TD\n%% only comments\n").isPresent()).isFalse(); - assertThat(SimpleMermaidRenderer.renderToSvg("").isPresent()).isFalse(); - assertThat(SimpleMermaidRenderer.renderToSvg(null).isPresent()).isFalse(); + assertThat(SimpleMermaidRenderer.renderToSvg(" \n\n\t")).isEmpty(); + assertThat(SimpleMermaidRenderer.renderToSvg("graph TD\n")).isEmpty(); + assertThat(SimpleMermaidRenderer.renderToSvg("graph TD\n%% only comments\n")).isEmpty(); + assertThat(SimpleMermaidRenderer.renderToSvg("")).isEmpty(); + assertThat(SimpleMermaidRenderer.renderToSvg(null)).isEmpty(); } @Test @@ -563,8 +623,7 @@ SvgDoc svg = render(code); assertThat(svg.getAllTextContents()).containsExactly("A", "B").inOrder(); - assertThat(SimpleMermaidRenderer.renderToSvg("%% only comments\nsome random text\n").isPresent()) - .isFalse(); + assertThat(SimpleMermaidRenderer.renderToSvg("%% only comments\nsome random text\n")).isEmpty(); } @Test @@ -576,29 +635,35 @@ @Test public void testAllUnsupportedDiagramTypesReturnEmpty() { - assertThat(SimpleMermaidRenderer.renderToSvg("sequenceDiagram\nAlice->>Bob: Hello\n").isPresent()).isFalse(); - assertThat(SimpleMermaidRenderer.renderToSvg("classDiagram\nClass01 <|-- Class02\n").isPresent()).isFalse(); - assertThat(SimpleMermaidRenderer.renderToSvg("erDiagram\nCUSTOMER ||--o{ ORDER : places\n").isPresent()).isFalse(); - assertThat(SimpleMermaidRenderer.renderToSvg("gantt\ntitle A Gantt Diagram\n").isPresent()).isFalse(); - assertThat(SimpleMermaidRenderer.renderToSvg("pie title Pets\n\"Dogs\" : 386\n").isPresent()).isFalse(); - assertThat(SimpleMermaidRenderer.renderToSvg("gitGraph\ncommit\n").isPresent()).isFalse(); - assertThat(SimpleMermaidRenderer.renderToSvg("xychart-beta\ntitle \"Score\"\n").isPresent()).isFalse(); - assertThat(SimpleMermaidRenderer.renderToSvg("stateDiagram\n[*] --> Still\n").isPresent()).isFalse(); - assertThat(SimpleMermaidRenderer.renderToSvg("stateDiagram-v2\n[*] --> Still\n").isPresent()).isFalse(); + assertThat(SimpleMermaidRenderer.renderToSvg("sequenceDiagram\nAlice->>Bob: Hello\n")) + .isEmpty(); + assertThat(SimpleMermaidRenderer.renderToSvg("classDiagram\nClass01 <|-- Class02\n")).isEmpty(); + assertThat(SimpleMermaidRenderer.renderToSvg("erDiagram\nCUSTOMER ||--o{ ORDER : places\n")) + .isEmpty(); + assertThat(SimpleMermaidRenderer.renderToSvg("gantt\ntitle A Gantt Diagram\n")).isEmpty(); + assertThat(SimpleMermaidRenderer.renderToSvg("pie title Pets\n\"Dogs\" : 386\n")).isEmpty(); + assertThat(SimpleMermaidRenderer.renderToSvg("gitGraph\ncommit\n")).isEmpty(); + assertThat(SimpleMermaidRenderer.renderToSvg("xychart-beta\ntitle \"Score\"\n")).isEmpty(); + assertThat(SimpleMermaidRenderer.renderToSvg("stateDiagram\n[*] --> Still\n")).isEmpty(); + assertThat(SimpleMermaidRenderer.renderToSvg("stateDiagram-v2\n[*] --> Still\n")).isEmpty(); } @Test public void testNodeReassignmentToSubgraph() { String code = - "graph TD\n" - + " A[Singing Robin]\n" - + " subgraph Sub\n" - + " A\n" - + " B[Flying Bluebird]\n" - + " end\n" - + " A --> B\n"; + """ + graph TD + A[Singing Robin] + subgraph Sub + A + B[Flying Bluebird] + end + A --> B + """; SvgDoc svg = render(code); - assertThat(svg.getAllTextContents()).containsExactly("Sub", "Singing Robin", "Flying Bluebird").inOrder(); + assertThat(svg.getAllTextContents()) + .containsExactly("Sub", "Singing Robin", "Flying Bluebird") + .inOrder(); } @Test @@ -609,12 +674,24 @@ assertThat(doc1.findText("Sweet Honey Pie")).isNotNull(); // Reverse edge in vertical nested subgraph - String code2 = "graph TD\n subgraph Sub\n A[Baby Chick]\n B[Mama Hen]\n B -->|Chirp Vert| A\n end\n"; + String code2 = + "graph TD\n" + + " subgraph Sub\n" + + " A[Baby Chick]\n" + + " B[Mama Hen]\n" + + " B -->|Chirp Vert| A\n" + + " end\n"; SvgDoc doc2 = render(code2); assertThat(doc2.findText("Chirp Vert")).isNotNull(); // Reverse edge in horizontal nested subgraph - String code3 = "graph LR\n subgraph Sub\n A[Baby Chick]\n B[Mama Hen]\n B -->|Chirp Horiz| A\n end\n"; + String code3 = + "graph LR\n" + + " subgraph Sub\n" + + " A[Baby Chick]\n" + + " B[Mama Hen]\n" + + " B -->|Chirp Horiz| A\n" + + " end\n"; SvgDoc doc3 = render(code3); assertThat(doc3.findText("Chirp Horiz")).isNotNull(); @@ -624,7 +701,7 @@ // Trailing non-edge characters to hit scanEdgeToken default return null String code6 = "graph TD\n A 12345\n"; - assertThat(SimpleMermaidRenderer.renderToSvg(code6).isPresent()).isTrue(); + assertThat(SimpleMermaidRenderer.renderToSvg(code6)).isPresent(); } private static SvgDoc render(String code) { @@ -640,7 +717,7 @@ // Redundant header line in body String code2 = "graph TD\n graph TD\n A --> B\n"; - assertThat(SimpleMermaidRenderer.renderToSvg(code2).isPresent()).isTrue(); + assertThat(SimpleMermaidRenderer.renderToSvg(code2)).isPresent(); // Direct AST Node empty label setter SimpleMermaidRenderer.Node n = new SimpleMermaidRenderer.Node("testNode"); @@ -648,24 +725,30 @@ assertThat(n.labelLines).containsExactly(""); // Double quotes in label and title to exercise escapeXml - String code3 = "graph TD\n subgraph Sg [\"Magic Castle with \\\"Stars\\\"\"]\n A[\"Has \\\"Glitter\\\" in pocket\"]\n end\n"; - assertThat(SimpleMermaidRenderer.renderToSvg(code3).isPresent()).isTrue(); + String code3 = + "graph TD\n" + + " subgraph Sg [\"Magic Castle with \\\"Stars\\\"\"]\n" + + " A[\"Has \\\"Glitter\\\" in pocket\"]\n" + + " end\n"; + assertThat(SimpleMermaidRenderer.renderToSvg(code3)).isPresent(); } @Test public void testSecurityNoScriptOrIframeExecutionInNodeLabels() { String code = - "graph TD\n" - + " A[\"<script>alert('xss-script')</script>\"]\n" - + " B[\"<iframe src='javascript:alert(1)'></iframe>\"]\n" - + " C[\"<img src=x onerror=alert('img-onerror')>\"]\n" - + " D[\"<svg onload=alert('svg-onload')>\"]\n" - + " E[\"<foreignObject><iframe src='https://evil.com'></iframe></foreignObject>\"]\n" - + " F[\"<a href='javascript:alert(1)'>Click Me</a>\"]\n" - + " A --> B --> C --> D --> E --> F\n"; + """ + graph TD + A["<script>alert('xss-script')</script>"] + B["<iframe src='javascript:alert(1)'></iframe>"] + C["<img src=x onerror=alert('img-onerror')>"] + D["<svg onload=alert('svg-onload')>"] + E["<foreignObject><iframe src='https://evil.com'></iframe></foreignObject>"] + F["<a href='javascript:alert(1)'>Click Me</a>"] + A --> B --> C --> D --> E --> F + """; Optional<String> svgOpt = SimpleMermaidRenderer.renderToSvg(code); - assertThat(svgOpt.isPresent()).isTrue(); + assertThat(svgOpt).isPresent(); SvgDoc svg = new SvgDoc(svgOpt.get()); svg.assertRootSvg(); @@ -692,18 +775,22 @@ assertThat(svg.findText("<iframe src='javascript:alert(1)'></iframe>")).isNotNull(); assertThat(svg.findText("<img src=x onerror=alert('img-onerror')>")).isNotNull(); assertThat(svg.findText("<svg onload=alert('svg-onload')>")).isNotNull(); - assertThat(svg.findText("<foreignObject><iframe src='https://evil.com'></iframe></foreignObject>")).isNotNull(); + assertThat( + svg.findText("<foreignObject><iframe src='https://evil.com'></iframe></foreignObject>")) + .isNotNull(); assertThat(svg.findText("<a href='javascript:alert(1)'>Click Me</a>")).isNotNull(); } @Test public void testSecurityNoScriptOrIframeInEdgeLabels() { String code = - "graph TD\n" - + " A -->|\"<script>alert('edge-pipe')</script>\"| B\n" - + " B -- \"<iframe src='http://evil.com'></iframe>\" --> C\n" - + " C == \"<img src=x onerror=alert('thick-edge')>\" ==> D\n" - + " D -. \"<svg onload=alert('dashed-edge')>\" .-> E\n"; + """ + graph TD + A -->|"<script>alert('edge-pipe')</script>"| B + B -- "<iframe src='http://evil.com'></iframe>" --> C + C == "<img src=x onerror=alert('thick-edge')>" ==> D + D -. "<svg onload=alert('dashed-edge')>" .-> E + """; SvgDoc svg = render(code); assertThat(svg.getElementsByTag("script")).isEmpty(); @@ -727,14 +814,16 @@ @Test public void testSecurityNoScriptOrIframeInSubgraphTitles() { String code = - "graph TD\n" - + " subgraph Sg1 [\"<script>alert('subgraph-title')</script>\"]\n" - + " A[Node A]\n" - + " end\n" - + " subgraph Sg2 [\"<iframe src='javascript:alert(2)'></iframe>\"]\n" - + " B[Node B]\n" - + " end\n" - + " A --> B\n"; + """ + graph TD + subgraph Sg1 ["<script>alert('subgraph-title')</script>"] + A[Node A] + end + subgraph Sg2 ["<iframe src='javascript:alert(2)'></iframe>"] + B[Node B] + end + A --> B + """; SvgDoc svg = render(code); assertThat(svg.getElementsByTag("script")).isEmpty(); @@ -753,14 +842,16 @@ @Test public void testSecurityDirectivesCannotInjectJavascriptUrls() { String code = - "graph TD\n" - + " A[Node A] --> B[Node B]\n" - + " click A href \"javascript:alert('click-href')\"\n" - + " click B call alert('click-call')\n" - + " click A \"javascript:alert('positional-href')\"\n" - + " style A fill:url(javascript:alert(1))\n" - + " classDef evil fill:red,color:white;\n" - + " linkStyle 0 stroke:red;\n"; + """ + graph TD + A[Node A] --> B[Node B] + click A href "javascript:alert('click-href')" + click B call alert('click-call') + click A "javascript:alert('positional-href')" + style A fill:url(javascript:alert(1)) + classDef evil fill:red,color:white; + linkStyle 0 stroke:red; + """; SvgDoc svg = render(code); assertThat(svg.getElementsByTag("a")).isEmpty(); @@ -776,15 +867,17 @@ @Test public void testSecurityXmlBreakoutPayloads() { String code = - "graph TD\n" - + " A[\"</text></svg><script>alert('breakout')</script><svg><text>\"]\n" - + " B[\"'\"><script>alert('quote-breakout')</script>\"]\n" - + " A --> B\n"; + """ + graph TD + A["</text></svg><script>alert('breakout')</script><svg><text>"] + B["'"><script>alert('quote-breakout')</script>"] + A --> B + """; SvgDoc svg = render(code); // Verify the document root remains the only SVG element and no script elements were injected assertThat(svg.getElementsByTag("script")).isEmpty(); - assertThat(svg.getElementsByTag("svg").size()).isEqualTo(1); + assertThat(svg.getElementsByTag("svg")).hasSize(1); String raw = SimpleMermaidRenderer.renderToSvg(code).get(); assertThat(raw).doesNotContain("<script"); @@ -794,25 +887,27 @@ @Test public void testIsolatedSubgraphAlongsideMainDagTree() { String code = - "graph TD\n" - + " ClientApp[Little Puppy Plays] --> Extras(Sweet Kitten)\n" - + " ClientApp --> Utils(Happy Bunny)\n" - + " \n" - + " Utils --> ServiceDiscovery[Red Apple Berry]\n" - + " Utils --> ModelManager[Yellow Banana Snack]\n" - + " \n" - + " Extras --> Recognition(Fluffy Duckling)\n" - + " \n" - + " Recognition --> SODA(Green Frog Jump)\n" - + " Recognition --> S3(Sunny Daisy Flower)\n" - + " \n" - + " subgraph Play Park Garden\n" - + " Executors(Teddy Bear)\n" - + " Errors(Wooden Blocks)\n" - + " Protos(Toy Wagon)\n" - + " end\n" - + " \n" - + " Recognition -.-> PlayParkGarden\n"; + """ + graph TD + ClientApp[Little Puppy Plays] --> Extras(Sweet Kitten) + ClientApp --> Utils(Happy Bunny) + \s + Utils --> ServiceDiscovery[Red Apple Berry] + Utils --> ModelManager[Yellow Banana Snack] + \s + Extras --> Recognition(Fluffy Duckling) + \s + Recognition --> SODA(Green Frog Jump) + Recognition --> S3(Sunny Daisy Flower) + \s + subgraph Play Park Garden + Executors(Teddy Bear) + Errors(Wooden Blocks) + Protos(Toy Wagon) + end + \s + Recognition -.-> PlayParkGarden + """; SvgDoc svg = render(code); // Verify all nodes and subgraph title exist @@ -830,7 +925,8 @@ assertThat(svg.findText("Sunny Daisy Flower")).isNotNull(); assertThat(svg.findText("PlayParkGarden")).isNotNull(); - // Verify vertical stack in Play Park Garden subgraph (Teddy Bear above Wooden Blocks above Toy Wagon) + // Verify vertical stack in Play Park Garden subgraph (Teddy Bear above Wooden Blocks above Toy + // Wagon) Element executorsText = svg.findText("Teddy Bear"); Element errorsText = svg.findText("Wooden Blocks"); Element protoText = svg.findText("Toy Wagon"); @@ -850,20 +946,22 @@ @Test public void testMultiNodeChainingWithAmpersand() { String code = - "graph TD\n" - + " A[Little Star] --> CheckJDAA{Is Puppy Sleepy?}\n" - + " CheckJDAA -- No --> InstallJDA[Play With Soft Ball]\n" - + " InstallJDA --> CheckJDAA\n" - + " CheckJDAA -- Yes --> B{Wants Sweet Cookie?}\n" - + " B -- Yes --> C[Drink Warm Milk Cup]\n" - + " B -- No --> D[Sing Happy Lullaby]\n" - + " D --> E[Cuddle Warm Blanket]\n" - + " D --> F[Hug Fluffy Panda]\n" - + " D --> G[Close Shiny Eyes]\n" - + " E & F & G --> H[Sweet Dreams Forest]\n" - + " C & H --> I[Gentle Good Night]\n" - + " I --> J[Sleep Until Morning]\n" - + " J --> K[Wake Up Happy Sun]\n"; + """ + graph TD + A[Little Star] --> CheckJDAA{Is Puppy Sleepy?} + CheckJDAA -- No --> InstallJDA[Play With Soft Ball] + InstallJDA --> CheckJDAA + CheckJDAA -- Yes --> B{Wants Sweet Cookie?} + B -- Yes --> C[Drink Warm Milk Cup] + B -- No --> D[Sing Happy Lullaby] + D --> E[Cuddle Warm Blanket] + D --> F[Hug Fluffy Panda] + D --> G[Close Shiny Eyes] + E & F & G --> H[Sweet Dreams Forest] + C & H --> I[Gentle Good Night] + I --> J[Sleep Until Morning] + J --> K[Wake Up Happy Sun] + """; SvgDoc svg = render(code); // Verify all nodes exist @@ -909,21 +1007,24 @@ assertThat(multiDoc.findText("B")).isNotNull(); assertThat(multiDoc.findText("C")).isNotNull(); assertThat(multiDoc.findText("D")).isNotNull(); - assertThat(multiDoc.getElementsByTag("path").size()).isEqualTo(5); // 1 marker path in <defs> + 4 edge paths + assertThat(multiDoc.getElementsByTag("path")) + .hasSize(5); // 1 marker path in <defs> + 4 edge paths } @Test public void testSubgraphDirectionOverrideWithCrossEdges() { String code = - "graph TD\n" - + " subgraph Castle [\"Toy Castle\"]\n" - + " direction LR\n" - + " A[Happy Bear] --> B[Silly Goose]\n" - + " end\n" - + " subgraph Garden [\"Flower Garden\"]\n" - + " C[Sunny Daisy]\n" - + " end\n" - + " B --> C\n"; + """ + graph TD + subgraph Castle ["Toy Castle"] + direction LR + A[Happy Bear] --> B[Silly Goose] + end + subgraph Garden ["Flower Garden"] + C[Sunny Daisy] + end + B --> C + """; SvgDoc doc = render(code); assertThat(doc.findText("Toy Castle")).isNotNull(); assertThat(doc.findText("Flower Garden")).isNotNull(); @@ -935,20 +1036,22 @@ @Test public void testNestedSubgraphsWithLabeledInterChildEdge() { String code = - "graph LR\n" - + " subgraph ToyBox [\"Big Toy Box\"]\n" - + " subgraph PuzzleA [\"Puppy Puzzle\"]\n" - + " A[Little Dog]\n" - + " end\n" - + " subgraph PuzzleB [\"Kitten Puzzle\"]\n" - + " B[Little Cat]\n" - + " end\n" - + " A -->|Friendly Meow| B\n" - + " end\n" - + " subgraph BedTime [\"Sleepy Pillow\"]\n" - + " C[Cozy Blanket]\n" - + " end\n" - + " B -->|Soft Hug| C\n"; + """ + graph LR + subgraph ToyBox ["Big Toy Box"] + subgraph PuzzleA ["Puppy Puzzle"] + A[Little Dog] + end + subgraph PuzzleB ["Kitten Puzzle"] + B[Little Cat] + end + A -->|Friendly Meow| B + end + subgraph BedTime ["Sleepy Pillow"] + C[Cozy Blanket] + end + B -->|Soft Hug| C + """; SvgDoc doc = render(code); assertThat(doc.findText("Big Toy Box")).isNotNull(); assertThat(doc.findText("Puppy Puzzle")).isNotNull(); @@ -964,18 +1067,20 @@ @Test public void testNestedSubgraphsVerticalWithCrossEdgesAndEmptySubgraphs() { String code = - "graph TD\n" - + " subgraph WonderLand [\"Magic Wonderland\"]\n" - + " subgraph EmptyBox [\"Empty Treasure Chest\"]\n" - + " end\n" - + " subgraph ZoneA [\"Butterfly Valley\"]\n" - + " A[Shiny Butterfly]\n" - + " end\n" - + " subgraph ZoneB [\"Rainbow Hill\"]\n" - + " B[Glowing Rainbow]\n" - + " end\n" - + " B -->|Sweet Melody| A\n" - + " end\n"; + """ + graph TD + subgraph WonderLand ["Magic Wonderland"] + subgraph EmptyBox ["Empty Treasure Chest"] + end + subgraph ZoneA ["Butterfly Valley"] + A[Shiny Butterfly] + end + subgraph ZoneB ["Rainbow Hill"] + B[Glowing Rainbow] + end + B -->|Sweet Melody| A + end + """; SvgDoc doc = render(code); assertThat(doc.findText("Magic Wonderland")).isNotNull(); assertThat(doc.findText("Empty Treasure Chest")).isNotNull(); @@ -989,11 +1094,13 @@ @Test public void testSkipLayerBypassWithDummyNodesTD() { String code = - "graph TD\n" - + " A[Teddy Bear] -->|Blue Balloon| B[Silly Monkey]\n" - + " A -->|Red Apple| C[Happy Puppy]\n" - + " B --> C\n" - + " C --> D[Little Kitten]\n"; + """ + graph TD + A[Teddy Bear] -->|Blue Balloon| B[Silly Monkey] + A -->|Red Apple| C[Happy Puppy] + B --> C + C --> D[Little Kitten] + """; SvgDoc doc = render(code); assertThat(doc.findText("Teddy Bear")).isNotNull(); assertThat(doc.findText("Silly Monkey")).isNotNull(); @@ -1006,11 +1113,13 @@ @Test public void testSkipLayerBypassWithDummyNodesLR() { String code = - "graph LR\n" - + " A[Teddy Bear] -->|Blue Balloon| B[Silly Monkey]\n" - + " A -->|Red Apple| C[Happy Puppy]\n" - + " B --> C\n" - + " C --> D[Little Kitten]\n"; + """ + graph LR + A[Teddy Bear] -->|Blue Balloon| B[Silly Monkey] + A -->|Red Apple| C[Happy Puppy] + B --> C + C --> D[Little Kitten] + """; SvgDoc doc = render(code); assertThat(doc.findText("Teddy Bear")).isNotNull(); assertThat(doc.findText("Silly Monkey")).isNotNull(); @@ -1023,23 +1132,25 @@ @Test public void testCompoundSubgraphAndStandaloneNodesLayoutWithStyles() { String code = - "graph LR\n" - + " subgraph CastleBox [\"Play Castle\"]\n" - + " direction TB\n" - + " ToyA[Magic Wand] <--> ToyB[Cozy Teddy]\n" - + " end\n" - + " subgraph GardenBox [\"Flower Garden\"]\n" - + " ToyC[Pink Blossom] --> ToyD[Sweet Daisy]\n" - + " end\n" - + " ToyB --> ToyC\n" - + " ToyE[Happy Butterfly] --> ToyB\n" - + " style CastleBox fill:#e3f2fd,stroke:#1e88e5\n" - + " style GardenBox fill:rgb(240,250,240),stroke:#43a047\n" - + " style ToyE fill:hsl(120,50%,90%),stroke:blue\n" - + " style ToyA fill:rgba(255,255,255,0.8),stroke:purple\n" - + " style ToyC fill:#ffb300,stroke:#333333\n" - + " style \"\" fill:#fff\n" - + " style NonExistent fill:#fff\n"; + """ + graph LR + subgraph CastleBox ["Play Castle"] + direction TB + ToyA[Magic Wand] <--> ToyB[Cozy Teddy] + end + subgraph GardenBox ["Flower Garden"] + ToyC[Pink Blossom] --> ToyD[Sweet Daisy] + end + ToyB --> ToyC + ToyE[Happy Butterfly] --> ToyB + style CastleBox fill:#e3f2fd,stroke:#1e88e5 + style GardenBox fill:rgb(240,250,240),stroke:#43a047 + style ToyE fill:hsl(120,50%,90%),stroke:blue + style ToyA fill:rgba(255,255,255,0.8),stroke:purple + style ToyC fill:#ffb300,stroke:#333333 + style "" fill:#fff + style NonExistent fill:#fff + """; SvgDoc doc = render(code); assertThat(doc.findText("Play Castle")).isNotNull(); assertThat(doc.findText("Flower Garden")).isNotNull(); @@ -1053,18 +1164,20 @@ @Test public void testStyledCustomShapes() { String code = - "graph TD\n" - + " N1((Sun Ball)) --> N2{Magic Gem}\n" - + " N2 --> N3{{Toy Boat}}\n" - + " N3 --> N4[(Toy Castle)]\n" - + " N4 --> N5>Sweet Candy]\n" - + " N5 --> N6[[Puppy House]]\n" - + " style N1 fill:#ffecb3,stroke:#ffa000\n" - + " style N2 fill:#e1bee7,stroke:#8e24aa\n" - + " style N3 fill:#c8e6c9,stroke:#388e3c\n" - + " style N4 fill:#b2ebf2,stroke:#00838f\n" - + " style N5 fill:#ffcdd2,stroke:#c62828\n" - + " style N6 fill:#d1c4e9,stroke:#512da8\n"; + """ + graph TD + N1((Sun Ball)) --> N2{Magic Gem} + N2 --> N3{{Toy Boat}} + N3 --> N4[(Toy Castle)] + N4 --> N5>Sweet Candy] + N5 --> N6[[Puppy House]] + style N1 fill:#ffecb3,stroke:#ffa000 + style N2 fill:#e1bee7,stroke:#8e24aa + style N3 fill:#c8e6c9,stroke:#388e3c + style N4 fill:#b2ebf2,stroke:#00838f + style N5 fill:#ffcdd2,stroke:#c62828 + style N6 fill:#d1c4e9,stroke:#512da8 + """; SvgDoc doc = render(code); assertThat(doc.findText("Sun Ball")).isNotNull(); assertThat(doc.findText("Magic Gem")).isNotNull(); @@ -1077,10 +1190,12 @@ @Test public void testSingleUnitCompoundComponent() { String code = - "graph TD\n" - + " subgraph SoloBox [\"Secret Clubhouse\"]\n" - + " KidA[Little Star] --> KidB[Bright Moon]\n" - + " end\n"; + """ + graph TD + subgraph SoloBox ["Secret Clubhouse"] + KidA[Little Star] --> KidB[Bright Moon] + end + """; SvgDoc doc = render(code); assertThat(doc.findText("Secret Clubhouse")).isNotNull(); assertThat(doc.findText("Little Star")).isNotNull(); @@ -1090,17 +1205,19 @@ @Test public void testSequentialSubgraphsWithoutInternalDag() { String code = - "graph TD\n" - + " subgraph VertBox [\"Stacking Blocks\"]\n" - + " BoxA[Red Block]\n" - + " BoxB[Blue Block]\n" - + " BoxC[Green Block]\n" - + " end\n" - + " subgraph HorizBox [\"Toy Train\"]\n" - + " direction LR\n" - + " CarA[Train Engine]\n" - + " CarB[Train Caboose]\n" - + " end\n"; + """ + graph TD + subgraph VertBox ["Stacking Blocks"] + BoxA[Red Block] + BoxB[Blue Block] + BoxC[Green Block] + end + subgraph HorizBox ["Toy Train"] + direction LR + CarA[Train Engine] + CarB[Train Caboose] + end + """; SvgDoc doc = render(code); assertThat(doc.findText("Stacking Blocks")).isNotNull(); assertThat(doc.findText("Toy Train")).isNotNull(); @@ -1109,20 +1226,22 @@ @Test public void testSequentialSubgraphsWithLabels() { String code = - "graph TD\n" - + " subgraph VertBox [\"Stacking Blocks\"]\n" - + " BoxA[Red Block]\n" - + " BoxB[Blue Block]\n" - + " end\n" - + " subgraph HorizBox [\"Toy Train\"]\n" - + " direction LR\n" - + " CarA[Train Engine]\n" - + " CarB[Train Caboose]\n" - + " end\n" - + " BoxA -->|Stack On| BoxB\n" - + " CarA -->|Pull Car| CarB\n" - + " style BoxA fill:#112233;stroke:#445566\n" - + " style BoxB fill:#112233,stroke:#445566\n"; + """ + graph TD + subgraph VertBox ["Stacking Blocks"] + BoxA[Red Block] + BoxB[Blue Block] + end + subgraph HorizBox ["Toy Train"] + direction LR + CarA[Train Engine] + CarB[Train Caboose] + end + BoxA -->|Stack On| BoxB + CarA -->|Pull Car| CarB + style BoxA fill:#112233;stroke:#445566 + style BoxB fill:#112233,stroke:#445566 + """; SvgDoc doc = render(code); assertThat(doc.findText("Stacking Blocks")).isNotNull(); assertThat(doc.findText("Toy Train")).isNotNull(); @@ -1131,18 +1250,20 @@ @Test public void testMultiLayerCompoundComponent() { String code = - "graph LR\n" - + " subgraph Box1 [\"First Box\"]\n" - + " A[Puppy Dog]\n" - + " end\n" - + " subgraph Box2 [\"Second Box\"]\n" - + " B[Kitty Cat]\n" - + " end\n" - + " subgraph Box3 [\"Third Box\"]\n" - + " C[Bunny Rabbit]\n" - + " end\n" - + " A --> B\n" - + " B --> C\n"; + """ + graph LR + subgraph Box1 ["First Box"] + A[Puppy Dog] + end + subgraph Box2 ["Second Box"] + B[Kitty Cat] + end + subgraph Box3 ["Third Box"] + C[Bunny Rabbit] + end + A --> B + B --> C + """; SvgDoc doc = render(code); assertThat(doc.findText("First Box")).isNotNull(); assertThat(doc.findText("Second Box")).isNotNull(); @@ -1152,19 +1273,21 @@ @Test public void testTripleNestedSubgraphsWithCrossChildEdges() { String code = - "graph TD\n" - + " subgraph OuterCastle [\"Giant Castle\"]\n" - + " subgraph MidTower [\"High Tower\"]\n" - + " subgraph InnerRoom [\"Secret Room\"]\n" - + " Gem[Magic Ruby]\n" - + " end\n" - + " end\n" - + " subgraph SecondTower [\"Low Tower\"]\n" - + " OtherGem[Shiny Emerald]\n" - + " end\n" - + " Gem -->|Sparkle Magic| OtherGem\n" - + " end\n" - + " Dragon[Friendly Dragon] --> Gem\n"; + """ + graph TD + subgraph OuterCastle ["Giant Castle"] + subgraph MidTower ["High Tower"] + subgraph InnerRoom ["Secret Room"] + Gem[Magic Ruby] + end + end + subgraph SecondTower ["Low Tower"] + OtherGem[Shiny Emerald] + end + Gem -->|Sparkle Magic| OtherGem + end + Dragon[Friendly Dragon] --> Gem + """; SvgDoc doc = render(code); assertThat(doc.findText("Giant Castle")).isNotNull(); assertThat(doc.findText("High Tower")).isNotNull(); @@ -1179,13 +1302,15 @@ @Test public void testDecisionTreeBranchingWithFeedbackLoopAndChildrenWords() { String code = - "graph TD\n" - + " A[Little Bunny Play] --> B{Choose Sweet Snack}\n" - + " B -->|Crisp Red Apple| C[Happy Bunny Chew]\n" - + " B -->|Sweet Yellow Banana| D[Joyful Bunny Hop]\n" - + " B -->|Crunchy Orange Carrot| E[Cheerful Bunny Munch]\n" - + " E -->|Ask For More Treats| B\n" - + " E -->|Tired Little Nap| F[Sleepy Cozy Blanket]\n"; + """ + graph TD + A[Little Bunny Play] --> B{Choose Sweet Snack} + B -->|Crisp Red Apple| C[Happy Bunny Chew] + B -->|Sweet Yellow Banana| D[Joyful Bunny Hop] + B -->|Crunchy Orange Carrot| E[Cheerful Bunny Munch] + E -->|Ask For More Treats| B + E -->|Tired Little Nap| F[Sleepy Cozy Blanket] + """; SvgDoc doc = render(code); assertThat(doc.findText("Little Bunny Play")).isNotNull(); assertThat(doc.findText("Choose Sweet Snack")).isNotNull(); @@ -1216,7 +1341,7 @@ assertThat(doc.findText(longLabel)).isNotNull(); List<Element> rects = doc.getElementsByTag("rect"); - assertThat(rects.size()).isEqualTo(1); + assertThat(rects).hasSize(1); double rectWidth = Double.parseDouble(rects.get(0).getAttribute("width")); double expectedMin = longLabel.length() * 7.5; assertThat(rectWidth).isGreaterThan(expectedMin); @@ -1226,21 +1351,23 @@ @Test public void testNestedSubgraphWithSiblingNodesAndCrossLayerEdges() { String code = - "graph TD\n" - + " subgraph ToyBox [\"Big Toy Box\"]\n" - + " A[Magic Wand] --> B[Golden Crown]\n" - + " B --> C[Shiny Sparkles]\n" - + " D[Toy Train] --> E{Has Train Track?}\n" - + " E -->|Yes| F[Start Train Engine]\n" - + " subgraph TrainCars [\"Little Train Cars\"]\n" - + " F --> G[Red Caboose]\n" - + " G --> H[Blue Engine]\n" - + " end\n" - + " end\n" - + " subgraph Playroom [\"Sunny Playroom\"]\n" - + " Target[Happy Child Playing]\n" - + " end\n" - + " H --> Target\n"; + """ + graph TD + subgraph ToyBox ["Big Toy Box"] + A[Magic Wand] --> B[Golden Crown] + B --> C[Shiny Sparkles] + D[Toy Train] --> E{Has Train Track?} + E -->|Yes| F[Start Train Engine] + subgraph TrainCars ["Little Train Cars"] + F --> G[Red Caboose] + G --> H[Blue Engine] + end + end + subgraph Playroom ["Sunny Playroom"] + Target[Happy Child Playing] + end + H --> Target + """; SvgDoc doc = render(code); assertThat(doc.findText("Big Toy Box")).isNotNull(); @@ -1268,12 +1395,14 @@ @Test public void testCylinderShapeWithAlapCompactionAndBackEdgeClearance() { String code = - "graph TD\n" - + " A[(\"Honey Pot <br> Sweet & Yummy\")] -->|Morning Buzz| B[Busy Little Bumblebee]\n" - + " B -->|Happy Flight| C[Flower Garden Patch]\n" - + " D[Playful Garden Snail] -->|Slow Crawl| C\n" - + " C -->|Gather Nectar| A\n" - + " C -->|Pollinate Plants| E[Bright Sunflower]\n"; + """ + graph TD + A[("Honey Pot <br> Sweet & Yummy")] -->|Morning Buzz| B[Busy Little Bumblebee] + B -->|Happy Flight| C[Flower Garden Patch] + D[Playful Garden Snail] -->|Slow Crawl| C + C -->|Gather Nectar| A + C -->|Pollinate Plants| E[Bright Sunflower] + """; SvgDoc doc = render(code); assertThat(doc.findText("Honey Pot")).isNotNull(); @@ -1301,7 +1430,8 @@ for (Element p : paths) { String d = p.getAttribute("d"); if (d.contains(" C ")) { - for (String part : Splitter.onPattern("[,\\s]+").omitEmptyStrings().split(d)) { + for (String part : + Splitter.on(java.util.regex.Pattern.compile("[,\\s]+")).omitEmptyStrings().split(d)) { try { double val = Double.parseDouble(part); if (val > dRight) { @@ -1320,23 +1450,25 @@ @Test public void testSubgraphEdgeWithDirectionOverrideAndDynamicLabelSpacing() { String code = - "flowchart TD\n" - + " subgraph StoryOne [\"Teddy Bear Adventure\"]\n" - + " direction LR\n" - + " P1[Cozy Blanket] ---|Soft Fluffy Hug| P2[Sweet Dream]\n" - + " P2 ---|Gentle Night Song| P3[Morning Sun]\n" - + " end\n" - + " subgraph StoryTwo [\"Puppy Playground\"]\n" - + " direction LR\n" - + " Q1[Rubber Ball] ---|Happy Bouncy Leap| Q2[Flying Frisbee]\n" - + " Q2 ---|Wagging Tail Jump| Q3[Green Lawn]\n" - + " end\n" - + " StoryOne ==>|Wake Up Early| StoryTwo\n"; + """ + flowchart TD + subgraph StoryOne ["Teddy Bear Adventure"] + direction LR + P1[Cozy Blanket] ---|Soft Fluffy Hug| P2[Sweet Dream] + P2 ---|Gentle Night Song| P3[Morning Sun] + end + subgraph StoryTwo ["Puppy Playground"] + direction LR + Q1[Rubber Ball] ---|Happy Bouncy Leap| Q2[Flying Frisbee] + Q2 ---|Wagging Tail Jump| Q3[Green Lawn] + end + StoryOne ==>|Wake Up Early| StoryTwo + """; SvgDoc doc = render(code); // 1. Subgraph container layout and node containment checks List<SvgDoc.Rect2D> sgs = doc.getSubgraphBoundingBoxes(); - assertThat(sgs.size()).isEqualTo(2); + assertThat(sgs).hasSize(2); SvgDoc.Rect2D sg1 = sgs.get(0); SvgDoc.Rect2D sg2 = sgs.get(1); @@ -1344,7 +1476,7 @@ assertThat(sg1.bottom()).isLessThan(sg2.y); List<SvgDoc.Rect2D> nodes = doc.getNodeBoundingBoxes(); - assertThat(nodes.size()).isEqualTo(6); + assertThat(nodes).hasSize(6); for (int i = 0; i < 3; i++) { assertThat(sg1.contains(nodes.get(i), 10.0)).isTrue(); } @@ -1354,7 +1486,7 @@ // 2. Subgraph connecting edge geometry List<Element> lines = doc.getElementsByTag("line"); - assertThat(lines.size()).isEqualTo(1); + assertThat(lines).hasSize(1); Element seLine = lines.get(0); double lx1 = Double.parseDouble(seLine.getAttribute("x1")); double ly1 = Double.parseDouble(seLine.getAttribute("y1")); @@ -1367,7 +1499,7 @@ // 3. Intra-subgraph horizontal edge paths List<Element> paths = doc.getEdgePaths(); - assertThat(paths.size()).isEqualTo(4); + assertThat(paths).hasSize(4); for (Element p : paths) { String d = p.getAttribute("d"); assertThat(d).startsWith("M "); @@ -1377,17 +1509,19 @@ @Test public void testIsolatedSubgraphInHorizontalGraph() { String code = - "graph LR\n" - + " subgraph Garden [\"Flower Garden\"]\n" - + " A[Bright Tulip]\n" - + " B[Daisy Flower]\n" - + " end\n"; + """ + graph LR + subgraph Garden ["Flower Garden"] + A[Bright Tulip] + B[Daisy Flower] + end + """; SvgDoc doc = render(code); List<SvgDoc.Rect2D> sgs = doc.getSubgraphBoundingBoxes(); - assertThat(sgs.size()).isEqualTo(1); + assertThat(sgs).hasSize(1); List<SvgDoc.Rect2D> nodes = doc.getNodeBoundingBoxes(); - assertThat(nodes.size()).isEqualTo(2); + assertThat(nodes).hasSize(2); assertThat(sgs.get(0).contains(nodes.get(0), 10.0)).isTrue(); assertThat(sgs.get(0).contains(nodes.get(1), 10.0)).isTrue(); } @@ -1395,20 +1529,22 @@ @Test public void testNestedSubgraphsWithInternalSubgraphEdgeAndInheritedDirection() { String code = - "graph TD\n" - + " subgraph MainBox [\"Toy Warehouse\"]\n" - + " direction LR\n" - + " subgraph BoxOne [\"Teddy Room\"]\n" - + " A[Brown Bear]\n" - + " end\n" - + " subgraph BoxTwo [\"Puppy Room\"]\n" - + " B[Happy Dog]\n" - + " end\n" - + " BoxOne --> BoxTwo\n" - + " end\n"; + """ + graph TD + subgraph MainBox ["Toy Warehouse"] + direction LR + subgraph BoxOne ["Teddy Room"] + A[Brown Bear] + end + subgraph BoxTwo ["Puppy Room"] + B[Happy Dog] + end + BoxOne --> BoxTwo + end + """; SvgDoc doc = render(code); List<SvgDoc.Rect2D> sgs = doc.getSubgraphBoundingBoxes(); - assertThat(sgs.size()).isEqualTo(3); + assertThat(sgs).hasSize(3); } }
diff --git a/javatests/com/google/gitiles/doc/SvgDoc.java b/javatests/com/google/gitiles/doc/SvgDoc.java index 0463ee7..0384d55 100644 --- a/javatests/com/google/gitiles/doc/SvgDoc.java +++ b/javatests/com/google/gitiles/doc/SvgDoc.java
@@ -18,11 +18,12 @@ import static com.google.common.truth.Truth.assertWithMessage; import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; import java.io.StringReader; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import java.util.Optional; +import java.util.regex.Pattern; import javax.annotation.Nullable; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; @@ -32,13 +33,11 @@ import org.w3c.dom.NodeList; import org.xml.sax.InputSource; -/** - * Shared DOM and geometric assertion helper for Mermaid SVG test suites. - */ +/** Shared DOM and geometric assertion helper for Mermaid SVG test suites. */ public class SvgDoc { - public static final List<String> DANGEROUS_TAGS = - Arrays.asList( + public static final ImmutableList<String> DANGEROUS_TAGS = + ImmutableList.of( "script", "iframe", "foreignObject", @@ -84,18 +83,19 @@ */ public static SvgDoc render(String mermaidCode) { Optional<String> svgOpt = SimpleMermaidRenderer.renderToSvg(mermaidCode); - assertThat(svgOpt.isPresent()).isTrue(); + assertThat(svgOpt).isPresent(); SvgDoc doc = new SvgDoc(svgOpt.get()); doc.assertAllInvariants(); return doc; } /** - * Renders the given Mermaid diagram code without asserting collision invariants (e.g. for malformed tests). + * Renders the given Mermaid diagram code without asserting collision invariants (e.g. for + * malformed tests). */ public static SvgDoc renderRaw(String mermaidCode) { Optional<String> svgOpt = SimpleMermaidRenderer.renderToSvg(mermaidCode); - assertThat(svgOpt.isPresent()).isTrue(); + assertThat(svgOpt).isPresent(); return new SvgDoc(svgOpt.get()); } @@ -120,7 +120,7 @@ public void assertDefs() { List<Element> defs = getElementsByTag("defs"); - assertThat(defs.size()).isEqualTo(1); + assertThat(defs).hasSize(1); Element def = defs.get(0); NodeList markers = def.getElementsByTagName("marker"); assertThat(markers.getLength()).isEqualTo(1); @@ -146,8 +146,8 @@ NodeList nl = doc.getElementsByTagName(tagName); for (int i = 0; i < nl.getLength(); i++) { Node n = nl.item(i); - if (n instanceof Element) { - list.add((Element) n); + if (n instanceof Element element) { + list.add(element); } } return list; @@ -156,7 +156,7 @@ public List<Element> getEdgePaths() { List<Element> list = new ArrayList<>(); for (Element p : getElementsByTag("path")) { - if (!"M 0 1.5 L 10 5 L 0 8.5 z".equals(p.getAttribute("d"))) { + if (!p.getAttribute("d").equals("M 0 1.5 L 10 5 L 0 8.5 z")) { list.add(p); } } @@ -166,14 +166,15 @@ public List<Element> findSubgraphRects() { List<Element> list = new ArrayList<>(); for (Element r : getElementsByTag("rect")) { - if ("4,4".equals(r.getAttribute("stroke-dasharray"))) { + if (r.getAttribute("stroke-dasharray").equals("4,4")) { list.add(r); } } return list; } - public @Nullable Element findPolygonWithVertices(int count) { + @Nullable + public Element findPolygonWithVertices(int count) { for (Element p : getElementsByTag("polygon")) { String pts = p.getAttribute("points").trim(); if (!pts.isEmpty() && pts.split("\\s+").length == count) { @@ -183,11 +184,14 @@ return null; } - public @Nullable Element findText(String text) { + @Nullable + public Element findText(String text) { String expected = text.replace("\0", "").trim(); for (Element t : getElementsByTag("text")) { String full = t.getTextContent().trim().replaceAll("\\s+", " "); - if (expected.equals(full) || expected.equals(t.getTextContent().trim()) || full.contains(expected)) { + if (expected.equals(full) + || expected.equals(t.getTextContent().trim()) + || full.contains(expected)) { return t; } } @@ -267,7 +271,7 @@ for (Element r : getElementsByTag("rect")) { String dash = r.getAttribute("stroke-dasharray"); String opacity = r.getAttribute("fill-opacity"); - if ("4,4".equals(dash) || "0.95".equals(opacity)) { + if (dash.equals("4,4") || opacity.equals("0.95")) { continue; } double x = Double.parseDouble(r.getAttribute("x")); @@ -285,9 +289,11 @@ for (Element p : getElementsByTag("polygon")) { String pts = p.getAttribute("points").trim(); if (!pts.isEmpty()) { - double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE; - double maxX = Double.MIN_VALUE, maxY = Double.MIN_VALUE; - for (String pair : Splitter.onPattern("\\s+").omitEmptyStrings().split(pts)) { + double minX = Double.MAX_VALUE; + double minY = Double.MAX_VALUE; + double maxX = Double.MIN_VALUE; + double maxY = Double.MIN_VALUE; + for (String pair : Splitter.on(Pattern.compile("\\s+")).omitEmptyStrings().split(pts)) { List<String> xy = Splitter.on(',').splitToList(pair); if (xy.size() == 2) { double px = Double.parseDouble(xy.get(0)); @@ -309,7 +315,7 @@ public List<Rect2D> getEdgeLabelBadgeBoundingBoxes() { List<Rect2D> list = new ArrayList<>(); for (Element r : getElementsByTag("rect")) { - if ("0.95".equals(r.getAttribute("fill-opacity"))) { + if (r.getAttribute("fill-opacity").equals("0.95")) { double x = Double.parseDouble(r.getAttribute("x")); double y = Double.parseDouble(r.getAttribute("y")); double w = Double.parseDouble(r.getAttribute("width")); @@ -323,7 +329,7 @@ public List<Rect2D> getSubgraphBoundingBoxes() { List<Rect2D> list = new ArrayList<>(); for (Element r : getElementsByTag("rect")) { - if ("4,4".equals(r.getAttribute("stroke-dasharray"))) { + if (r.getAttribute("stroke-dasharray").equals("4,4")) { double x = Double.parseDouble(r.getAttribute("x")); double y = Double.parseDouble(r.getAttribute("y")); double w = Double.parseDouble(r.getAttribute("width")); @@ -340,7 +346,7 @@ for (int j = i + 1; j < nodes.size(); j++) { Rect2D a = nodes.get(i); Rect2D b = nodes.get(j); - assertWithMessage("Node overlap detected between " + a + " and " + b) + assertWithMessage("Node overlap detected between %s and %s", a, b) .that(a.overlaps(b, 2.0)) .isFalse(); } @@ -352,7 +358,7 @@ List<Rect2D> labels = getEdgeLabelBadgeBoundingBoxes(); for (Rect2D badge : labels) { for (Rect2D node : nodes) { - assertWithMessage("Edge label badge " + badge + " overlaps node " + node) + assertWithMessage("Edge label badge %s overlaps node %s", badge, node) .that(badge.overlaps(node, 2.0)) .isFalse(); } @@ -367,7 +373,7 @@ Rect2D b = sgs.get(j); boolean oneContainsOther = a.contains(b, 0) || b.contains(a, 0); if (!oneContainsOther) { - assertWithMessage("Sibling subgraphs overlap: " + a + " and " + b) + assertWithMessage("Sibling subgraphs overlap: %s and %s", a, b) .that(a.overlaps(b, 2.0)) .isFalse(); }