DecisionRationaleTemplateRenderer.java
package com.taxonomy.architecture.decision;
import com.taxonomy.extension.api.report.ReportRenderResult;
import com.taxonomy.templates.DecisionRationaleTemplateContract;
import com.taxonomy.templates.DocumentTemplateGitRepository.TemplateNotFoundException;
import com.taxonomy.templates.DocumentTemplateService;
import com.taxonomy.templates.DocumentTemplateService.TemplateFile;
import com.taxonomy.templates.OoxmlTemplatePackageCodec;
import org.apache.poi.ooxml.POIXMLProperties;
import org.apache.poi.xwpf.usermodel.BodyElementType;
import org.apache.poi.xwpf.usermodel.IBodyElement;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFFooter;
import org.apache.poi.xwpf.usermodel.XWPFHeader;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.springframework.stereotype.Component;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
/**
* Materializes the hierarchical decision report into the currently versioned DOTX template.
*
* <p>The existing renderer remains the single implementation of dynamic chapters, tables,
* diagrams, and appendix semantics. This adapter opens the editable cover/header/footer
* template, replaces stable tokens, removes the body marker, and invokes those proven
* rendering stages through its package-level typed body writer.</p>
*/
@Component
public final class DecisionRationaleTemplateRenderer {
private static final String DOCX_MAIN_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
private static final String TEMPLATE_ID_PROPERTY = "Taxonomy.Template.Id";
private static final String TEMPLATE_COMMIT_PROPERTY = "Taxonomy.Template.Commit";
private static final String TEMPLATE_SHA256_PROPERTY =
"Taxonomy.Template.PackageSha256";
private static final String TEMPLATE_SCHEMA_VERSION_PROPERTY =
"Taxonomy.Template.SchemaVersion";
private final DocumentTemplateService templates;
private final DecisionRationaleTemplateContract contract;
public DecisionRationaleTemplateRenderer(
DocumentTemplateService templates,
DecisionRationaleTemplateContract contract) {
this.templates = Objects.requireNonNull(templates, "templates");
this.contract = Objects.requireNonNull(contract, "contract");
}
public byte[] render(
DecisionRationaleDocxRenderer delegate,
DecisionRationaleReport report) {
return renderArtifact(delegate, report).bytes();
}
ReportRenderResult renderArtifact(
DecisionRationaleDocxRenderer delegate,
DecisionRationaleReport report) {
Objects.requireNonNull(delegate, "delegate");
Objects.requireNonNull(report, "report");
TemplateFile template;
try {
template = templates.downloadCurrentValidated(
DecisionRationaleTemplateContract.TEMPLATE_ID);
} catch (TemplateNotFoundException exception) {
throw unavailable(
"Required template is missing. Restart Taxonomy to seed the bundled "
+ "default or upload a valid template with ID '"
+ DecisionRationaleTemplateContract.TEMPLATE_ID + "'.",
exception);
} catch (Exception exception) {
throw unavailable("Required template could not be validated: "
+ safeMessage(exception), exception);
}
DecisionReportTemplateProvenance provenance =
DecisionReportTemplateProvenance.from(template);
DecisionReportLabels labels = new DecisionReportLabels(report.languageTag());
try (XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(
convertDotxToDocx(template.content())));
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
contract.validateDocument(document);
replaceTokens(document, tokenValues(report, labels, template));
removeBodyMarker(document);
delegate.writeReportBody(document, report, labels);
writeTemplateProvenanceProperties(document, provenance);
document.write(output);
return new ReportRenderResult(
output.toByteArray(), provenance.artifactMetadata());
} catch (Exception exception) {
throw unavailable(
"Could not materialize template commit " + template.commitId()
+ ": " + safeMessage(exception),
exception);
}
}
private static Map<String, String> tokenValues(
DecisionRationaleReport report,
DecisionReportLabels labels,
TemplateFile template) {
Map<String, String> values = new LinkedHashMap<>();
values.put(DecisionRationaleTemplateContract.TITLE_TOKEN, value(report.title()));
values.put("{{taxonomy.report.subtitle}}", labels.subtitle());
values.put("{{taxonomy.report.status}}", statusLabel(report, labels));
values.put(DecisionRationaleTemplateContract.REQUIREMENT_TOKEN,
value(report.requirement()));
values.put("{{taxonomy.report.generatedAt}}",
formatInstant(report.metadata().generatedAt(),
report.metadata().reportTimeZone(),
labels.german()));
values.put("{{taxonomy.report.generatedBy}}",
value(report.metadata().generatedBy()));
values.put("{{taxonomy.report.taxonomyVersion}}",
value(report.metadata().taxonomyDataVersion()));
values.put("{{taxonomy.report.applicationVersion}}",
value(report.metadata().taxonomyApplicationVersion()));
values.put("{{taxonomy.report.commit}}",
value(report.metadata().taxonomyBuildCommit()));
values.put("{{taxonomy.report.repository}}",
value(report.metadata().repositoryId()));
values.put("{{taxonomy.report.workspace}}",
value(report.metadata().workspaceId()));
values.put("{{taxonomy.report.branch}}",
value(report.metadata().branch()));
values.put("{{taxonomy.report.basedOnCommit}}",
value(report.metadata().basedOnCommit()));
values.put("{{taxonomy.report.analysisProvider}}",
value(report.metadata().analysisProvider()));
values.put("{{taxonomy.template.id}}",
DecisionRationaleTemplateContract.TEMPLATE_ID);
values.put("{{taxonomy.template.commit}}",
abbreviate(template.commitId()));
values.put("{{taxonomy.template.sha256}}",
value(template.manifest().packageSha256()));
return Map.copyOf(values);
}
private static void writeTemplateProvenanceProperties(
XWPFDocument document,
DecisionReportTemplateProvenance provenance) {
POIXMLProperties.CustomProperties properties =
document.getProperties().getCustomProperties();
replaceCustomProperty(
properties,
TEMPLATE_ID_PROPERTY,
provenance.templateId());
replaceCustomProperty(
properties,
TEMPLATE_COMMIT_PROPERTY,
provenance.commitId());
replaceCustomProperty(
properties,
TEMPLATE_SHA256_PROPERTY,
provenance.packageSha256());
replaceCustomProperty(
properties,
TEMPLATE_SCHEMA_VERSION_PROPERTY,
Integer.toString(provenance.schemaVersion()));
}
private static void replaceCustomProperty(
POIXMLProperties.CustomProperties properties,
String name,
String value) {
var underlying = properties.getUnderlyingProperties();
for (int index = underlying.sizeOfPropertyArray() - 1;
index >= 0;
index--) {
if (name.equals(underlying.getPropertyArray(index).getName())) {
underlying.removeProperty(index);
}
}
properties.addProperty(name, value);
}
private static void replaceTokens(
XWPFDocument document,
Map<String, String> values) {
replaceContainer(document.getParagraphs(), document.getTables(), values);
for (XWPFHeader header : document.getHeaderList()) {
replaceContainer(header.getParagraphs(), header.getTables(), values);
}
for (XWPFFooter footer : document.getFooterList()) {
replaceContainer(footer.getParagraphs(), footer.getTables(), values);
}
}
private static void replaceContainer(
List<XWPFParagraph> paragraphs,
List<XWPFTable> tables,
Map<String, String> values) {
for (XWPFParagraph paragraph : paragraphs) {
replaceParagraph(paragraph, values);
}
for (XWPFTable table : tables) {
replaceTable(table, values);
}
}
private static void replaceTable(
XWPFTable table,
Map<String, String> values) {
for (XWPFTableRow row : table.getRows()) {
for (XWPFTableCell cell : row.getTableCells()) {
replaceContainer(cell.getParagraphs(), cell.getTables(), values);
}
}
}
/**
* Replace tokens across run boundaries while retaining the first run's formatting.
*
* <p>Word may split a visually contiguous placeholder into several runs after an
* administrator edits its formatting. Reconstructing the paragraph text makes that
* common case deterministic rather than relying on one token per run.</p>
*/
private static void replaceParagraph(
XWPFParagraph paragraph,
Map<String, String> values) {
String original = paragraph.getText();
if (original == null || original.isEmpty()) {
return;
}
String replaced = original;
for (Map.Entry<String, String> token : values.entrySet()) {
replaced = replaced.replace(token.getKey(), token.getValue());
}
if (replaced.equals(original)) {
return;
}
List<XWPFRun> runs = paragraph.getRuns();
XWPFRun first = runs.isEmpty() ? paragraph.createRun() : runs.get(0);
for (int index = paragraph.getRuns().size() - 1; index >= 1; index--) {
paragraph.removeRun(index);
}
while (first.getCTR().sizeOfTArray() > 1) {
first.getCTR().removeT(first.getCTR().sizeOfTArray() - 1);
}
if (first.getCTR().sizeOfTArray() == 0) {
first.setText(replaced);
} else {
first.setText(replaced, 0);
}
}
private static void removeBodyMarker(XWPFDocument document) {
List<IBodyElement> elements = new ArrayList<>(document.getBodyElements());
for (int index = 0; index < elements.size(); index++) {
IBodyElement element = elements.get(index);
if (element.getElementType() != BodyElementType.PARAGRAPH) {
continue;
}
XWPFParagraph paragraph = (XWPFParagraph) element;
if (DecisionRationaleTemplateContract.BODY_MARKER
.equals(paragraph.getText().strip())) {
document.removeBodyElement(index);
return;
}
}
throw new IllegalArgumentException(
"Required body marker "
+ DecisionRationaleTemplateContract.BODY_MARKER
+ " was not found");
}
/**
* A DOTX and a DOCX use the same package structure but different main-part content
* types. Convert before Apache POI opens the package so the emitted artifact is a
* genuine DOCX rather than a template with a misleading file extension.
*/
private static byte[] convertDotxToDocx(byte[] dotx) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream(dotx.length);
boolean contentTypeChanged = false;
try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(dotx));
ZipOutputStream zip = new ZipOutputStream(output)) {
ZipEntry entry;
while ((entry = input.getNextEntry()) != null) {
byte[] content = input.readAllBytes();
if ("[Content_Types].xml".equals(entry.getName())) {
String xml = new String(content, StandardCharsets.UTF_8);
if (!xml.contains(OoxmlTemplatePackageCodec.DOTX_MAIN_CONTENT_TYPE)) {
throw new IOException(
"Template package does not declare the DOTX main content type");
}
xml = xml.replace(
OoxmlTemplatePackageCodec.DOTX_MAIN_CONTENT_TYPE,
DOCX_MAIN_CONTENT_TYPE);
content = xml.getBytes(StandardCharsets.UTF_8);
contentTypeChanged = true;
}
ZipEntry copy = new ZipEntry(entry.getName());
if (entry.getComment() != null) {
copy.setComment(entry.getComment());
}
zip.putNextEntry(copy);
if (!entry.isDirectory()) {
zip.write(content);
}
zip.closeEntry();
input.closeEntry();
}
}
if (!contentTypeChanged) {
throw new IOException("Template package is missing [Content_Types].xml");
}
return output.toByteArray();
}
private static String statusLabel(
DecisionRationaleReport report,
DecisionReportLabels labels) {
return switch (report.status()) {
case FINAL -> labels.finalStatus();
case FINAL_WITH_WARNINGS -> labels.finalWarnings();
case DRAFT_INCOMPLETE -> labels.draft();
case NO_RESULT -> labels.noResult();
};
}
private static String formatInstant(
Instant instant,
String zoneId,
boolean german) {
if (instant == null) {
return "unknown";
}
ZoneId zone;
try {
zone = ZoneId.of(value(zoneId));
} catch (RuntimeException exception) {
zone = ZoneOffset.UTC;
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(
german ? "dd.MM.yyyy HH:mm z" : "yyyy-MM-dd HH:mm z",
german ? Locale.GERMAN : Locale.ENGLISH);
return formatter.withZone(zone).format(instant);
}
private static String value(String value) {
return value == null || value.isBlank() ? "unknown" : value.strip();
}
private static String abbreviate(String value) {
String normalized = value(value);
return normalized.length() <= 12 ? normalized : normalized.substring(0, 12);
}
private static IllegalStateException unavailable(
String detail,
Exception cause) {
return new IllegalStateException(
"Decision report template '"
+ DecisionRationaleTemplateContract.TEMPLATE_ID
+ "' is unavailable: " + detail,
cause);
}
private static String safeMessage(Exception exception) {
String message = exception.getMessage();
return message == null || message.isBlank()
? exception.getClass().getSimpleName()
: message;
}
}