ArchitecturePdfRenderer.java
package com.taxonomy.portfolio.workbench;
import com.taxonomy.diagram.DiagramScene;
import com.taxonomy.diagram.DiagramSceneEdge;
import com.taxonomy.diagram.DiagramSceneNode;
import com.taxonomy.export.DiagramTextWrapper;
import com.taxonomy.portfolio.workbench.ArchitectureWorkbenchDtos.Projection;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.springframework.stereotype.Component;
import java.awt.Color;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.text.Normalizer;
import java.util.List;
/** Draws a vector PDF directly from the same deterministic scene returned to the browser. */
@Component
public class ArchitecturePdfRenderer {
private static final float PAGE_MARGIN = 32.0f;
private static final float HEADER_HEIGHT = 54.0f;
private static final PDType1Font REGULAR =
new PDType1Font(Standard14Fonts.FontName.HELVETICA);
private static final PDType1Font BOLD =
new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD);
public byte[] render(Projection projection) {
if (projection == null || projection.scene() == null || projection.scene().isEmpty()) {
throw new IllegalArgumentException("Architecture projection must contain a renderable scene");
}
return renderScene(projection.scene(), document -> configureMetadata(document, projection),
(stream, height) -> drawHeader(stream, projection, height), stream -> drawFooter(stream, projection));
}
/** Live editor export uses the same vector geometry without masquerading as an analysis snapshot. */
public byte[] render(DiagramScene scene, String provenance) {
if (scene == null || scene.isEmpty()) throw new IllegalArgumentException("Architecture scene must not be empty");
return renderScene(scene, document -> {
PDDocumentInformation information = new PDDocumentInformation();
information.setTitle(scene.title());
information.setSubject(provenance);
information.setCreator("Taxonomy server-side vector renderer");
document.setDocumentInformation(information);
}, (stream, height) -> {
text(stream, BOLD, 16, PAGE_MARGIN, height - PAGE_MARGIN - 16, scene.title());
List<String> lines = java.util.Arrays.asList(provenance.split("\\n"));
for (int i = 0; i < Math.min(2, lines.size()); i++) {
text(stream, REGULAR, 8, PAGE_MARGIN, height - PAGE_MARGIN - 33 - i * 12, lines.get(i));
}
}, stream -> text(stream, REGULAR, 8, PAGE_MARGIN, 16, "Derived server layout · Git-authoritative architecture"));
}
@FunctionalInterface
private interface HeaderWriter { void write(PDPageContentStream stream, float height) throws IOException; }
@FunctionalInterface
private interface FooterWriter { void write(PDPageContentStream stream) throws IOException; }
private byte[] renderScene(DiagramScene scene, java.util.function.Consumer<PDDocument> metadata,
HeaderWriter header, FooterWriter footer) {
try (PDDocument document = new PDDocument();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
PDPage page = new PDPage(new PDRectangle(PDRectangle.A3.getHeight(), PDRectangle.A3.getWidth()));
document.addPage(page);
metadata.accept(document);
float availableWidth = page.getMediaBox().getWidth() - 2 * PAGE_MARGIN;
float availableHeight = page.getMediaBox().getHeight() - 2 * PAGE_MARGIN - HEADER_HEIGHT;
float scale = (float) Math.min(
availableWidth / Math.max(1.0, scene.width()),
availableHeight / Math.max(1.0, scene.height()));
float diagramX = PAGE_MARGIN;
float diagramTop = page.getMediaBox().getHeight() - PAGE_MARGIN - HEADER_HEIGHT;
try (PDPageContentStream stream = new PDPageContentStream(document, page)) {
stream.setNonStrokingColor(Color.WHITE);
stream.addRect(0, 0, page.getMediaBox().getWidth(), page.getMediaBox().getHeight());
stream.fill();
header.write(stream, page.getMediaBox().getHeight());
drawEdges(stream, scene, diagramX, diagramTop, scale);
drawNodes(stream, scene, diagramX, diagramTop, scale);
footer.write(stream);
}
document.save(output);
return output.toByteArray();
} catch (IOException error) {
throw new IllegalStateException("Unable to render architecture PDF", error);
}
}
private static void configureMetadata(PDDocument document, Projection projection) {
PDDocumentInformation information = new PDDocumentInformation();
information.setTitle(projection.scene().title());
information.setSubject("Requirement-derived architecture snapshot " + projection.snapshotId());
information.setAuthor("Taxonomy Architecture Analyzer");
information.setCreator("Taxonomy server-side vector renderer");
information.setKeywords("architecture, taxonomy, requirement, snapshot, " + projection.snapshotId());
document.setDocumentInformation(information);
}
private static void drawHeader(PDPageContentStream stream,
Projection projection,
float pageHeight) throws IOException {
float y = pageHeight - PAGE_MARGIN - 16;
text(stream, BOLD, 16, PAGE_MARGIN, y, projection.scene().title());
text(stream, REGULAR, 9, PAGE_MARGIN, y - 19,
"Project " + projection.projectKey()
+ " · Requirement " + projection.requirementKey()
+ " · Snapshot " + projection.snapshotId());
text(stream, REGULAR, 8, PAGE_MARGIN, y - 33,
"Provider " + safe(projection.provider())
+ " · Branch " + safe(projection.branchName())
+ " · Commit " + abbreviated(projection.commitSha()));
}
private static void drawEdges(PDPageContentStream stream,
DiagramScene scene,
float diagramX,
float diagramTop,
float scale) throws IOException {
stream.setStrokingColor(new Color(88, 97, 116));
for (DiagramSceneEdge edge : scene.edges()) {
float x1 = diagramX + (float) edge.sourceX() * scale;
float y1 = diagramTop - (float) edge.sourceY() * scale;
float x2 = diagramX + (float) edge.targetX() * scale;
float y2 = diagramTop - (float) edge.targetY() * scale;
stream.setLineWidth(Math.max(0.55f, (float) (0.8 + edge.relevance()) * scale));
stream.moveTo(x1, y1);
stream.lineTo(x2, y2);
stream.stroke();
drawArrow(stream, x1, y1, x2, y2, Math.max(4.0f, 7.0f * scale));
if (edge.relationType() != null && !edge.relationType().isBlank()) {
text(stream, REGULAR, Math.max(5.5f, 7.5f * scale),
(x1 + x2) / 2.0f - 12,
(y1 + y2) / 2.0f + 4,
edge.relationType());
}
}
}
private static void drawNodes(PDPageContentStream stream,
DiagramScene scene,
float diagramX,
float diagramTop,
float scale) throws IOException {
for (DiagramSceneNode node : scene.nodes()) {
float x = diagramX + (float) node.x() * scale;
float top = diagramTop - (float) node.y() * scale;
float width = (float) node.width() * scale;
float height = (float) node.height() * scale;
float y = top - height;
stream.setNonStrokingColor(fill(node.type()));
stream.addRect(x, y, width, height);
stream.fill();
stream.setStrokingColor(node.anchor()
? new Color(23, 37, 84)
: new Color(100, 116, 139));
stream.setLineWidth(node.anchor() ? Math.max(1.4f, 2.2f * scale) : Math.max(0.7f, scale));
stream.addRect(x, y, width, height);
stream.stroke();
float fontScale = Math.max(0.62f, scale);
text(stream, BOLD, 7.5f * fontScale, x + 8 * scale, top - 15 * scale, node.id());
text(stream, REGULAR, 6.3f * fontScale,
x + width - 62 * scale,
top - 14 * scale,
node.type() + " " + Math.round(node.relevance() * 100.0) + "%");
List<String> labelLines = DiagramTextWrapper.wrap(node.label(), 34, 2, "n/a");
for (int index = 0; index < labelLines.size(); index++) {
text(stream, REGULAR, 8.3f * fontScale,
x + 8 * scale,
top - (38 + index * 16) * scale,
labelLines.get(index));
}
}
}
private static void drawFooter(PDPageContentStream stream,
Projection projection) throws IOException {
text(stream, REGULAR, 7, PAGE_MARGIN, 17,
"Generated from persisted architecture snapshot; no page screenshot and no LLM re-analysis.");
if (!projection.warnings().isEmpty()) {
text(stream, REGULAR, 7, PAGE_MARGIN, 8,
"Warnings: " + String.join(" | ", projection.warnings()));
}
}
private static void drawArrow(PDPageContentStream stream,
float x1,
float y1,
float x2,
float y2,
float size) throws IOException {
double angle = Math.atan2(y2 - y1, x2 - x1);
float ax1 = (float) (x2 - size * Math.cos(angle - Math.PI / 6.0));
float ay1 = (float) (y2 - size * Math.sin(angle - Math.PI / 6.0));
float ax2 = (float) (x2 - size * Math.cos(angle + Math.PI / 6.0));
float ay2 = (float) (y2 - size * Math.sin(angle + Math.PI / 6.0));
stream.moveTo(x2, y2);
stream.lineTo(ax1, ay1);
stream.moveTo(x2, y2);
stream.lineTo(ax2, ay2);
stream.stroke();
}
private static void text(PDPageContentStream stream,
PDType1Font font,
float size,
float x,
float y,
String value) throws IOException {
String safe = ascii(value);
if (safe.isBlank()) return;
stream.beginText();
stream.setNonStrokingColor(new Color(15, 23, 42));
stream.setFont(font, Math.max(5.0f, size));
stream.newLineAtOffset(x, y);
stream.showText(safe);
stream.endText();
}
private static Color fill(String type) {
if (type == null) return new Color(241, 245, 249);
return switch (type) {
case "Capabilities" -> new Color(219, 234, 254);
case "Business Processes", "Business Roles" -> new Color(220, 252, 231);
case "Core Services", "COI Services", "Services" -> new Color(254, 243, 199);
case "User Applications", "Applications" -> new Color(243, 232, 255);
case "Information Products" -> new Color(255, 228, 230);
case "Communications Services" -> new Color(207, 250, 254);
default -> new Color(241, 245, 249);
};
}
private static String abbreviated(String value) {
String normalized = safe(value);
return normalized.length() > 12 ? normalized.substring(0, 12) : normalized;
}
private static String safe(String value) {
return value == null || value.isBlank() ? "n/a" : value.strip();
}
/** Keep labels readable with the Standard 14 PDF fonts. */
private static String ascii(String value) {
String german = safe(value)
.replace("Ä", "Ae")
.replace("Ö", "Oe")
.replace("Ü", "Ue")
.replace("ä", "ae")
.replace("ö", "oe")
.replace("ü", "ue")
.replace("ß", "ss")
.replace('–', '-')
.replace('—', '-')
.replace('…', '.')
.replace('·', '|');
return Normalizer.normalize(german, Normalizer.Form.NFD)
.replaceAll("\\p{M}+", "")
.replaceAll("[^\\x20-\\x7E]", "?");
}
}