205 lines
7.8 KiB
Java
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);
|
|
}
|
|
}
|