Skip to content

Commit

Permalink
[asciidoctor-java] enable to embed images
Browse files Browse the repository at this point in the history
  • Loading branch information
rmannibucau committed Dec 12, 2023
1 parent 17eca02 commit 21786cf
Show file tree
Hide file tree
Showing 5 changed files with 224 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,27 @@
import io.yupiik.asciidoc.model.Document;
import io.yupiik.asciidoc.parser.Parser;
import io.yupiik.asciidoc.parser.resolver.ContentResolver;
import io.yupiik.asciidoc.renderer.Visitor;
import io.yupiik.asciidoc.renderer.html.SimpleHtmlRenderer;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

public final class Main {
private Main() {
// no-op
}

public static void main(final String... args) throws IOException { // todo: complete impl, this is just an undocumented boostrap main for testing purposes
if (args.length== 0) {
if (args.length == 0) {
System.err.println(error());
return;
}

final var attributes = new HashMap<String, String>();
Function<Map<String, String>, Visitor<String>> renderer = SimpleHtmlRenderer::new;
ContentResolver resolver = null;
SimpleHtmlRenderer.Configuration configuration = new SimpleHtmlRenderer.Configuration();
Path input = null;
Path output = null;

Expand All @@ -54,15 +51,19 @@ public static void main(final String... args) throws IOException { // todo: comp
} else if ("-o".equals(args[i]) || "--output".equals(args[i])) {
output = !"-".equals(args[i + 1]) ? Path.of(args[i + 1]) : null /* means stdout */;
} else if ("-b".equals(args[i]) || "--base".equals(args[i])) {
resolver = ContentResolver.of(Path.of(args[i + 1]));
final var base = Path.of(args[i + 1]);
configuration.setAssetsBase(base);
resolver = ContentResolver.of(base);
}
}

if (input == null) {
throw new IllegalArgumentException("No --input argument, ensure to set --input <path>\n" + error());
}
if (resolver == null) {
resolver = ContentResolver.of(input.toAbsolutePath().getParent());
final var parent = input.toAbsolutePath().getParent().normalize();
resolver = ContentResolver.of(parent);
configuration.setAssetsBase(parent);
}

final var parser = new Parser();
Expand All @@ -71,7 +72,7 @@ public static void main(final String... args) throws IOException { // todo: comp
document = parser.parse(reader, new Parser.ParserContext(resolver));
}

final var html = renderer.apply(attributes);
final var html = new SimpleHtmlRenderer(configuration.setAttributes(attributes));
html.visit(document);
if (output != null) {
Files.writeString(output, html.result());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,22 @@
import io.yupiik.asciidoc.model.Text;
import io.yupiik.asciidoc.model.UnOrderedList;
import io.yupiik.asciidoc.renderer.Visitor;
import io.yupiik.asciidoc.renderer.uri.DataResolver;

import java.nio.file.Path;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.IntStream;
import java.util.stream.Stream;

import static java.util.Locale.ROOT;
import static java.util.Optional.ofNullable;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toMap;

/**
* Important: as of today it is a highly incomplete implementation but it gives a starting point.
Expand All @@ -56,19 +61,27 @@
*/
public class SimpleHtmlRenderer implements Visitor<String> {
private final StringBuilder builder = new StringBuilder();
private final Map<String, String> attributes;
private final Configuration configuration;
private final boolean dataUri;
private final DataResolver resolver;

public SimpleHtmlRenderer() {
this(Map.of());
this(new Configuration().setAttributes(Map.of()));
}

public SimpleHtmlRenderer(final Map<String, String> attributes) {
this.attributes = attributes;
public SimpleHtmlRenderer(final Configuration configuration) {
this.configuration = configuration;

final var dataUriValue = configuration.getAttributes().getOrDefault("data-uri", "false");
this.dataUri = Boolean.parseBoolean(dataUriValue) || dataUriValue.isBlank();
this.resolver = dataUri ?
(configuration.getResolver() == null ? new DataResolver(configuration.getAssetsBase()) : configuration.getResolver()) :
null;
}

@Override
public void visit(final Document document) {
final boolean contentOnly = Boolean.parseBoolean(attributes.getOrDefault("noheader", "false"));
final boolean contentOnly = Boolean.parseBoolean(configuration.getAttributes().getOrDefault("noheader", "false"));
if (!contentOnly) {
builder.append("<!DOCTYPE html>\n");
builder.append("<html");
Expand Down Expand Up @@ -190,7 +203,7 @@ public void visitSection(final Section element) {
builder.append(" <h").append(element.level());
writeCommonAttributes(element.options(), null);
builder.append(">");
final var titleRenderer = new SimpleHtmlRenderer(attributes);
final var titleRenderer = new SimpleHtmlRenderer(configuration);
titleRenderer.visitElement(element.title() instanceof Text t && t.options().isEmpty() && t.style().isEmpty() ?
new Text(t.style(), t.value(), Map.of("nowrap", "")) :
element);
Expand Down Expand Up @@ -442,11 +455,14 @@ public void visitOpenBlock(final OpenBlock element) {

@Override
public ConditionalBlock.Context context() {
return attributes::get;
return configuration.getAttributes()::get;
}

@Override
public String result() {
if (resolver != null) {
resolver.close();
}
return builder.toString();
}

Expand Down Expand Up @@ -485,8 +501,19 @@ protected void visitXref(final Macro element) {

// todo: enhance
protected void visitImage(final Macro element) {
if (dataUri && !element.label().startsWith("data:")) {
visitImage(new Macro(
element.name(), resolver.apply(element.label()).base64(),
Stream.of(element.options(), Map.of("", element.label()))
.filter(Objects::nonNull)
.map(Map::entrySet)
.flatMap(Collection::stream)
.collect(toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a)),
element.inline()));
return;
}
builder.append(" <img src=\"").append(element.label())
.append("\" alt=\"").append(element.options().getOrDefault("alt", element.label()))
.append("\" alt=\"").append(element.options().getOrDefault("", element.label()))
.append("\">\n");
}

Expand Down Expand Up @@ -576,7 +603,7 @@ protected String escape(final String name) {
}

protected String attr(final String key, final String defaultKey, final String defaultValue, final Map<String, String> mainMap) {
return mainMap.getOrDefault(key, attributes.getOrDefault(defaultKey, defaultValue));
return mainMap.getOrDefault(key, configuration.getAttributes().getOrDefault(defaultKey, defaultValue));
}

protected String attr(final String key, final Map<String, String> defaultMap) {
Expand All @@ -598,4 +625,37 @@ private int extractNumbers(final String col) {
return 1;
}
}

public static class Configuration {
private DataResolver resolver;
private Path assetsBase;
private Map<String, String> attributes = Map.of();

public DataResolver getResolver() {
return resolver;
}

public Configuration setResolver(final DataResolver resolver) {
this.resolver = resolver;
return this;
}

public Path getAssetsBase() {
return assetsBase;
}

public Configuration setAssetsBase(final Path assetsBase) {
this.assetsBase = assetsBase;
return this;
}

public Map<String, String> getAttributes() {
return attributes;
}

public Configuration setAttributes(final Map<String, String> attributes) {
this.attributes = attributes;
return this;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package io.yupiik.asciidoc.renderer.uri;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;

import static java.net.http.HttpClient.newHttpClient;
import static java.net.http.HttpResponse.BodyHandlers.ofByteArray;
import static java.util.Locale.ROOT;

public class DataResolver implements Function<String, DataUri>, AutoCloseable {
private final Path base;
private final Map<String, DataUri> cache = new ConcurrentHashMap<>();
private HttpClient httpClient;

public DataResolver(final Path base) {
this.base = base;
}

public Map<String, DataUri> cache() { // enable to reuse it
return cache;
}

public DataResolver cache(final Map<String, DataUri> cache) {
this.cache.putAll(cache);
return this;
}

@Override
public DataUri apply(final String path) {
return cache.computeIfAbsent(path, k -> {
if (k.startsWith("http://") || k.startsWith("https://")) {
return resolveHttp(k);
}
return resolveLocal(path);
});
}

private DataUri resolveLocal(final String path) { // todo: log
var local = Path.of(path);
if (!local.isAbsolute()) {
local = base.resolve(local);
}
final var ref = local;
return new DataUri(() -> {
try {
return Files.newInputStream(ref);
} catch (final IOException e) {
throw new IllegalStateException(e);
}
}, findMimeType(path));
}

private DataUri resolveHttp(final String url) {
if (httpClient == null) {
httpClient = newHttpClient();
}
try {
final var res = httpClient.send(
HttpRequest.newBuilder()
.build(),
ofByteArray());
if (res.statusCode() >= 400) {
throw new IllegalArgumentException("Invalid url: '" + url + "': " + res);
}
return new DataUri(() -> new ByteArrayInputStream(res.body()), findMimeType(url));
} catch (final IOException e) {
throw new IllegalStateException(e);
} catch (final InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}

private String findMimeType(final String url) {
final var ext = url.substring(url.lastIndexOf('.') + 1);
if (ext.length() < url.length()) {
return "image/" + ext.toLowerCase(ROOT);
}
return "application/octet-stream";
}

@Override
public void close() {
if (httpClient instanceof AutoCloseable c) {
try {
c.close();
} catch (final RuntimeException e) {
throw e;
} catch (final Exception e) {
throw new IllegalStateException(e);
}
}
httpClient = null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.yupiik.asciidoc.renderer.uri;

import java.io.IOException;
import java.io.InputStream;
import java.util.Base64;
import java.util.function.Supplier;

public record DataUri(Supplier<InputStream> content, String mimeType) {
public String base64() {
try (final var in = content().get()) {
return "data:" + mimeType() + ";base64," + Base64.getEncoder().encodeToString(in.readAllBytes());
} catch (final IOException e) {
throw new IllegalStateException(e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@
import io.yupiik.asciidoc.parser.Parser;
import io.yupiik.asciidoc.parser.resolver.ContentResolver;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.assertEquals;
Expand Down Expand Up @@ -321,6 +325,29 @@ void xref() {
assertRenderingContent("xref:foo.adoc[Bar]", " <a href=\"foo.html\">Bar</a>\n");
}


@Test
void embeddedImage(@TempDir final Path work) throws IOException {
final var base64 = "iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAAAXNSR0IArs4c6QAAAIRlWElmTU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAACWAAAAAQAAAJYAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAACSgAwAEAAAAAQAAACQAAAAAWFiVFgAAAAlwSFlzAAAXEgAAFxIBZ5/SUgAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAACC9JREFUWAm1WHuMXFUZ/92Zfczszmu7rlowgCZC20TXpA1afND6QqFbEGlNFF8pUohRREGqS0wLcWspJkI0kbd/NIKLJs7e2ZZX3YKPaG01qCGoaazBUOxj595578zcOf6+c++5Ozu7Lbs1Pcmde+53vu873/t8Z4D/Z2xXkXnkagHYPKRzATDCPFcYhO38nM9+2CdX6K3GVfRcbHlmnlOqSyPY+Sx+qxQO8LGdl2aJlDU7X/xsvskXQyvCrLeayLlXIJbYiLyrUOSTSK+E7X5VszgEX+DF8GvDOTuBRBgZSu1CjPu28AfOJ6G5qe/gKXcZ1lgNGLe2bfh606ULdEh1a6Y59yvoTw2jJrKpbVD1G1CglZLpQdTV3RpnOc5xLBmNxQI59ziel7hxs6HWOWd3EEst2PlhDV9igC/NQkbjRusuJFJDKBYagHVHKFB/egeKzjGk0gxo654Qfk4mRtN9pXcym1qBJe7Ve0mQP3DId6XtfAn7PYVn6grZ/LXh+iKFWpqFhGmzuVtboFh4Fa3GDr1PEn6Ki0tHMg+hUjyIXsoXiYwx2CM6I9XZlYGF9ZitOZ/QmosFxBIyxlVPSLR9yk/1vcXLsa+sMKVr0za9bpIhRD7bidFMtLedl/1Adg5qdibIs/mNDO7PaJg5Omzn8QDXxVMnls/BP4Msr++yw0GBW+1+k4XvElQqzPLo7f4GlmTT1zGQyWIgtQc558ewCJMRYSkouBW6N4V615iGXe5XKj0/qx9jgV9Qw5zjBBo/EfLKls+j1WqYLCpMOB6emaErT60N1yfyO7TbJgtcP/UeDTfJESLNnZzZQssP+4Wtp/u7tE5aa9yk5mZEmjuRTvei5Z1kWB9DN8PJ6tptltF1coxHylEkkgL3y8BLUOH6kiZTQYBOuu/WFpAAzbl+Vgkju7BWw18gfCL/NUy6H8OvOH+uKXjXh3vZ7qc1rB3+QFDtQ6TFTMLgzL+gXTXh/At7VW9ImnN/E7jwCPb+04dPOM8HsKN0VSzEncjPwg0Pkywhkj9Z2GVS5CQ4JXPiyfej4dHk1iiutGY0mZ3/HGL970Wd55iGv92Hw7oNxSLo3gtRcr4d7mVFbg/hnnOnhptkCZFONzGSiyY5WkXOqxw1N2P8lTgD+d+BJaY0WILfuMHOPxqslTFZvdCQkeaRAF5hArxVw40XQiQmZ9vcnz4YpLlXGKWmF6FEjZXlp7lgxJJ38kS/gOeYUPvwVTqkaUaOenMUBafAdO9jB/A9DZOfSH2UAe4SHueXDz9wYP7+IYFMxsf9rBINbKfia5T/SYiTc97GgK0G59hDGm7OMPkw1XjS2aYDPMd2xHY+FNLbzrd0GdhXkTLxQQ03yRMgdUi4KQCrHbRCnGnuIKZuCRkq7EIyFaOmDnqioxp+bLVvGflYDWmOgD+m70HZPYLzU/xos+5IZidK7suI00gRdZfGXbeO9LPn3Nw2c1NQIyxLnMDBZr0WeRy56Z1ANIVo13UBxi5ckTyu42YrO0MzLEvxbItis+WxLNyK/xRXYCTt1yV7+n3cdytR34imlmGlzk6LiSJxq/ej+IaXfhtmWecjjI+f0UoDkKPTKQNes4TevgRmyn/DyMA7NL6462JW6fXrfcsI/RB5mhZXkHKOqHkremJrkWR1EPELbpOwm7Ax80iogGbYKZAAJWO2M+Wffq0f9Z7P0rY3oS85DIverUiAq9f47EF35GF8PPV3zUc0fJIqiGVkZE+cx8p8A0vCFvSlLkCL8taqsvYPWKSt1x7GtW8+7luGVm0bcy1kFoylzPcvT12GaIRHhjWCTNqHOtSS+vP5IXug/Ro46ayBp75MQa7jkZLQ9i/XaJUZD9FoFJ63mrh/0ride2jgQhYKFiC9zXa6YiL/BTL+M930IvYWV8Fr3UwzXY94IsMGTCwGVEu/47uEnt6P0jV0L41RLdW5+ARa1h4G8GNIZ86H6+TIZ2Re7Jk9T/sW6WWIAE9Xlb4ImvuWIZKG3uYJL6f9sw2l21bbmWF1l7Psp9rlBtfO36LLgLS1dmFEg03TZ3CCd0fad6x63r2IUeNp979s6h/Uq1m6xc7/gEF5DS0UoaV6Uat4tEgDsb4edLF1Va0PoBG7nz31ek0zMnAfy8BhxGXN84uiDvzZdDc7z48hcysVTWLxCbYWrL61G+ndF0l0Nzl+GMvSER0fpwpyhh3gs44x1ktXSSwNYzD9Br4B9mdoNn7PxBij2xpoqRwzN8pz7jZsGPi+vhhsXTNbNkgyVyBdD4JaEnf/inj/SlTLJeIdofbD6KOGjAwyfJWke9jk34dIb52Z8wotFUO5dCm6jv8FraFv0IKfZ3ZejBidIDRF3vst602MsUE0ai5mGivxyaFjYVYTRcZcl0nqyujLX4Z+3tOrpSqZJDCQHtbBO02zl92tqDYuYbbcgauHePNQGZ2+EtyK8yt58m/IjKGaXoVqcTPdPYUKdRrMrKI7eautlXiepdHTfZXey9z19Ac6/hDYxFu6jAqopXMUyzIX8Y+EFqYdG1HrRxTi2YCOAc9uQNoRS9GnfMS1URUUSHYEm60qcZ/Uj+1einzhZsbWp5htCRQKNbQiv9a8boQHqd/BmGshKf1SGDcvcxHtXgvH2QKrNYyNA9fgKgojLpXqLO+Duub6bKLdfehnSVdSPWW8RVLe4kHaRVze1dIHsSH9RcJWsBPYAq/+LlzNoip7mUuBT3iaX0FsH1IGOk5lvaHBkTSXnil7gs0zhwjcPjQ9hWsfnXu0ry04FwLfGnOFm4PcsbGsdQrTjr8Inv8D6yTlAwxy6EMAAAAASUVORK5CYII=";
Files.createDirectories(work);
Files.write(work.resolve("img.png"), Base64.getDecoder().decode(base64));
final var doc = new Parser().parseBody("""
= Test
image::img.png[logo]
""", new Parser.ParserContext(ContentResolver.of(Path.of("target/missing"))));
final var renderer = new SimpleHtmlRenderer(new SimpleHtmlRenderer.Configuration()
.setAssetsBase(work)
.setAttributes(Map.of("noheader", "true", "data-uri", "")));
renderer.visitBody(doc);
assertEquals("""
<div>
<h1>Test</h1>
<img src="data:image/png;base64,$base64" alt="logo">
</div>
""".replace("$base64", base64), renderer.result());
}

private void assertRendering(final String adoc, final String html) {
final var doc = new Parser().parse(adoc, new Parser.ParserContext(ContentResolver.of(Path.of("target/missing"))));
final var renderer = new SimpleHtmlRenderer();
Expand All @@ -330,7 +357,7 @@ private void assertRendering(final String adoc, final String html) {

private void assertRenderingContent(final String adoc, final String html) {
final var doc = new Parser().parseBody(adoc, new Parser.ParserContext(ContentResolver.of(Path.of("target/missing"))));
final var renderer = new SimpleHtmlRenderer(Map.of("noheader", "true"));
final var renderer = new SimpleHtmlRenderer(new SimpleHtmlRenderer.Configuration().setAttributes(Map.of("noheader", "true")));
renderer.visitBody(doc);
assertEquals(html, renderer.result());
}
Expand Down

0 comments on commit 21786cf

Please sign in to comment.