AsciiDocToSvg.java
package pro.verron.asciidoc.converters;
import pro.verron.asciidoc.converters.svg.*;
import pro.verron.asciidoc.core.*;
import java.util.*;
import static pro.verron.asciidoc.converters.svg.AsciiDocFont.getAwtFont;
import static pro.verron.asciidoc.converters.svg.AsciiDocMetrics.wrapText;
import static pro.verron.asciidoc.converters.svg.SvgAttribute.attr;
/// Converts an [AsciiDocModel] into an SVG document simulating an editor
/// interface.
///
/// The theme is read from the model attribute {@code "theme"}. Supported
/// values are {@code "word"}, {@code "gdocs"}, and {@code "libre"}.
///
/// @see AsciiDocToHtml
/// @see AsciiDocToText
public final class AsciiDocToSvg {
private static final int VIEWPORT_WIDTH = 1200;
private static final int BANNER_HEIGHT = 100;
private static final int PAGE_WIDTH = 800;
private static final int PAGE_LEFT = 50;
private static final int PAGE_MARGIN_TOP = 40;
private static final int PAGE_PADDING = 72;
private static final int COMMENTS_LEFT = 900;
private static final int COMMENT_WIDTH = 250;
private static final int BODY_FONT_SIZE = 14;
private static final int LINE_HEIGHT = 20;
/// Constructs a new [AsciiDocToSvg] converter.
public AsciiDocToSvg() {}
/// Converts the given AsciiDoc model into an SVG document simulating an
/// editor interface.
///
/// @param model the parsed AsciiDoc model
///
/// @return the SVG document as an XML string
public String apply(AsciiDocModel model) {
var themeStr = model.getAttribute("theme")
.orElse("word");
var theme = Theme.valueOf(themeStr.toUpperCase());
var comments = extractComments(model);
var blockToComments = mapCommentsToBlocks(comments);
var pageY = BANNER_HEIGHT + PAGE_MARGIN_TOP;
var currentY = pageY + PAGE_PADDING;
var blockPositions = new ArrayList<BlockPosition>();
var bodyElements = new ArrayList<SvgElement>();
var modelBlocks = model.getBlocks();
for (int i = 0; i < modelBlocks.size(); i++) {
var block = modelBlocks.get(i);
if (block instanceof MacroBlock m && "comment".equals(m.name()))
continue;
int startY = currentY;
boolean isCommented = blockToComments.containsKey(i);
var result = renderBlock(block,
PAGE_LEFT + PAGE_PADDING,
currentY,
isCommented,
theme);
currentY = result.nextY();
bodyElements.addAll(result.elements());
blockPositions.add(new BlockPosition(i, startY, currentY - 8));
}
var pageHeight = Math.max(800, currentY - pageY + PAGE_PADDING);
int totalHeight = pageY + pageHeight + 50;
var children = new ArrayList<SvgElement>();
// Background
var bgColor = theme.getBgColor();
SvgRect bg;
if (bgColor.isEmpty()) bg = new SvgRect("0", "0", "100%", "100%");
else {
var fill = attr("fill", bgColor.get());
bg = new SvgRect("0", "0", "100%", "100%", fill);
}
children.add(bg);
// Editor Banner
var title = model.getAttribute("title")
.orElse("Document.docx");
children.addAll(theme.renderBanner(title, BANNER_HEIGHT));
// Page Shadow
children.add(new SvgRect(String.valueOf(PAGE_LEFT + 4),
String.valueOf(pageY + 4),
String.valueOf(PAGE_WIDTH),
String.valueOf(pageHeight),
attr("fill", "#000"),
attr("fill-opacity", "0.1"),
attr("rx", "2")));
// Page
children.add(new SvgRect(String.valueOf(PAGE_LEFT),
String.valueOf(pageY),
String.valueOf(PAGE_WIDTH),
String.valueOf(pageHeight),
attr("fill", "white"),
attr("stroke", "#ccc"),
attr("stroke-width", "1")));
// Body content
children.addAll(bodyElements);
// Comments and connectors
children.addAll(renderComments(blockToComments, blockPositions, theme));
var doc = new SvgDocument(VIEWPORT_WIDTH, totalHeight, children);
var serialized = doc.serialize();
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + serialized;
}
private List<CommentInfo> extractComments(AsciiDocModel model) {
var comments = new ArrayList<CommentInfo>();
for (var block : model.getBlocks()) {
if (!(block instanceof MacroBlock macro)) continue;
if (!Objects.equals(macro.name(), "comment")) continue;
var start = macro.attribute("start");
var author = macro.attribute("author");
var value = macro.attribute("value");
var commentInfo = new CommentInfo(macro.id(),
start.orElse(""),
author.orElse(""),
value.orElse(""));
comments.add(commentInfo);
}
return comments;
}
private BlockRenderResult renderBlock(
Block block,
int x,
int y,
boolean highlight,
Theme theme
) {
var elements = new ArrayList<SvgElement>();
var nextY = y;
var maxWidth = PAGE_WIDTH - 2 * PAGE_PADDING;
switch (block) {
case Heading(_, int level, List<Inline> inlines) -> {
var fontSize = Math.max(18, 34 - (level * 3));
var result = appendWrappedText(renderInlines(inlines),
x,
y + fontSize,
fontSize,
700,
theme,
maxWidth);
elements.addAll(result.elements());
nextY = result.nextY() + 10;
}
case Paragraph(_, List<Inline> inlines) -> {
var result = appendWrappedText(renderInlines(inlines),
x,
y + BODY_FONT_SIZE,
BODY_FONT_SIZE,
400,
theme,
maxWidth);
elements.addAll(result.elements());
nextY = result.nextY() + 8;
}
case UnorderedList(List<ListItem> items) -> {
for (ListItem item : items) {
var result = appendWrappedText(
"• " + renderInlines(item.inlines()),
x,
nextY + BODY_FONT_SIZE,
BODY_FONT_SIZE,
400,
theme,
maxWidth);
elements.addAll(result.elements());
nextY = result.nextY();
}
nextY += 6;
}
case OrderedList(List<ListItem> items) -> {
int index = 1;
for (ListItem item : items) {
var result = appendWrappedText(
index + ". " + renderInlines(item.inlines()),
x,
nextY + BODY_FONT_SIZE,
BODY_FONT_SIZE,
400,
theme,
maxWidth);
elements.addAll(result.elements());
nextY = result.nextY();
index++;
}
nextY += 6;
}
case QuoteBlock(List<Inline> inlines) -> {
nextY += 8;
int startY = nextY;
var result = appendWrappedText(renderInlines(inlines),
x + 10,
nextY + BODY_FONT_SIZE,
BODY_FONT_SIZE,
400,
theme,
maxWidth - 10);
elements.addAll(result.elements());
nextY = result.nextY();
elements.add(new SvgLine(String.valueOf(x),
String.valueOf(startY),
String.valueOf(x),
String.valueOf(nextY - 4),
"#888",
attr("stroke-width", 3)));
}
case CodeBlock(String language, String content) -> {
nextY += 8;
int blockHeight = Math.max(LINE_HEIGHT * 2,
(content.lines()
.toList()
.size() + 1) * LINE_HEIGHT);
elements.add(new SvgRect(String.valueOf(x),
String.valueOf(nextY),
String.valueOf(PAGE_WIDTH - (PAGE_PADDING * 2)),
String.valueOf(blockHeight),
attr("fill", "#f6f8fa"),
attr("stroke", "#d0d7de"),
attr("rx", "6")));
var textResult = appendTextLine("[" + language + "]",
x + 10,
nextY + BODY_FONT_SIZE,
12,
600,
theme);
elements.add(textResult.element());
nextY = textResult.nextY();
for (String line : content.lines()
.toList()) {
var lineResult = appendTextLine(line,
x + 10,
nextY + 12,
13,
400,
theme);
elements.add(lineResult.element());
nextY = lineResult.nextY();
}
nextY += 8;
}
case ImageBlock(String url, String _) -> {
nextY += 8;
elements.add(new SvgImage(String.valueOf(x),
String.valueOf(nextY),
"320",
"120",
url,
attr("rx", 4)));
nextY += 114 + LINE_HEIGHT;
}
case Table tbl -> {
nextY += 8;
var rows = tbl.rows();
var nbRows = rows.size();
var rowHeight = 8 + LINE_HEIGHT;
var tblHeight = rowHeight * nbRows;
var tblWidth = 380;
elements.add(new SvgRect(String.valueOf(x),
String.valueOf(nextY),
String.valueOf(tblWidth),
String.valueOf(tblHeight),
attr("stroke", "black"),
attr("fill", "white")));
for (var row : rows) {
elements.add(new SvgRect(String.valueOf(x),
String.valueOf(nextY),
String.valueOf(tblWidth),
String.valueOf(rowHeight),
attr("stroke", "black"),
attr("fill", "white")));
var nextX = x;
var cells = row.cells();
var cellWidth = tblWidth / cells.size();
for (var cell : cells) {
elements.add(new SvgRect(String.valueOf(nextX),
String.valueOf(nextY),
String.valueOf(cellWidth),
String.valueOf(rowHeight),
attr("stroke", "black"),
attr("fill", "white")));
nextX += cellWidth;
}
nextY += rowHeight;
}
}
default -> nextY += LINE_HEIGHT;
}
if (highlight) {
var attrFill = theme.getHighlightColor()
.map(c -> attr("fill", c))
.orElse(SvgAttribute.NONE);
var highlightRect = new SvgRect(String.valueOf(x - 5),
String.valueOf(y - 2),
String.valueOf(PAGE_WIDTH - 2 * PAGE_PADDING + 10),
String.valueOf(nextY - y),
attrFill);
elements.addFirst(highlightRect);
}
return new BlockRenderResult(nextY, elements);
}
private List<SvgElement> renderComments(
Map<Integer, List<CommentInfo>> blockToComments,
List<BlockPosition> blockPositions,
Theme theme
) {
var elements = new ArrayList<SvgElement>();
var commentY = BANNER_HEIGHT + PAGE_MARGIN_TOP + PAGE_PADDING;
var strokeColor = theme.getStrokeColor();
for (var pos : blockPositions) {
var comments = blockToComments.get(pos.index);
if (comments == null) continue;
for (var c : comments) {
var commentPadding = 10;
var textWidth = COMMENT_WIDTH - 2 * commentPadding;
var authorFont = getAwtFont(theme, 11, 700);
var valueFont = getAwtFont(theme, 11, 400);
var valueLines = wrapText(c.value, valueFont, textWidth);
var rectHeight = 30 + (valueLines.size() * 15);
var attrStroke = strokeColor.map(sc -> attr("stroke", sc))
.orElse(SvgAttribute.NONE);
elements.add(new SvgRect(String.valueOf(COMMENTS_LEFT),
String.valueOf(commentY),
String.valueOf(COMMENT_WIDTH),
String.valueOf(rectHeight),
attr("fill", "#f9f9f9"),
attr("stroke-width", "1"),
attr("rx", "4"),
attrStroke));
if (theme == Theme.GDOCS) {
elements.add(new SvgCircle(String.valueOf(
COMMENTS_LEFT + 15),
String.valueOf(commentY + 20),
"5",
"#4285f4"));
}
var attrFF = theme.getFontFamily()
.map(ff -> attr("font-family", ff))
.orElse(SvgAttribute.NONE);
elements.add(new SvgText(String.valueOf(
COMMENTS_LEFT + (theme == Theme.GDOCS ? 25 : 10)),
String.valueOf(commentY + 20),
"11",
"#333",
c.author,
attr("font-weight", "bold"),
attrFF));
var lineY = commentY + 35;
for (var line : valueLines) {
elements.add(new SvgText(String.valueOf(COMMENTS_LEFT + 10),
String.valueOf(lineY),
"11",
"#666",
line,
attrFF));
lineY += 15;
}
var startX = PAGE_LEFT + PAGE_WIDTH;
var startY = (pos.startY + pos.endY) / 2;
var endX = COMMENTS_LEFT;
var endY = commentY + (rectHeight / 2);
elements.add(new SvgLine(String.valueOf(startX),
String.valueOf(startY),
String.valueOf(endX),
String.valueOf(endY),
strokeColor.orElse(""),
attr("stroke-width", 1),
attr("stroke-dasharray", 4)));
commentY += rectHeight + 10;
}
}
return elements;
}
private Map<Integer, List<CommentInfo>> mapCommentsToBlocks(List<CommentInfo> comments) {
Map<Integer, List<CommentInfo>> map = new TreeMap<>();
for (CommentInfo c : comments) {
try {
int blockIndex = Integer.parseInt(c.start.split(",")[0]);
map.computeIfAbsent(blockIndex, k -> new ArrayList<>())
.add(c);
} catch (Exception ignored) {}
}
return map;
}
private TextLineResult appendTextLine(
String line,
int x,
int y,
int fontSize,
int weight,
Theme theme
) {
var text = new SvgText(String.valueOf(x),
String.valueOf(y),
String.valueOf(fontSize),
"#111",
line,
attr("font-weight", weight),
theme.getFontFamily()
.map(ff -> attr("font-family", ff))
.orElse(SvgAttribute.NONE));
return new TextLineResult(text, y + LINE_HEIGHT);
}
private WrappedTextResult appendWrappedText(
String text,
int x,
int y,
int fontSize,
int weight,
Theme theme,
int maxWidth
) {
var font = getAwtFont(theme, fontSize, weight);
var lines = wrapText(text, font, maxWidth);
var elements = new ArrayList<SvgElement>();
var currentY = y;
for (var line : lines) {
var result = appendTextLine(line,
x,
currentY,
fontSize,
weight,
theme);
elements.add(result.element());
currentY = result.nextY();
}
return new WrappedTextResult(currentY, elements);
}
private String renderInlines(List<Inline> inlines) {
var text = new StringBuilder();
for (var inline : inlines) {
var string = switch (inline) {
case Text(String value) -> value;
case Bold(List<Inline> children) -> renderInlines(children);
case Italic(List<Inline> children) -> renderInlines(children);
case Link(String url, String label) ->
label.isBlank() ? url : label;
case ImageInline(String url, Map<String, String> attributes) ->
"[%s: %s]".formatted(attributes.getOrDefault("title",
"image"), url);
case Tab _ -> " ";
default -> inline.text();
};
text.append(string);
}
return text.toString();
}
private record CommentInfo(
String id, String start, String author, String value
) {}
private record BlockPosition(int index, int startY, int endY) {}
private record BlockRenderResult(int nextY, List<SvgElement> elements) {}
private record TextLineResult(SvgText element, int nextY) {}
private record WrappedTextResult(int nextY, List<SvgElement> elements) {}
}