Skip to content

Commit

Permalink
resolves yuzutech#842 creates SVG and PNG error images
Browse files Browse the repository at this point in the history
Use svgSalamander to create SVG and PNG images.
Batik adds ~7mb of dependencies.
  • Loading branch information
ggrossetie committed Aug 9, 2021
1 parent 21dbfca commit 80f7cc1
Show file tree
Hide file tree
Showing 6 changed files with 225 additions and 27 deletions.
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>io.kroki</groupId>
<artifactId>kroki</artifactId>
<name>kroki</name>
<packaging>pom</packaging>
<version>0.14.0</version>
<properties>
Expand All @@ -29,5 +30,4 @@
</plugin>
</plugins>
</build>
<name>kroki</name>
</project>
18 changes: 6 additions & 12 deletions server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@
<artifactId>plantuml</artifactId>
<version>${plantuml.version}</version>
</dependency>
<dependency>
<groupId>guru.nidi.com.kitfox</groupId>
<artifactId>svgSalamander</artifactId>
<version>1.1.3</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
Expand Down Expand Up @@ -108,6 +113,7 @@
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito-core.version}</version>
<scope>test</scope>
</dependency>
<!--
Due to a bug in JDK 8 we need to add vertx-codegen as a test dependency otherwise the following exception is thrown:
Expand All @@ -133,18 +139,6 @@
<version>${vertx.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>batik-transcoder</artifactId>
<version>1.14</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.twelvemonkeys.imageio</groupId>
<artifactId>imageio-batik</artifactId>
<version>3.7.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<resources>
Expand Down
63 changes: 50 additions & 13 deletions server/src/main/java/io/kroki/server/error/ErrorHandler.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
package io.kroki.server.error;

import com.kitfox.svg.SVGException;
import io.kroki.server.log.Logging;
import io.vertx.core.Vertx;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.http.HttpHeaders;
import io.vertx.core.http.HttpServerResponse;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.MIMEHeader;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.impl.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;

public class ErrorHandler implements io.vertx.ext.web.handler.ErrorHandler {
Expand Down Expand Up @@ -143,28 +149,59 @@ private boolean sendError(RoutingContext context, String mime, int errorCode, St
}
jsonError.put("stack", stack);
}

response.putHeader(HttpHeaders.CONTENT_TYPE, "application/json");
response.end(jsonError.encode());
return true;
}

if (mime.startsWith("text/plain")) {
String completeErrorMessage = getCompleteErrorMessage(context, errorCode, errorMessage);
response.putHeader(HttpHeaders.CONTENT_TYPE, "text/plain");
StringBuilder sb = new StringBuilder();
sb.append("Error ");
sb.append(errorCode);
sb.append(": ");
sb.append(errorMessage);
if (context.failure() != null && displayExceptionDetails) {
for (StackTraceElement elem : context.failure().getStackTrace()) {
sb.append("\tat ").append(elem).append("\n");
}
}
response.end(sb.toString());
response.end(completeErrorMessage);
return true;
}

if (mime.startsWith("image/svg+xml")) {
String completeErrorMessage = getCompleteErrorMessage(context, errorCode, errorMessage);
try {
String svgImage = ErrorImage.buildSVGImage(completeErrorMessage).getSource();
response.putHeader(HttpHeaders.CONTENT_TYPE, "image/svg+xml");
response.end(svgImage);
return true;
} catch (IOException | SVGException e) {
logger.warn("Unable to generate error image", e);
return false;
}
}

if (mime.startsWith("image/png") || mime.startsWith("image/*")) {
String completeErrorMessage = getCompleteErrorMessage(context, errorCode, errorMessage);
try ( ByteArrayOutputStream output = new ByteArrayOutputStream()) {
BufferedImage bufferedImage = ErrorImage.buildPNGImage(completeErrorMessage);
ImageIO.write(bufferedImage, "png", output);
response.putHeader(HttpHeaders.CONTENT_TYPE, "image/png");
response.end(Buffer.buffer(output.toByteArray()));
return true;
} catch (IOException | SVGException e) {
logger.warn("Unable to generate error image", e);
return false;
}
}

return false;
}

private String getCompleteErrorMessage(RoutingContext context, int errorCode, String errorMessage) {
StringBuilder sb = new StringBuilder();
sb.append("Error ");
sb.append(errorCode);
sb.append(": ");
sb.append(errorMessage);
if (context.failure() != null && displayExceptionDetails) {
for (StackTraceElement elem : context.failure().getStackTrace()) {
sb.append("\tat ").append(elem).append("\n");
}
}
return sb.toString();
}
}
99 changes: 99 additions & 0 deletions server/src/main/java/io/kroki/server/error/ErrorImage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package io.kroki.server.error;

import com.kitfox.svg.SVGDiagram;
import com.kitfox.svg.SVGElement;
import com.kitfox.svg.SVGException;
import com.kitfox.svg.SVGUniverse;
import com.kitfox.svg.app.beans.SVGIcon;

import java.awt.Dimension;
import java.awt.Graphics2D;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;

public class ErrorImage {

public static SVGWithDimension buildSVGImage(String errorMessage) throws IOException, SVGException {
String[] lines = errorMessage.split("\\n");
StringBuilder text = new StringBuilder();
for (String line : lines) {
// QUESTION: should we use ellipsis or force break if line length is too long?
text.append("<tspan x=\"10\" dy=\"14\">").append(line).append("</tspan>\n");
}
String svg = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n" +
"<svg xmlns=\"http://www.w3.org/2000/svg\" version=\"1.1\">\n" +
"<rect fill=\"#fff5f7\" width=\"100%\" height=\"100%\"/>\n" +
"<text id=\"text\" xml:space=\"preserve\" font-weight=\"bold\" fill=\"#cd0930\" font-family=\"Roboto\" font-size=\"16px\" x=\"0\" y=\"5\" dy=\"0\">\n" +
text +
"</text>\n" +
"<rect fill=\"#ff3860\" width=\"3\" height=\"100%\"/>\n" +
"</svg>";
Dimension dimension = computeDimension(svg);
String result = svg
.replaceAll("<svg xmlns=.*>\\n", "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"" + dimension.width + "\" height=\"" + dimension.height + "\" viewBox=\"0 0 " + dimension.width + " " + dimension.height + "\" version=\"1.1\">\n")
.replaceAll("width=\"100%\"", "width=\"" + dimension.width + "\"")
.replaceAll("height=\"100%\"", "height=\"" + dimension.height + "\"");
return new SVGWithDimension(result, dimension);
}

public static BufferedImage buildPNGImage(String errorMessage) throws IOException, SVGException {
SVGWithDimension svgWithDimension = buildSVGImage(errorMessage);
try (ByteArrayInputStream svgInputStream = new ByteArrayInputStream(svgWithDimension.source.getBytes(StandardCharsets.UTF_8))) {
SVGUniverse svgUniverse = new SVGUniverse();
URI svgUri = svgUniverse.loadSVG(svgInputStream, "error-message");
SVGDiagram diagram = svgUniverse.getDiagram(svgUri);
SVGIcon svgIcon = new SVGIcon();
svgIcon.setSvgUniverse(svgUniverse);
svgIcon.setSvgURI(svgUri);
svgIcon.setAntiAlias(true);
// svgIcon.setInterpolation(SVGIcon.INTERP_NEAREST_NEIGHBOR);
// svgIcon.setInterpolation(SVGIcon.INTERP_BILINEAR);
svgIcon.setInterpolation(SVGIcon.INTERP_BICUBIC);
int width = (int) diagram.getWidth();
int height = (int) diagram.getHeight();
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = image.createGraphics();
g.setClip(0, 0, width, height);
svgIcon.paintIcon(null, g, 0, 0);
g.dispose();
return image;
// ImageIO.write(image, "png", resultOutputStream);
// SVGCache.getSVGUniverse().clear();
// resultOutputStream.flush();
// return image;
}
}

private static Dimension computeDimension(String svg) throws IOException, SVGException {
try (ByteArrayInputStream svgInputStream = new ByteArrayInputStream(svg.getBytes(StandardCharsets.UTF_8))) {
SVGUniverse svgUniverse = new SVGUniverse();
URI svgUri = svgUniverse.loadSVG(svgInputStream, "error-message");
SVGDiagram diagram = svgUniverse.getDiagram(svgUri);
SVGElement textElement = diagram.getElement("text");
Rectangle2D bbox = textElement.getRoot().getBoundingBox();
return new Dimension((int) Math.floor(bbox.getWidth() + bbox.getX()) + 10, (int) Math.floor(bbox.getHeight() + bbox.getY()) + 5);
}
}

public static class SVGWithDimension {
private final String source;
private final Dimension dimension;

public SVGWithDimension(String source, Dimension dimension) {
this.source = source;
this.dimension = dimension;
}

public String getSource() {
return source;
}

public Dimension getDimension() {
return dimension;
}
}
}
24 changes: 24 additions & 0 deletions server/src/test/java/io/kroki/server/ErrorImageTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.kroki.server;

import com.kitfox.svg.SVGException;
import io.kroki.server.error.ErrorImage;
import org.junit.jupiter.api.Test;

import java.awt.Dimension;
import java.io.IOException;

import static org.assertj.core.api.Assertions.assertThat;

public class ErrorImageTest {

@Test
public void should_generate_svg_error_image() throws IOException, SVGException {
ErrorImage.SVGWithDimension svgWithDimension = ErrorImage.buildSVGImage("There is no layout engine support for \"nfds\"\nUse one of: circo dot fdp neato nop nop1 nop2 osage patchwork sfdp twopi");
Dimension dimension = svgWithDimension.getDimension();
assertThat(dimension.width).isGreaterThan(500);
assertThat(dimension.height).isGreaterThan(40);
assertThat(svgWithDimension.getSource()).contains("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n");
assertThat(svgWithDimension.getSource()).contains("<tspan x=\"10\" dy=\"14\">There is no layout engine support for \"nfds\"</tspan>\n");
assertThat(svgWithDimension.getSource()).contains("<tspan x=\"10\" dy=\"14\">Use one of: circo dot fdp neato nop nop1 nop2 osage patchwork sfdp twopi</tspan>\n");
}
}
46 changes: 45 additions & 1 deletion server/src/test/java/io/kroki/server/error/ErrorHandlerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@
import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.http.HttpServerResponse;
import io.vertx.core.http.impl.headers.HeadersMultiMap;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.impl.ParsableHeaderValuesContainer;
import io.vertx.ext.web.impl.ParsableMIMEValue;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatcher;
import org.mockito.Mockito;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

Expand Down Expand Up @@ -105,9 +112,46 @@ void should_return_service_unavailable_error_json() {
Mockito.verify(httpServerResponse).end("{\"error\":{\"code\":503,\"message\":\"Mermaid service is unavailable!\"}}");
}

private HttpServerResponse jsonServerResponse() {
@Test
void should_return_svg_error_image() {
Vertx vertx = Vertx.vertx();
RoutingContext routingContext = mock(RoutingContext.class);
HttpServerResponse httpServerResponse = plainResponse();
HttpServerRequest httpServerRequest = mock(HttpServerRequest.class);

when(httpServerResponse.getStatusMessage()).thenReturn(null);
when(routingContext.parsedHeaders()).thenReturn(new ParsableHeaderValuesContainer(
Collections.singletonList(new ParsableMIMEValue("image/svg+xml")),
new ArrayList<>(),
new ArrayList<>(),
new ArrayList<>(),
new ParsableMIMEValue("*/*")
));
when(routingContext.response()).thenReturn(httpServerResponse);
when(routingContext.request()).thenReturn(httpServerRequest);
when(routingContext.failure()).thenReturn(new BadRequestException("Syntax Error? (line: 1)"));
when(httpServerRequest.method()).thenReturn(HttpMethod.GET);
ErrorHandler errorHandler = new ErrorHandler(vertx, false);

errorHandler.handle(routingContext);

Mockito.verify(httpServerResponse).setStatusMessage("Bad Request");
Mockito.verify(httpServerResponse).setStatusCode(400);
Mockito.verify(httpServerResponse).end(argThat((ArgumentMatcher<String>) argument ->
argument.contains("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n") &&
argument.contains("<tspan x=\"10\" dy=\"14\">Error 400: Syntax Error? (line: 1)</tspan>\n")
));
}

private HttpServerResponse plainResponse() {
HttpServerResponse httpServerResponse = mock(HttpServerResponse.class);
MultiMap headers = new HeadersMultiMap();
when(httpServerResponse.headers()).thenReturn(headers);
return httpServerResponse;
}

private HttpServerResponse jsonServerResponse() {
HttpServerResponse httpServerResponse = mock(HttpServerResponse.class);
MultiMap headers = new HeadersMultiMap();
headers.add(HttpHeaders.CONTENT_TYPE, "application/json");
when(httpServerResponse.headers()).thenReturn(headers);
Expand Down

0 comments on commit 80f7cc1

Please sign in to comment.