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
+32
View File
@@ -1699,3 +1699,35 @@ Gemessen (04.10.2026):
Falle der Sonde: Der Menüpunkt „Normal“ steht in beiden Untermenüs; die
Sonde nimmt den ersten (Schriftgröße).
### Dokumentation: „Exportieren“ (F4.8c)
`ExportRun` öffnet ein Projekt, wie Javas Menü es öffnet
(`Information.reset`, `SzenarioVerwaltung.laden`, `updateViewport`,
`updateCables`), holt per Reflection das Druckpanel
(`GUIContainer.prepareExportPanel`) und liest dessen Ausschnitt
(`getClipX` … `getClipHeight`), die Grenzen seiner drei Ebenen (Netz,
Dokumentation, Fußzeile; `GUIMainArea.minX` …) und die Fußzeile. Dann malt es
mit `createNetworkImage` das Bild und misst Größe, Eckpixel, die Farben der
Fußzeile und die Füllung einer Fläche darin. Vier Projekte
(`nat_with_mapped_port`, `zwei_rechnernetze_komplett`, die Fixture
`documentation.fls`, `modem_beispiel`) und ein leeres
(`packages/editor/test/networkImage.java.json`; Javas Bilder unter
`.build/export`).
Gemessen (04.10.2026):
* Ausschnitt: alle Geräte, Kabel (die beiden Mitten mit 2 Rand) und
Dokumentationselemente, links, oben und rechts 10 Rand. Die Fußzeile steht
20 unter dem untersten Element, bündig mit dem linkesten; sie ist so breit
wie ihr Text und 20 mehr, so hoch wie ihre Schrift; unter ihr 20 Rand. Ist
die Fußzeile breiter als der Inhalt, reicht das Bild 20 über sie hinaus.
* Fußzeile: der Pfad der Datei, ohne Datei „Filius“, hellgrau (192).
* Hintergrund durchsichtig; Dokumentation unter dem Netz, gemalt wie im
Entwurf (Fläche 204 → 215, 201 → 212); nichts ausgewählt.
* Leeres Projekt: ein leeres Bild 20 × 35. Die Fußzeile landet durch einen
Überlauf (`Integer.MAX_VALUE + 10`) außerhalb. Nicht übernommen.
Falle der Sonde: Das Druckpanel nimmt Javas Geräte-Etiketten aus der
Oberfläche (eine Swing-Komponente hat nur einen Elternteil); erst
`updateViewport` legt sie zurück.
+12 -1
View File
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
fi
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
mkdir -p "$here/.build"
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java" "$here/src/MailWindowRun.java" "$here/src/SaveAfterRunRun.java" "$here/src/TextEditorWindowRun.java" "$here/src/FileExplorerWindowRun.java" "$here/src/EchoWindowRun.java" "$here/src/DnsWindowRun.java" "$here/src/FirewallWindowRun.java" "$here/src/GnutellaWindowRun.java" "$here/src/ViewersRun.java" "$here/src/ExchangeWindowRun.java" "$here/src/DocuRun.java"
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java" "$here/src/MailWindowRun.java" "$here/src/SaveAfterRunRun.java" "$here/src/TextEditorWindowRun.java" "$here/src/FileExplorerWindowRun.java" "$here/src/EchoWindowRun.java" "$here/src/DnsWindowRun.java" "$here/src/FirewallWindowRun.java" "$here/src/GnutellaWindowRun.java" "$here/src/ViewersRun.java" "$here/src/ExchangeWindowRun.java" "$here/src/DocuRun.java" "$here/src/ExportRun.java"
# FILIUS writes its settings below $HOME; keep them out of the real one.
mkdir -p "$here/.build/home"
# Removed first: a crashed run must not leave the previous result standing.
@@ -499,6 +499,17 @@ HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -
"$output" "$root/packages/doc/test/fixtures/documentation.fls" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || true
test -s "$output"
# FILIUS' „Exportieren“ (ExportRun): the print panel and the image Java makes
# of four projects and an empty one — the part cut out, the footer, the
# colours (packages/editor/test/networkImage.test.ts). Java's images go to
# .build/export, to look at.
output="$root/packages/editor/test/networkImage.java.json"
rm -f "$output"
HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" ExportRun \
"$output" "$here/.build/export" "$upstream/beispiele/nat_with_mapped_port.fls" "$upstream/beispiele/zwei_rechnernetze_komplett.fls" \
"$root/packages/doc/test/fixtures/documentation.fls" "$upstream/beispiele/modem_beispiel.fls" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || true
test -s "$output"
# FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS
# does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts)
# and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts).
+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));
}
}
}