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 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 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 components() { return Map.copyOf(components); } public String project() throws Exception { var d = (Document) original.cloneNode(true); var imports = new HashMap(); var remove = new ArrayList(); 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 indexed(Document d) throws IOException { var result = new LinkedHashMap(); 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 structure(Document document) { var result = new java.util.ArrayList(); var objects = new IdentityHashMap(); 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(); 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(); 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); } }