VisioPageContentsConverter.java

package com.taxonomy.visio.converter;

import com.taxonomy.visio.VisioConnect;
import com.taxonomy.visio.VisioPage;
import com.taxonomy.visio.VisioShape;
import com.taxonomy.visio.VisioProperty;
import com.thoughtworks.xstream.converters.Converter;
import com.thoughtworks.xstream.converters.MarshallingContext;
import com.thoughtworks.xstream.converters.UnmarshallingContext;
import com.thoughtworks.xstream.io.HierarchicalStreamReader;
import com.thoughtworks.xstream.io.HierarchicalStreamWriter;

import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * Marshals one masterless page using the Visio 2012 {@code PageContents}
 * structure. Regular nodes carry explicit rectangle geometry; relationship
 * connectors carry explicit one-dimensional line geometry and are glued through
 * the page's {@code Connects} collection.
 */
public class VisioPageContentsConverter implements Converter {

    private static final String VISIO_NS =
            "http://schemas.microsoft.com/office/visio/2012/main";
    private static final String REL_NS =
            "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
    private static final String ANCHOR_FILL = "#4CAF50";
    private static final String DEFAULT_FILL = "#2196F3";
    private static final String LINE_COLOR = "#44546A";

    @Override
    public boolean canConvert(Class type) {
        return VisioPage.class.equals(type);
    }

    @Override
    public void marshal(
            Object source,
            HierarchicalStreamWriter writer,
            MarshallingContext context) {
        VisioPage page = (VisioPage) source;
        Map<String, VisioShape> shapesById = indexShapes(page);

        writer.addAttribute("xmlns", VISIO_NS);
        writer.addAttribute("xmlns:r", REL_NS);

        if (!page.getShapes().isEmpty() || !page.getConnects().isEmpty()) {
            writer.startNode("Shapes");
            for (VisioShape shape : page.getShapes()) {
                writeRegularShape(writer, shape);
            }

            long connectorId = maximumShapeId(page);
            for (VisioConnect connect : page.getConnects()) {
                connectorId++;
                writeConnectorShape(
                        writer,
                        connectorId,
                        connect,
                        requireShape(shapesById, connect.getFromShape(), "source"),
                        requireShape(shapesById, connect.getToShape(), "target"));
            }
            writer.endNode();
        }

        if (!page.getConnects().isEmpty()) {
            writer.startNode("Connects");
            long connectorId = maximumShapeId(page);
            for (VisioConnect connect : page.getConnects()) {
                connectorId++;
                // Dynamic glue from a routable 1-D endpoint to a 2-D shape pin is
                // represented by one BeginX/EndX Connect row per endpoint. Visio
                // couples the corresponding Y coordinate; explicit BeginY/EndY
                // rows are reserved for other glue targets such as guides.
                writeGlue(writer, connectorId, "BeginX", connect.getFromShape());
                writeGlue(writer, connectorId, "EndX", connect.getToShape());
            }
            writer.endNode();
        }
    }

    @Override
    public Object unmarshal(
            HierarchicalStreamReader reader,
            UnmarshallingContext context) {
        throw new UnsupportedOperationException(
                "VisioPageContentsConverter does not support unmarshaling");
    }

    private static void writeRegularShape(
            HierarchicalStreamWriter writer,
            VisioShape shape) {
        writer.startNode("Shape");
        writer.addAttribute("ID", shape.getId());
        writer.addAttribute("NameU", "TaxonomyShape." + shape.getId());
        writer.addAttribute("IsCustomNameU", "1");
        writer.addAttribute("Name", shape.getText());
        writer.addAttribute("IsCustomName", "1");
        writer.addAttribute("Type", "Shape");
        writeStyleReferences(writer);

        writeCell(writer, "PinX", decimal(shape.getX()));
        writeCell(writer, "PinY", decimal(shape.getY()));
        writeCell(writer, "Width", decimal(shape.getWidth()));
        writeCell(writer, "Height", decimal(shape.getHeight()));
        writeCell(writer, "LocPinX", decimal(shape.getWidth() / 2.0), "Width*0.5");
        writeCell(writer, "LocPinY", decimal(shape.getHeight() / 2.0), "Height*0.5");
        writeCell(writer, "Angle", "0");
        writeCell(writer, "FillForegnd", shape.isAnchor() ? ANCHOR_FILL : DEFAULT_FILL);
        writeCell(writer, "FillPattern", "1");
        writeCell(writer, "LineColor", LINE_COLOR);
        writeCell(writer, "LinePattern", "1");
        writeCell(writer, "LineWeight", "0.01388888888888889");
        writeCell(writer, "VerticalAlign", "1");
        writeRectangleGeometry(writer, shape.getWidth(), shape.getHeight());
        writeProperties(writer, shape.getProperties());

        writer.startNode("Text");
        writer.setValue(shape.getText());
        writer.endNode();
        writer.endNode();
    }

    private static void writeConnectorShape(
            HierarchicalStreamWriter writer,
            long connectorId,
            VisioConnect connect,
            VisioShape source,
            VisioShape target) {
        double beginX = source.getX();
        double beginY = source.getY();
        double endX = target.getX();
        double endY = target.getY();
        double width = Math.hypot(endX - beginX, endY - beginY);
        double angle = Math.atan2(endY - beginY, endX - beginX);

        writer.startNode("Shape");
        writer.addAttribute("ID", String.valueOf(connectorId));
        writer.addAttribute("NameU", "TaxonomyConnector." + connectorId);
        writer.addAttribute("IsCustomNameU", "1");
        writer.addAttribute("Type", "Shape");
        writeStyleReferences(writer);

        writeCell(writer, "OneD", "1");
        // A masterless custom connector is not automatically recognised as the
        // built-in Dynamic Connector. Mark it explicitly routable and allow
        // walking/dynamic glue before connecting BeginX/EndX to 2-D shape pins.
        writeCell(writer, "ObjType", "2");
        writeCell(writer, "GlueType", "2");

        // Persist meaningful calculated values as well as formulas. Consumers
        // that do not immediately recalculate the ShapeSheet still render the
        // connector between the selected source and target positions.
        writeCell(writer, "BeginX", decimal(beginX));
        writeCell(writer, "BeginY", decimal(beginY));
        writeCell(writer, "EndX", decimal(endX));
        writeCell(writer, "EndY", decimal(endY));

        // Use Visio's normal rotating 1-D transform. The local geometry is a
        // horizontal line of Width; the endpoint-derived Pin/Width/Angle cells
        // position and rotate it when glue moves either endpoint.
        writeCell(writer, "PinX", decimal((beginX + endX) / 2.0),
                "(BeginX+EndX)*0.5");
        writeCell(writer, "PinY", decimal((beginY + endY) / 2.0),
                "(BeginY+EndY)*0.5");
        writeCell(writer, "Width", decimal(width),
                "SQRT((EndX-BeginX)^2+(EndY-BeginY)^2)");
        writeCell(writer, "Height", "0");
        writeCell(writer, "LocPinX", decimal(width / 2.0), "Width*0.5");
        writeCell(writer, "LocPinY", "0");
        writeCell(writer, "Angle", decimal(angle),
                "ATAN2(EndY-BeginY,EndX-BeginX)");
        writeCell(writer, "LineColor", LINE_COLOR);
        writeCell(writer, "LinePattern", "1");
        writeCell(writer, "LineWeight", "0.01388888888888889");
        writeCell(writer, "EndArrow", "13");
        writeCell(writer, "EndArrowSize", "2");
        writeLineGeometry(writer, width);
        writeProperties(writer, connect.getProperties());

        writer.startNode("Text");
        writer.setValue(connect.getRelationType());
        writer.endNode();
        writer.endNode();
    }

    private static void writeGlue(
            HierarchicalStreamWriter writer,
            long connectorId,
            String fromCell,
            String endpointShapeId) {
        writer.startNode("Connect");
        writer.addAttribute("FromSheet", String.valueOf(connectorId));
        writer.addAttribute("FromCell", fromCell);
        writer.addAttribute("ToSheet", endpointShapeId);
        writer.addAttribute("ToCell", "PinX");
        writer.endNode();
    }

    private static void writeStyleReferences(HierarchicalStreamWriter writer) {
        writer.addAttribute("LineStyle", "0");
        writer.addAttribute("FillStyle", "0");
        writer.addAttribute("TextStyle", "0");
    }

    private static void writeProperties(HierarchicalStreamWriter writer, Map<String, VisioProperty> properties) {
        if (properties.isEmpty()) return;
        writer.startNode("Section");
        writer.addAttribute("N", "Property");
        for (var entry : new java.util.TreeMap<>(properties).entrySet()) {
            writer.startNode("Row");
            // Hex encoding is injective and uses only portable ShapeSheet row-name characters.
            writer.addAttribute("N", "t" + java.util.HexFormat.of().formatHex(entry.getKey().getBytes(java.nio.charset.StandardCharsets.UTF_8)));
            writeCell(writer, "Label", entry.getKey());
            writeCell(writer, "Type", entry.getValue().shapeType());
            writer.startNode("Cell");
            writer.addAttribute("N", "Value");
            writer.addAttribute("V", entry.getValue().shapeValue());
            if (entry.getValue().kind() == VisioProperty.Kind.STRING) writer.addAttribute("U", "STR");
            writer.endNode();
            writer.endNode();
        }
        writer.endNode();
    }

    private static void writeRectangleGeometry(
            HierarchicalStreamWriter writer,
            double width,
            double height) {
        writer.startNode("Section");
        writer.addAttribute("N", "Geometry");
        writer.addAttribute("IX", "0");
        writeGeometryRow(writer, "MoveTo", 1, 0, 0, null, null);
        writeGeometryRow(writer, "LineTo", 2, width, 0, "Width", null);
        writeGeometryRow(writer, "LineTo", 3, width, height, "Width", "Height");
        writeGeometryRow(writer, "LineTo", 4, 0, height, null, "Height");
        writeGeometryRow(writer, "LineTo", 5, 0, 0, null, null);
        writer.endNode();
    }

    private static void writeLineGeometry(
            HierarchicalStreamWriter writer,
            double width) {
        writer.startNode("Section");
        writer.addAttribute("N", "Geometry");
        writer.addAttribute("IX", "0");
        writeGeometryRow(writer, "MoveTo", 1, 0, 0, null, null);
        writeGeometryRow(writer, "LineTo", 2, width, 0, "Width", null);
        writer.endNode();
    }

    private static void writeGeometryRow(
            HierarchicalStreamWriter writer,
            String type,
            int index,
            double x,
            double y,
            String xFormula,
            String yFormula) {
        writer.startNode("Row");
        writer.addAttribute("T", type);
        writer.addAttribute("IX", String.valueOf(index));
        writeCell(writer, "X", decimal(x), xFormula);
        writeCell(writer, "Y", decimal(y), yFormula);
        writer.endNode();
    }

    private static void writeCell(
            HierarchicalStreamWriter writer,
            String name,
            String value) {
        writeCell(writer, name, value, null);
    }

    private static void writeCell(
            HierarchicalStreamWriter writer,
            String name,
            String value,
            String formula) {
        writer.startNode("Cell");
        writer.addAttribute("N", name);
        writer.addAttribute("V", value);
        if (formula != null) {
            writer.addAttribute("F", formula);
        }
        writer.endNode();
    }

    private static Map<String, VisioShape> indexShapes(VisioPage page) {
        Map<String, VisioShape> shapes = new LinkedHashMap<>();
        for (VisioShape shape : page.getShapes()) {
            shapes.put(shape.getId(), shape);
        }
        return shapes;
    }

    private static VisioShape requireShape(
            Map<String, VisioShape> shapes,
            String shapeId,
            String role) {
        VisioShape shape = shapes.get(shapeId);
        if (shape == null) {
            throw new IllegalArgumentException(
                    "Connector references missing " + role + " shape " + shapeId);
        }
        return shape;
    }

    private static long maximumShapeId(VisioPage page) {
        return page.getShapes().stream()
                .mapToLong(shape -> Long.parseLong(shape.getId()))
                .max()
                .orElse(0);
    }

    private static String decimal(double value) {
        return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString();
    }
}