Files
NeitzelFXDesigner/designer-kit-ui/src/main/java/de/neitzel/fxdesigner/kit/FxmlProjection.java
T

205 lines
7.8 KiB
Java

package de.neitzel.fxdesigner.kit;
import java.io.*;
import java.util.*;
import javax.xml.parsers.*;
import javax.xml.transform.*;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import org.w3c.dom.*;
/**
* Bounded scalar/layout projection. Original XML owns bindings, controllers and unknown metadata.
*/
public final class FxmlProjection {
private static final String FX = "http://javafx.com/fxml/1";
private static final Set<String> EDITABLE =
Set.of(
"caption",
"text",
"prefWidth",
"prefHeight",
"minWidth",
"minHeight",
"maxWidth",
"maxHeight",
"layoutX",
"layoutY",
"visible",
"managed",
"disable",
"opacity",
"alignment",
"spacing",
"hgap",
"vgap",
"GridPane.columnIndex",
"GridPane.rowIndex",
"GridPane.hgrow",
"GridPane.vgrow");
private final Document original;
private Document baseline;
private final Map<String, String> components = new LinkedHashMap<>();
public FxmlProjection(String text) throws Exception {
original = parse(text);
}
static Document parse(String text) throws Exception {
var f = DocumentBuilderFactory.newInstance();
f.setNamespaceAware(true);
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
f.setXIncludeAware(false);
f.setExpandEntityReferences(false);
return f.newDocumentBuilder().parse(new org.xml.sax.InputSource(new StringReader(text)));
}
static String xml(Document d) throws Exception {
var t = TransformerFactory.newInstance().newTransformer();
var w = new StringWriter();
t.transform(new DOMSource(d), new StreamResult(w));
return w.toString();
}
public Map<String, String> components() {
return Map.copyOf(components);
}
public String project() throws Exception {
var d = (Document) original.cloneNode(true);
var imports = new HashMap<String, String>();
var remove = new ArrayList<Node>();
for (Node n = d.getFirstChild(); n != null; n = n.getNextSibling())
if (n instanceof ProcessingInstruction pi && pi.getTarget().equals("import")) {
String type = pi.getData().trim();
if (!type.startsWith("javafx.")) {
if (type.endsWith(".*"))
throw new IOException("Application wildcard imports need an explicit Component import");
imports.put(type.substring(type.lastIndexOf('.') + 1), type);
remove.add(n);
}
}
remove.forEach(d::removeChild);
d.insertBefore(
d.createProcessingInstruction("import", SnapshotRegion.class.getName()),
d.getDocumentElement());
var definitions = d.getElementsByTagNameNS(FX, "define");
while (definitions.getLength() > 0) {
Node n = definitions.item(0);
n.getParentNode().removeChild(n);
}
var nodes = d.getElementsByTagName("*");
for (int i = 0; i < nodes.getLength(); i++) {
var e = (Element) nodes.item(i);
String tag = e.getTagName();
if (FX.equals(e.getNamespaceURI()))
throw new IOException("This minimal projection does not support " + tag);
if (imports.containsKey(tag)) {
String id = e.getAttributeNS(FX, "id");
if (id.isBlank()) throw new IOException("Component projection requires fx:id");
components.put(id, imports.get(tag));
if (e.hasChildNodes())
throw new IOException("Inline Component content is outside this projection");
d.renameNode(e, null, "SnapshotRegion");
var attrs = e.getAttributes();
for (int a = attrs.getLength() - 1; a >= 0; a--) {
var attr = (Attr) attrs.item(a);
if (!EDITABLE.contains(attr.getName()) && !attr.getName().equals("fx:id"))
e.removeAttributeNode(attr);
}
} else if (Character.isUpperCase(tag.charAt(0))
&& tag.contains(".")
&& !tag.startsWith("javafx."))
throw new IOException("Unsupported application element: " + tag);
e.removeAttributeNS(FX, "controller");
var attrs = e.getAttributes();
for (int a = attrs.getLength() - 1; a >= 0; a--) {
var attr = (Attr) attrs.item(a);
if (attr.getValue().startsWith("#")
|| attr.getValue().startsWith("$")
|| attr.getName().startsWith("on")) e.removeAttributeNode(attr);
}
}
baseline = (Document) d.cloneNode(true);
return xml(d);
}
private static Map<String, Element> indexed(Document d) throws IOException {
var result = new LinkedHashMap<String, Element>();
result.put("@root", d.getDocumentElement());
var nodes = d.getElementsByTagName("*");
for (int i = 0; i < nodes.getLength(); i++) {
var e = (Element) nodes.item(i);
String id = e.getAttributeNS(FX, "id");
if (!id.isBlank() && result.putIfAbsent(id, e) != null)
throw new IOException("Duplicate fx:id");
}
return result;
}
private static java.util.List<String> structure(Document document) {
var result = new java.util.ArrayList<String>();
var objects = new IdentityHashMap<Node, Integer>();
var nodes = document.getElementsByTagName("*");
for (int i = 0; i < nodes.getLength(); i++) {
var e = (Element) nodes.item(i);
String tag = e.getTagName();
if (Character.isUpperCase(tag.charAt(0))) {
Node parent = e.getParentNode();
while (parent != null && !objects.containsKey(parent)) parent = parent.getParentNode();
var fixed = new TreeMap<String, String>();
if (e != document.getDocumentElement() && e.getAttributeNS(FX, "id").isBlank())
for (int a = 0; a < e.getAttributes().getLength(); a++) {
var attr = e.getAttributes().item(a);
if (!attr.getNodeName().startsWith("xmlns"))
fixed.put(attr.getNodeName(), attr.getNodeValue());
}
result.add(
tag
+ ":"
+ e.getAttributeNS(FX, "id")
+ ":"
+ objects.getOrDefault(parent, -1)
+ ":"
+ fixed);
objects.put(e, objects.size());
}
}
return result;
}
public String restore(String projected) throws Exception {
var changed = parse(projected);
var before = indexed(baseline);
var after = indexed(changed);
var output = (Document) original.cloneNode(true);
var targets = indexed(output);
if (!before.keySet().equals(after.keySet()) || !structure(baseline).equals(structure(changed)))
throw new IOException("Structural editing is outside this minimal projection");
for (String id : before.keySet()) {
var attributes = new HashSet<String>();
for (Element element : List.of(before.get(id), after.get(id)))
for (int i = 0; i < element.getAttributes().getLength(); i++)
attributes.add(element.getAttributes().item(i).getNodeName());
for (String attr : attributes)
if (!EDITABLE.contains(attr)
&& !attr.startsWith("xmlns")
&& !before.get(id).getAttribute(attr).equals(after.get(id).getAttribute(attr)))
throw new IOException("Edit " + attr + " in source; this projection does not support it");
}
for (String id : before.keySet())
for (String attr : EDITABLE) {
String old = before.get(id).getAttribute(attr), value = after.get(id).getAttribute(attr);
if (!old.equals(value)) {
var target = targets.get(id);
String originalValue = target.getAttribute(attr);
if (originalValue.startsWith("$") || originalValue.startsWith("#"))
throw new IOException("Bound attributes must be edited in source");
if (value.isEmpty()) target.removeAttribute(attr);
else target.setAttribute(attr, value);
}
}
return xml(output);
}
}