VisioPackageBuilder.java
package com.taxonomy.export;
import com.taxonomy.visio.VisioDocument;
import com.taxonomy.visio.VisioPage;
import com.taxonomy.visio.converter.VisioDocumentConverter;
import com.taxonomy.visio.converter.VisioPageContentsConverter;
import com.taxonomy.visio.converter.VisioPagesConverter;
import com.thoughtworks.xstream.XStream;
import com.thoughtworks.xstream.io.xml.XppDriver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
/**
* Builds the currently supported, deliberately bounded Visio {@code .vsdx}
* package profile from a {@link VisioDocument}.
* <p>
* The package uses the Open Packaging Convention and Visio 2012 XML namespace.
* Structural and independent-reader verification belong to this builder's test
* contract; compatibility with a particular Microsoft Visio release requires
* separate product evidence.
*/
public class VisioPackageBuilder {
private static final Logger log = LoggerFactory.getLogger(VisioPackageBuilder.class);
private static final String XML_DECLARATION =
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n";
// The DOS minimum (Jan 1) is also ZipEntry's sentinel for an extended
// timestamp, whose conversion uses the default timezone. Jan 2 stays in
// the DOS range and emits no timezone-dependent extended timestamp.
private static final LocalDateTime ZIP_EPOCH = LocalDateTime.of(1980, 1, 2, 0, 0);
/**
* Builds a deterministic {@code .vsdx} byte array from a validated document.
*
* @param doc the Visio document model
* @return the package bytes
* @throws IOException if ZIP creation fails
* @throws IllegalArgumentException if the document violates the supported package profile
*/
public byte[] build(VisioDocument doc) throws IOException {
VisioPackageValidator.validate(doc);
Map<String, String> parts = buildParts(doc);
VisioOpcValidator.validate(parts);
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) {
zip.setLevel(Deflater.BEST_COMPRESSION);
for (Map.Entry<String, String> part : parts.entrySet()) {
writeEntry(zip, part.getKey(), part.getValue());
}
}
log.info("Built bounded .vsdx package: {} bytes, {} parts", output.size(), parts.size());
return output.toByteArray();
}
/** VSDX plus a directly readable, checksum-bound handoff manifest and mapping profile. */
public byte[] buildBundle(VisioDocument doc) throws IOException {
byte[] vsdx = build(doc);
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (ZipOutputStream zip = new ZipOutputStream(output, StandardCharsets.UTF_8)) {
zip.setLevel(Deflater.BEST_COMPRESSION);
ZipEntry entry = new ZipEntry("diagram.vsdx");
entry.setTimeLocal(ZIP_EPOCH);
zip.putNextEntry(entry); zip.write(vsdx); zip.closeEntry();
writeEntry(zip, "mapping-profile.json", VisioHandoffProfile.profileJson());
writeEntry(zip, "manifest.json", VisioHandoffProfile.bundleManifest(vsdx, VisioHandoffProfile.manifest(doc)));
}
return output.toByteArray();
}
private Map<String, String> buildParts(VisioDocument doc) {
Map<String, String> parts = new LinkedHashMap<>();
parts.put("[Content_Types].xml", buildContentTypes(doc));
parts.put("_rels/.rels", buildRootRelationships());
parts.put("docProps/core.xml", buildCoreProperties(doc));
parts.put("docProps/app.xml", buildExtendedProperties(doc));
parts.put("docProps/custom.xml", buildCustomProperties(doc));
parts.put("taxonomy/manifest.json", VisioHandoffProfile.manifest(doc));
parts.put("taxonomy/mapping-profile.json", VisioHandoffProfile.profileJson());
parts.put("visio/document.xml", buildDocumentXml(doc));
parts.put("visio/_rels/document.xml.rels", buildDocumentRelationships());
parts.put("visio/pages/pages.xml", buildPagesXml(doc));
parts.put("visio/pages/_rels/pages.xml.rels", buildPagesRelationships(doc));
for (int index = 0; index < doc.getPages().size(); index++) {
VisioPage page = doc.getPages().get(index);
parts.put("visio/pages/page" + (index + 1) + ".xml", buildPageXml(page));
}
return parts;
}
private void writeEntry(ZipOutputStream zip, String name, String content) throws IOException {
ZipEntry entry = new ZipEntry(name);
entry.setTimeLocal(ZIP_EPOCH);
zip.putNextEntry(entry);
zip.write(content.getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
private String buildContentTypes(VisioDocument doc) {
StringBuilder xml = new StringBuilder(XML_DECLARATION);
xml.append("<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n");
xml.append(" <Default Extension=\"rels\" ")
.append("ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/>\n");
xml.append(" <Default Extension=\"xml\" ContentType=\"application/xml\"/>\n");
xml.append(" <Override PartName=\"/docProps/core.xml\" ")
.append("ContentType=\"application/vnd.openxmlformats-package.core-properties+xml\"/>\n");
xml.append(" <Override PartName=\"/docProps/app.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.extended-properties+xml\"/>\n");
xml.append(" <Override PartName=\"/docProps/custom.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.custom-properties+xml\"/>\n");
xml.append(" <Override PartName=\"/taxonomy/manifest.json\" ContentType=\"application/json\"/>\n");
xml.append(" <Override PartName=\"/taxonomy/mapping-profile.json\" ContentType=\"application/json\"/>\n");
xml.append(" <Override PartName=\"/visio/document.xml\" ")
.append("ContentType=\"application/vnd.ms-visio.drawing.main+xml\"/>\n");
xml.append(" <Override PartName=\"/visio/pages/pages.xml\" ")
.append("ContentType=\"application/vnd.ms-visio.pages+xml\"/>\n");
for (int index = 0; index < doc.getPages().size(); index++) {
xml.append(" <Override PartName=\"/visio/pages/page")
.append(index + 1)
.append(".xml\" ContentType=\"application/vnd.ms-visio.page+xml\"/>\n");
}
xml.append("</Types>");
return xml.toString();
}
private String buildRootRelationships() {
return """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.microsoft.com/visio/2010/relationships/document" Target="visio/document.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties" Target="docProps/custom.xml"/>
<Relationship Id="rId5" Type="https://github.com/carstenartur/Taxonomy/relationships/export-manifest" Target="taxonomy/manifest.json"/>
<Relationship Id="rId6" Type="https://github.com/carstenartur/Taxonomy/relationships/mapping-profile" Target="taxonomy/mapping-profile.json"/>
</Relationships>""";
}
private String buildCoreProperties(VisioDocument doc) {
String title = doc.getPages().stream()
.map(VisioPage::getName)
.filter(name -> name != null && !name.isBlank())
.findFirst()
.orElse("Taxonomy architecture");
return XML_DECLARATION
+ "<cp:coreProperties"
+ " xmlns:cp=\"http://schemas.openxmlformats.org/package/2006/metadata/core-properties\""
+ " xmlns:dc=\"http://purl.org/dc/elements/1.1/\">\n"
+ " <dc:title>" + escapeXml(title) + "</dc:title>\n"
+ " <dc:creator>Taxonomy Architecture Analyzer</dc:creator>\n"
+ " <dc:description>Experimental visual architecture projection; Microsoft Visio compatibility is not certified.</dc:description>\n"
+ "</cp:coreProperties>";
}
private String buildExtendedProperties(VisioDocument doc) {
return XML_DECLARATION + "<Properties xmlns=\"http://schemas.openxmlformats.org/officeDocument/2006/extended-properties\">"
+ "<Application>Taxonomy Architecture Analyzer</Application><Pages>" + doc.getPages().size() + "</Pages></Properties>";
}
private String buildCustomProperties(VisioDocument doc) {
var properties = new java.util.TreeMap<>(doc.getProperties());
properties.put("taxonomy.exportProfile", com.taxonomy.visio.VisioProperty.text(VisioHandoffProfile.ID));
properties.put("taxonomy.profileSha256", com.taxonomy.visio.VisioProperty.text(VisioHandoffProfile.sha256(
VisioHandoffProfile.profileJson().getBytes(StandardCharsets.UTF_8))));
VisioPackageValidator.validateProperties(properties);
StringBuilder xml = new StringBuilder(XML_DECLARATION).append("<Properties xmlns=\"http://schemas.openxmlformats.org/officeDocument/2006/custom-properties\" xmlns:vt=\"http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes\">");
int pid = 2;
for (var entry : properties.entrySet()) {
String type = switch (entry.getValue().kind()) { case STRING -> "lpwstr"; case NUMBER -> "r8"; case BOOLEAN -> "bool"; };
xml.append("<property fmtid=\"{D5CDD505-2E9C-101B-9397-08002B2CF9AE}\" pid=\"").append(pid++)
.append("\" name=\"").append(escapeXml(entry.getKey())).append("\"><vt:").append(type).append(">")
.append(escapeXml(entry.getValue().value())).append("</vt:").append(type).append("></property>");
}
return xml.append("</Properties>").toString();
}
private String buildDocumentXml(VisioDocument doc) {
XStream xstream = new XStream(new XppDriver());
xstream.setMode(XStream.NO_REFERENCES);
xstream.alias("VisioDocument", VisioDocument.class);
xstream.registerConverter(new VisioDocumentConverter());
return XML_DECLARATION + xstream.toXML(doc);
}
private String buildDocumentRelationships() {
return """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.microsoft.com/visio/2010/relationships/pages" Target="pages/pages.xml"/>
</Relationships>""";
}
private String buildPagesXml(VisioDocument doc) {
XStream xstream = new XStream(new XppDriver());
xstream.setMode(XStream.NO_REFERENCES);
xstream.alias("Pages", VisioDocument.class);
xstream.registerConverter(new VisioPagesConverter());
return XML_DECLARATION + xstream.toXML(doc);
}
private String buildPagesRelationships(VisioDocument doc) {
StringBuilder xml = new StringBuilder(XML_DECLARATION);
xml.append("<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n");
for (int index = 0; index < doc.getPages().size(); index++) {
xml.append(" <Relationship Id=\"rId")
.append(index + 1)
.append("\" Type=\"http://schemas.microsoft.com/visio/2010/relationships/page\"")
.append(" Target=\"page")
.append(index + 1)
.append(".xml\"/>\n");
}
xml.append("</Relationships>");
return xml.toString();
}
private String buildPageXml(VisioPage page) {
XStream xstream = new XStream(new XppDriver());
xstream.setMode(XStream.NO_REFERENCES);
xstream.alias("PageContents", VisioPage.class);
xstream.registerConverter(new VisioPageContentsConverter());
return XML_DECLARATION + xstream.toXML(page);
}
private static String escapeXml(String value) {
return value.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
}