feat: F4.8c the documentation mode's image export, as Java's

ExportRun measures Java's print panel (GUIContainer.prepareExportPanel) and
image on four projects and an empty one: 10 to spare around, a light grey
footer 20 below the lowest element, 20 below it, a transparent background,
the documentation painted as in the design. The sidebar gains Java's
"Exportieren": the surface painted with Canvas 2D (an SVG with a
foreignObject taints the canvas in WebKit) and saved under the project's
name; Java's message when saving fails. Not adopted: Java's empty image of
an empty project (its footer overflows). The editor takes a projectName;
the capture export shares the download helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 11:54:09 +00:00
co-authored by Claude Opus 5.5
parent de03f6bb96
commit 7e68414bb2
20 changed files with 1803 additions and 25 deletions
+214
View File
@@ -0,0 +1,214 @@
// FILIUS' „Exportieren“ in the documentation mode measured
// (GUIContainer.createNetworkImage, GUIPrintPanel): each project opened as
// the menu opens it, then the print panel Java paints — the bounds of every
// device, cable and documentation element in it, the clip bounds of its
// network, documentation and footer panels, the footer's text, place and
// size, the rectangle cut out of the panel — and the image itself: its size,
// whether its background is transparent, the colours of its footer, the fill
// of a rectangle in it. An empty project too (no element at all). The
// reference for the editor's image export (packages/editor/test/networkImage.test.ts).
//
// Usage: java -cp filius.jar:lib/*:classes ExportRun <out.json> <png folder> <file.fls>...
import filius.gui.GUIContainer;
import filius.gui.netzwerksicht.GUIDocuItem;
import filius.gui.netzwerksicht.GUIKabelItem;
import filius.gui.netzwerksicht.GUIKnotenItem;
import filius.gui.netzwerksicht.GUIMainArea;
import filius.gui.netzwerksicht.GUIPrintPanel;
import filius.rahmenprogramm.Information;
import filius.rahmenprogramm.SzenarioVerwaltung;
import java.awt.Component;
import java.awt.FontMetrics;
import java.awt.Rectangle;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileOutputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import javax.imageio.ImageIO;
import javax.swing.JLabel;
import javax.swing.SwingUtilities;
public class ExportRun {
static Object get(Object owner, Class<?> type, String name) throws Exception {
Field field = type.getDeclaredField(name);
field.setAccessible(true);
return field.get(owner);
}
static List<Object> bounds(Component component) {
Rectangle rect = component.getBounds();
return List.of(rect.x, rect.y, rect.width, rect.height);
}
static Map<String, Object> area(GUIMainArea panel) throws Exception {
Map<String, Object> shown = new LinkedHashMap<>();
for (String name : new String[] { "minX", "maxX", "minY", "maxY" }) {
shown.put(name, get(panel, GUIMainArea.class, name));
}
return shown;
}
static String rgba(int argb) {
return ((argb >> 16) & 255) + "," + ((argb >> 8) & 255) + "," + (argb & 255) + "," + ((argb >>> 24) & 255);
}
/** The colours inside a rectangle of the image (in image coordinates), most frequent first, at most five. */
static List<Object> colours(BufferedImage image, int x, int y, int width, int height) {
Map<String, Integer> counts = new TreeMap<>();
for (int row = Math.max(0, y); row < Math.min(image.getHeight(), y + height); row++) {
for (int column = Math.max(0, x); column < Math.min(image.getWidth(), x + width); column++) {
counts.merge(rgba(image.getRGB(column, row)), 1, Integer::sum);
}
}
List<Map.Entry<String, Integer>> sorted = new ArrayList<>(counts.entrySet());
sorted.sort((lhs, rhs) -> rhs.getValue() - lhs.getValue());
List<Object> shown = new ArrayList<>();
for (Map.Entry<String, Integer> entry : sorted.subList(0, Math.min(5, sorted.size()))) {
shown.add(List.of(entry.getKey(), entry.getValue()));
}
return shown;
}
static Map<String, Object> measure(String name, File pngFolder) throws Exception {
Map<String, Object> result = new LinkedHashMap<>();
GUIContainer container = GUIContainer.getGUIContainer();
List<Object> nodes = new ArrayList<>();
for (GUIKnotenItem item : container.getKnotenItems()) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("name", item.getKnoten().holeAnzeigeName());
node.put("bounds", bounds(item.getImageLabel()));
nodes.add(node);
}
result.put("nodes", nodes);
List<Object> cables = new ArrayList<>();
for (GUIKabelItem item : container.getCableItems()) cables.add(bounds(item.getKabelpanel()));
result.put("cables", cables);
List<Object> docs = new ArrayList<>();
for (GUIDocuItem item : container.getDocuItems()) {
Map<String, Object> doc = new LinkedHashMap<>();
doc.put("type", item.getType());
doc.put("bounds", bounds(item.asDocuElement()));
doc.put("color", item.getColor() == null ? null : rgba(item.getColor().getRGB()));
docs.add(doc);
}
result.put("docs", docs);
result.put("path", SzenarioVerwaltung.getInstance().holePfad() == null ? null : name);
Method prepare = GUIContainer.class.getDeclaredMethod("prepareExportPanel");
prepare.setAccessible(true);
GUIPrintPanel panel = (GUIPrintPanel) prepare.invoke(container);
result.put("panelSize", List.of(panel.getWidth(), panel.getHeight()));
result.put("network", area((GUIMainArea) get(panel, GUIPrintPanel.class, "networkPanel")));
result.put("documentation", area((GUIMainArea) get(panel, GUIPrintPanel.class, "docuPanel")));
GUIMainArea footer = (GUIMainArea) get(panel, GUIPrintPanel.class, "footer");
result.put("footerArea", area(footer));
JLabel label = (JLabel) get(footer, footer.getClass(), "footer");
Map<String, Object> footerLabel = new LinkedHashMap<>();
FontMetrics metrics = label.getFontMetrics(label.getFont());
footerLabel.put("text", label.getText().equals(SzenarioVerwaltung.getInstance().holePfad()) ? "<path>" : label.getText());
footerLabel.put("bounds", bounds(label));
footerLabel.put("stringWidth", metrics.stringWidth(label.getText()));
footerLabel.put("fontHeight", metrics.getHeight());
footerLabel.put("foreground", rgba(label.getForeground().getRGB()));
result.put("footer", footerLabel);
try {
int clipX = panel.getClipX(), clipY = panel.getClipY();
result.put("clip", List.of(clipX, clipY, panel.getClipWidth(), panel.getClipHeight()));
container.updateViewport();
BufferedImage image = container.createNetworkImage();
result.put("imageSize", List.of(image.getWidth(), image.getHeight()));
result.put("cornerPixel", rgba(image.getRGB(0, 0)));
// The footer in image coordinates: the panel's own minus the clip's origin.
Rectangle at = label.getBounds();
result.put("footerColours", colours(image, at.x - clipX, at.y - clipY, at.width, at.height));
for (GUIDocuItem item : container.getDocuItems()) {
if (item.getType() != GUIDocuItem.RECT) continue;
Rectangle rect = item.asDocuElement().getBounds();
// Inside the panel the elements lie 10 to the right and down (EMPTY_BORDER).
result.put("rectangleCentre", colours(image, rect.x + rect.width / 2 + 10 - clipX, rect.y + 4 + 10 - clipY, 1, 1));
result.put("rectangleCentreColor", rgba(item.getColor().getRGB()));
break;
}
ImageIO.write(image, "png", new File(pngFolder, name.replace(".fls", "") + ".png"));
} catch (RuntimeException error) {
result.put("error", error.getClass().getSimpleName());
}
container.updateViewport();
return result;
}
public static void main(String[] args) throws Exception {
// FILIUS' window keeps the JVM alive: end it on any failure too.
try {
run(args);
} catch (Throwable error) {
error.printStackTrace();
System.exit(1);
}
System.exit(0);
}
static void run(String[] args) throws Exception {
GUIContainer.getGUIContainer();
File pngFolder = new File(args[1]);
pngFolder.mkdirs();
Map<String, Object> output = new LinkedHashMap<>();
Map<String, Object> projects = new LinkedHashMap<>();
for (int index = 2; index < args.length; index++) {
String file = args[index];
String name = Path.of(file).getFileName().toString();
Object[] measured = new Object[1];
Exception[] failed = new Exception[1];
SwingUtilities.invokeAndWait(() -> {
try {
// As GUIMainMenu opens a file.
Information.getInformation().reset();
SzenarioVerwaltung.getInstance().laden(file, GUIContainer.getGUIContainer().getKnotenItems(),
GUIContainer.getGUIContainer().getCableItems(), GUIContainer.getGUIContainer().getDocuItems());
GUIContainer.getGUIContainer().setProperty(null);
GUIContainer.getGUIContainer().updateViewport();
GUIContainer.getGUIContainer().updateCables();
measured[0] = measure(name, pngFolder);
} catch (Exception error) {
failed[0] = error;
}
});
if (failed[0] != null) throw failed[0];
projects.put(name, measured[0]);
}
output.put("projects", projects);
// An empty project: nothing on the surface, no file.
Object[] empty = new Object[1];
Exception[] failed = new Exception[1];
SwingUtilities.invokeAndWait(() -> {
try {
GUIContainer container = GUIContainer.getGUIContainer();
container.getKnotenItems().clear();
container.getCableItems().clear();
container.getDocuItems().clear();
SzenarioVerwaltung.getInstance().reset();
container.updateViewport();
empty[0] = measure("empty.fls", pngFolder);
} catch (Exception error) {
failed[0] = error;
}
});
if (failed[0] != null) throw failed[0];
output.put("empty", empty[0]);
try (FileOutputStream out = new FileOutputStream(args[0])) {
out.write((FlsOracle.Json.write(output, "") + "\n").getBytes(StandardCharsets.UTF_8));
}
}
}