feat: F4.8d the documentation mode's report as a PDF, as Java's
ReportRun reads Java's report back with iText: every piece of text with its place, font, size and colour. The editor lays the report out by iText's rules (A4, leading 1.5, loose chunks in the current line, tables 80 % wide with padding 2, fixed traffic columns, page breaks) and draws it with pdf-lib in the PDF's own Helvetica, widths without kerning as iText measures. All device pages of Internet_Komplett, the first page and the data exchange of einfaches_rechnernetz_komplett match Java piece by piece; Java's iText reads our PDF with the same text in the same places. The sidebar gains Java's "Report"; the frames come from the room by a short subscription. Not adopted: true/false for DHCP (Java's own Ja/Nein), hash table orders, an image running off the page, the report writing back into the model. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -237,6 +237,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
|||||||
| `src/simulation/CaptureWindow.tsx`, `src/simulation/captureCells.ts`, `src/simulation/useSimulation.ts` (`traces`, `clearCapture`), `src/Editor.tsx`, `src/desktop/desktop.css`, `src/theme/tokens.css` (`--capture-*`), Kataloge (`capture.*`, `menu.showCapture`) | Java `AggregatedExchangeDialog` (`lauscherdialog_msg1`), `AggregatedExchangePanel` (`buttontabcomponent_msg1`), `AggregatedMessageTable` (`nachrichtentabelle_msg7`–`9`), `LauscherTableCellRenderer`, `LayerColorVar`, `Lauscher` (`rp_lauscher_msg1`–`14`), `GUIEvents` (`guievents_msg4`), gemessen mit `ExchangeWindowRun` | neu (F4.7): im Menü eines Geräts je Karte „Datenaustausch anzeigen (Adresse)“; ein Fenster mit einem Reiter je Karte, die Zeilen in Javas Schichtenfarben (dunkle Varianten im dunklen Thema), die gewählte weiß auf schwarz, darunter ihre Details, „Tabellen löschen“, „Als Textdatei exportieren“ (nach dem Reiter benannt), „Automatisches Scrollen“; Wahl und Scrollen je Reiter, behalten, solange das Fenster zu ist; das Fenster geht mit dem Entwurf und kommt mit dem nächsten Start; der Reiter einer entfernten Karte verschwindet. Besser als Java: die drei sind Knöpfe und ein Kästchen statt eines Menüs der rechten Maustaste; ein Frame wird nach seiner Nummer gewählt (Javas Details nehmen den Zeilenindex dafür) |
|
| `src/simulation/CaptureWindow.tsx`, `src/simulation/captureCells.ts`, `src/simulation/useSimulation.ts` (`traces`, `clearCapture`), `src/Editor.tsx`, `src/desktop/desktop.css`, `src/theme/tokens.css` (`--capture-*`), Kataloge (`capture.*`, `menu.showCapture`) | Java `AggregatedExchangeDialog` (`lauscherdialog_msg1`), `AggregatedExchangePanel` (`buttontabcomponent_msg1`), `AggregatedMessageTable` (`nachrichtentabelle_msg7`–`9`), `LauscherTableCellRenderer`, `LayerColorVar`, `Lauscher` (`rp_lauscher_msg1`–`14`), `GUIEvents` (`guievents_msg4`), gemessen mit `ExchangeWindowRun` | neu (F4.7): im Menü eines Geräts je Karte „Datenaustausch anzeigen (Adresse)“; ein Fenster mit einem Reiter je Karte, die Zeilen in Javas Schichtenfarben (dunkle Varianten im dunklen Thema), die gewählte weiß auf schwarz, darunter ihre Details, „Tabellen löschen“, „Als Textdatei exportieren“ (nach dem Reiter benannt), „Automatisches Scrollen“; Wahl und Scrollen je Reiter, behalten, solange das Fenster zu ist; das Fenster geht mit dem Entwurf und kommt mit dem nächsten Start; der Reiter einer entfernten Karte verschwindet. Besser als Java: die drei sind Knöpfe und ein Kästchen statt eines Menüs der rechten Maustaste; ein Frame wird nach seiner Nummer gewählt (Javas Details nehmen den Zeilenindex dafür) |
|
||||||
| `src/design/DocumentationSidebar.tsx`, `src/design/DocumentationText.tsx`, `src/design/DocumentationMenu.tsx`, `src/canvas/documentationPaint.ts`, `src/canvas/Canvas.tsx` (Dokumentationsmodus), `src/simulation/ModeBar.tsx` (dritter Knopf), `src/Editor.tsx`, `src/assets/modes/dokumodus*.png`, `src/assets/documentation/*.png`, Kataloge (`mode.documentation`, `docu.*`) | Java `GUIMainMenu` (`btDokumodus`, `guimainmemu_msg14`, Reihenfolge der Knöpfe), `GUIDocumentationSidebar` (`docusidebar_msg1`, `3`), `GUIContainer` (Ablegen aus der Seitenleiste, Ebenen), `JDocuElement` (Fokus, Ziehen, Menü `guievents_msg13`–`21`, Zeichnen), `GUIDocumentationPanel` (Klick daneben), gemessen mit `DocuRun` | neu (F4.8b): der Dokumentationsmodus — Javas dritter Knopf vorn, er stoppt eine Simulation; die Seitenleiste „Text“/„Gliederungsfläche“ (gezogen landet ein Element mittig unter dem Zeiger, mit Fokus); die Elemente liegen über dem Netz und nehmen den Zeiger: Tippen gibt den Fokus, eine Fläche wird an ihrer Mitte gezogen, ein Text in seinem Fokusband, am Rand ändert sich die Größe nach Javas Regel; der Text wird in ein Feld über ihm getippt (auf `Y.Text`, Escape beendet, Strg+Entf löscht); das Menü mit Schriftgröße, Schriftstil, Javas Farben und Löschen; Entf löscht; Javas Zeichnung je Modus (halbtransparent im Dokumentationsmodus, sonst deckend aufgehellt, Rand, grauer Textrand nur im Dokumentationsmodus, Fokus blau). Besser als Java: Tippen auf die Seitenleiste legt ein Element in die Mitte der Ansicht (Java legt nichts ab); das Menü zeigt Größen, Stile und Farben auf einmal statt in Untermenüs; die Ränder lassen sich bei kleinem Zoom mit dem Finger greifen (mindestens 10 Bildschirmpunkte) |
|
| `src/design/DocumentationSidebar.tsx`, `src/design/DocumentationText.tsx`, `src/design/DocumentationMenu.tsx`, `src/canvas/documentationPaint.ts`, `src/canvas/Canvas.tsx` (Dokumentationsmodus), `src/simulation/ModeBar.tsx` (dritter Knopf), `src/Editor.tsx`, `src/assets/modes/dokumodus*.png`, `src/assets/documentation/*.png`, Kataloge (`mode.documentation`, `docu.*`) | Java `GUIMainMenu` (`btDokumodus`, `guimainmemu_msg14`, Reihenfolge der Knöpfe), `GUIDocumentationSidebar` (`docusidebar_msg1`, `3`), `GUIContainer` (Ablegen aus der Seitenleiste, Ebenen), `JDocuElement` (Fokus, Ziehen, Menü `guievents_msg13`–`21`, Zeichnen), `GUIDocumentationPanel` (Klick daneben), gemessen mit `DocuRun` | neu (F4.8b): der Dokumentationsmodus — Javas dritter Knopf vorn, er stoppt eine Simulation; die Seitenleiste „Text“/„Gliederungsfläche“ (gezogen landet ein Element mittig unter dem Zeiger, mit Fokus); die Elemente liegen über dem Netz und nehmen den Zeiger: Tippen gibt den Fokus, eine Fläche wird an ihrer Mitte gezogen, ein Text in seinem Fokusband, am Rand ändert sich die Größe nach Javas Regel; der Text wird in ein Feld über ihm getippt (auf `Y.Text`, Escape beendet, Strg+Entf löscht); das Menü mit Schriftgröße, Schriftstil, Javas Farben und Löschen; Entf löscht; Javas Zeichnung je Modus (halbtransparent im Dokumentationsmodus, sonst deckend aufgehellt, Rand, grauer Textrand nur im Dokumentationsmodus, Fokus blau). Besser als Java: Tippen auf die Seitenleiste legt ein Element in die Mitte der Ansicht (Java legt nichts ab); das Menü zeigt Größen, Stile und Farben auf einmal statt in Untermenüs; die Ränder lassen sich bei kleinem Zoom mit dem Finger greifen (mindestens 10 Bildschirmpunkte) |
|
||||||
| `src/canvas/networkImage.ts`, `src/design/exportImage.ts`, `src/design/DocumentationSidebar.tsx` („Exportieren“), `src/Editor.tsx` (`projectName`), `src/download.ts`, `src/canvas/layout.ts` (`cableFrame`, `cableControl`), `src/main.tsx` (Dateiname), `src/assets/documentation/download_small.png`, Kataloge (`docu.export`, `docu.exportHint`, `docu.saveFailed`) | Java `GUIDocumentationSidebar` (`docusidebar_msg5`, `6`), `GUIContainer.exportAsImage/createNetworkImage`, `GUIPrintPanel`, `GUIFooterPanel`, `GUIMainArea` (Grenzen), `JCablePanel` (Grenzen, Farben), `guimainmemu_msg11`, gemessen mit `ExportRun` | neu (F4.8c): „Exportieren“ in der Seitenleiste des Dokumentationsmodus speichert die Fläche als PNG wie Java — Dokumentation wie im Entwurf gemalt unter dem Netz, Kabel, Geräte, nichts gewählt, Hintergrund durchsichtig; Javas Ausschnitt (10 Rand, die hellgraue Fußzeile 20 unter dem untersten Element, 20 Rand darunter); Dateiname nach dem Projekt; scheitert das Speichern, Javas „Fehler beim Speichern aufgetreten!“. Das Bild wird mit Canvas 2D gemalt, nicht aus der SVG gerastert (ein `foreignObject` sperrt die Leinwand unter WebKit). **Nicht übernommen:** ein leeres Projekt gibt bei Java ein leeres Bild 20 × 35 (die Fußzeile läuft über), hier die Fußzeile allein; Java schneidet an den Rändern seiner Fläche ab, der Editor hat keine. Die Fußzeile trägt den Projektnamen, nicht den Pfad (den kennt kein Browser). Javas englisches „Error occured!“ berichtigt. Der Download des Datenaustauschs nutzt denselben Helfer |
|
| `src/canvas/networkImage.ts`, `src/design/exportImage.ts`, `src/design/DocumentationSidebar.tsx` („Exportieren“), `src/Editor.tsx` (`projectName`), `src/download.ts`, `src/canvas/layout.ts` (`cableFrame`, `cableControl`), `src/main.tsx` (Dateiname), `src/assets/documentation/download_small.png`, Kataloge (`docu.export`, `docu.exportHint`, `docu.saveFailed`) | Java `GUIDocumentationSidebar` (`docusidebar_msg5`, `6`), `GUIContainer.exportAsImage/createNetworkImage`, `GUIPrintPanel`, `GUIFooterPanel`, `GUIMainArea` (Grenzen), `JCablePanel` (Grenzen, Farben), `guimainmemu_msg11`, gemessen mit `ExportRun` | neu (F4.8c): „Exportieren“ in der Seitenleiste des Dokumentationsmodus speichert die Fläche als PNG wie Java — Dokumentation wie im Entwurf gemalt unter dem Netz, Kabel, Geräte, nichts gewählt, Hintergrund durchsichtig; Javas Ausschnitt (10 Rand, die hellgraue Fußzeile 20 unter dem untersten Element, 20 Rand darunter); Dateiname nach dem Projekt; scheitert das Speichern, Javas „Fehler beim Speichern aufgetreten!“. Das Bild wird mit Canvas 2D gemalt, nicht aus der SVG gerastert (ein `foreignObject` sperrt die Leinwand unter WebKit). **Nicht übernommen:** ein leeres Projekt gibt bei Java ein leeres Bild 20 × 35 (die Fußzeile läuft über), hier die Fußzeile allein; Java schneidet an den Rändern seiner Fläche ab, der Editor hat keine. Die Fußzeile trägt den Projektnamen, nicht den Pfad (den kennt kein Browser). Javas englisches „Error occured!“ berichtigt. Der Download des Datenaustauschs nutzt denselben Helfer |
|
||||||
|
| `src/report/reportWriter.ts`, `src/report/report.ts`, `src/report/reportPdf.ts`, `src/design/DocumentationSidebar.tsx` („Report“), `src/Editor.tsx` (Bericht), `src/simulation/useSimulation.ts` (`captures`), `src/simulation/captureCells.ts` (`reportRows`), `src/canvas/networkImage.ts` (`exportNames.report`), `src/assets/documentation/pdf_small.png`, Kataloge (`docu.report`, `docu.reportHint`, `report.*`), Abhängigkeit `pdf-lib` (MIT) | Java `GUIDocumentationSidebar` (`docusidebar_msg7`, `8`, `generateReport`), `ReportGenerator` (Aufbau, Schriften, Farben, Tabellen), iText 5 (Satz: Zeilenabstand, Umbruch, Tabellen, Seitenwechsel), `main_dlg_YES`/`NO`, gemessen mit `ReportRun` | neu (F4.8d): „Report“ speichert Javas Bericht als PDF — Titel und Projektname, „1. Übersicht“ mit dem exportierten Bild, „2. Komponenten“ je Gerät mit Karten (Basiskonfiguration, Netzwerkschnittstellen, Anwendungen, DNS-Einträge, Weiterleitungstabelle, Portweiterleitung), „3. Datenaustausch“ je Karte mit Rahmen; Satz nach iTexts Regeln, Helvetica ohne eingebettete Schrift, Breiten ohne Unterschneidung wie iText (pdf-libs `widthOfTextAtSize` unterschneidet). pdf-lib wird erst beim Klick geladen. Die Rahmen holt der Editor mit einem kurzen Abo aller Karten beim Raum (auch nach dem Stopp). **Nicht übernommen:** `true`/`false` bei DHCP (Javas eigenes „Ja“/„Nein“); Programme und Karten in Javas HashMap-Reihenfolge (hier Installation bzw. Geräte); ein Bild höher als die Seite läuft über den Rand (hier verkleinert); der Bericht schreibt Protokolle der Portweiterleitung ins Modell zurück; „tcp“ klein bleibt bei Java klein. Projektname statt Pfad unter dem Titel; ein Zeichen, das Helvetica nicht schreiben kann, wird „?“ |
|
||||||
| `src/Editor.tsx`, `src/canvas/Canvas.tsx` (Aktionsmodus) | Java `GUIContainer.setActiveSite/showDesktop/closeDialogs/reopenDialogs`, `GUIKnotenItem` (Klick im Aktionsmodus), `GUIEvents.kontextMenueAktionsmodus` (`guievents_msg3`), `JCablePanel` (`farbeBlinken`) | erweitert (F4.6a): im Aktionsmodus ist der Entwurf gesperrt wie bei Java — keine Palette, kein Blatt, nichts bewegt sich, kein Rückgängig, kein Löschen —, Kabel mit Rahmen leuchten grün, ein Tipp auf einen Rechner öffnet seinen Desktop (Langdruck: „Desktop anzeigen“); offene Desktops überstehen den Entwurf und erscheinen beim nächsten Start wieder, die eines entfernten Rechners nicht |
|
| `src/Editor.tsx`, `src/canvas/Canvas.tsx` (Aktionsmodus) | Java `GUIContainer.setActiveSite/showDesktop/closeDialogs/reopenDialogs`, `GUIKnotenItem` (Klick im Aktionsmodus), `GUIEvents.kontextMenueAktionsmodus` (`guievents_msg3`), `JCablePanel` (`farbeBlinken`) | erweitert (F4.6a): im Aktionsmodus ist der Entwurf gesperrt wie bei Java — keine Palette, kein Blatt, nichts bewegt sich, kein Rückgängig, kein Löschen —, Kabel mit Rahmen leuchten grün, ein Tipp auf einen Rechner öffnet seinen Desktop (Langdruck: „Desktop anzeigen“); offene Desktops überstehen den Entwurf und erscheinen beim nächsten Start wieder, die eines entfernten Rechners nicht |
|
||||||
| `browser/simulation.spec.ts`, `test/programs.test.ts` | — | neu (F4.6a): Moduswechsel, Sperren, Geschwindigkeit, Kabelaktivität, Desktop mit Titel, Programmen, Netzwerk-Info, Verschieben und Stapeln, Handy, Englisch; die Programmliste gegen Javas `Desktop_*.txt`; seit F4.6b die Befehlszeile (Ping über drei Router, Verlauf, Chips, Strg+C, Behalten, exit) und der Fehlerfall des Raums; seit F4.6c die Software-Installation (sofort lauffähig, Doppelklick, Abwählen) |
|
| `browser/simulation.spec.ts`, `test/programs.test.ts` | — | neu (F4.6a): Moduswechsel, Sperren, Geschwindigkeit, Kabelaktivität, Desktop mit Titel, Programmen, Netzwerk-Info, Verschieben und Stapeln, Handy, Englisch; die Programmliste gegen Javas `Desktop_*.txt`; seit F4.6b die Befehlszeile (Ping über drei Router, Verlauf, Chips, Strg+C, Behalten, exit) und der Fehlerfall des Raums; seit F4.6c die Software-Installation (sofort lauffähig, Doppelklick, Abwählen) |
|
||||||
| `browser/web.spec.ts`, `browser/fixtures/web-pages.fls`, `test/webPagesFixture.test.ts`, `test/serverLines.test.ts`, `src/simulation/useSimulation.ts` (`views`), `src/Editor.tsx` (Sprache beim Start), `src/desktop/DesktopWindow.tsx` | — | neu (F4.6d): Browser und Webserver in Chromium auf einer eigenen Projektdatei (mit den Befehlen des Dokuments gebaut und bei jedem Lauf verglichen): Adresszeile, Titel, Sandbox, Bilder, Marke, Links im Ordner, beide Formulare bis in die Zeilen des Servers, Fehlerseite, nicht erreichbarer Server, Knopf, Kästchen und Tabelle, die Routerseiten auf Englisch; die Zeilen des Servers gegen Javas Fenster |
|
| `browser/web.spec.ts`, `browser/fixtures/web-pages.fls`, `test/webPagesFixture.test.ts`, `test/serverLines.test.ts`, `src/simulation/useSimulation.ts` (`views`), `src/Editor.tsx` (Sprache beim Start), `src/desktop/DesktopWindow.tsx` | — | neu (F4.6d): Browser und Webserver in Chromium auf einer eigenen Projektdatei (mit den Befehlen des Dokuments gebaut und bei jedem Lauf verglichen): Adresszeile, Titel, Sandbox, Bilder, Marke, Links im Ordner, beide Formulare bis in die Zeilen des Servers, Fehlerseite, nicht erreichbarer Server, Knopf, Kästchen und Tabelle, die Routerseiten auf Englisch; die Zeilen des Servers gegen Javas Fenster |
|
||||||
@@ -252,6 +253,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
|||||||
| `browser/capture.spec.ts`, `test/captureCells.test.ts` | — | neu (F4.7): auf `web-pages.fls` Javas Menü, eine Seite geladen (Zeile je Frame, Javas Farben, Details, Textdatei mit Javas Kopf), die Karten eines Routers, „Tabellen löschen“, „Automatisches Scrollen“ aus, ein Reiter geschlossen, das Fenster über Entwurf und Start, der Reiter eines entfernten Rechners weg; auf einem Handy; die Zellen in beiden Sprachen und Zahlenformaten |
|
| `browser/capture.spec.ts`, `test/captureCells.test.ts` | — | neu (F4.7): auf `web-pages.fls` Javas Menü, eine Seite geladen (Zeile je Frame, Javas Farben, Details, Textdatei mit Javas Kopf), die Karten eines Routers, „Tabellen löschen“, „Automatisches Scrollen“ aus, ein Reiter geschlossen, das Fenster über Entwurf und Start, der Reiter eines entfernten Rechners weg; auf einem Handy; die Zellen in beiden Sprachen und Zahlenformaten |
|
||||||
| `browser/documentation.spec.ts`, `test/documentationPaint.test.ts` | — | neu (F4.8b): auf `nat_with_mapped_port` Javas Knopfreihenfolge, eine Fläche gezogen (mittig, grau, Deckkraft 200), verschoben, auf 20 × 20 verkleinert, Farbe aus Javas zwölf; ein Text getippt, groß und kursiv; Escape, Javas Textrand; im Entwurf deckend aufgehellt, nicht wählbar; Entf; Start der Simulation beendet den Modus; Handy; die Zeichenregeln gegen Javas gemessene Pixel |
|
| `browser/documentation.spec.ts`, `test/documentationPaint.test.ts` | — | neu (F4.8b): auf `nat_with_mapped_port` Javas Knopfreihenfolge, eine Fläche gezogen (mittig, grau, Deckkraft 200), verschoben, auf 20 × 20 verkleinert, Farbe aus Javas zwölf; ein Text getippt, groß und kursiv; Escape, Javas Textrand; im Entwurf deckend aufgehellt, nicht wählbar; Entf; Start der Simulation beendet den Modus; Handy; die Zeichenregeln gegen Javas gemessene Pixel |
|
||||||
| `test/networkImage.test.ts`, `test/networkImage.java.json`, `browser/documentation.spec.ts` (Export) | — | neu (F4.8c): Ausschnitt und Fußzeile gegen Javas Druckpanel auf vier Projekten (aus Javas eigenen Grenzen, die von Javas Schrift abhängen), jedes Kabel Javas gegen `cableFrame`, Javas Farben (durchsichtig, Fußzeile 192, Fläche wie im Entwurf); Zeilenumbruch; Dateinamen; das Malen auf einem aufzeichnenden Kontext (Reihenfolge, Farben, gestrichelte Funkstrecke); im Browser der Download (Name, Größe aus den Rahmen der Fläche, Eckpixel, Flächenfarbe 215, Fußzeile hellgrau) und Javas Meldung, wenn `toBlob` nichts liefert |
|
| `test/networkImage.test.ts`, `test/networkImage.java.json`, `browser/documentation.spec.ts` (Export) | — | neu (F4.8c): Ausschnitt und Fußzeile gegen Javas Druckpanel auf vier Projekten (aus Javas eigenen Grenzen, die von Javas Schrift abhängen), jedes Kabel Javas gegen `cableFrame`, Javas Farben (durchsichtig, Fußzeile 192, Fläche wie im Entwurf); Zeilenumbruch; Dateinamen; das Malen auf einem aufzeichnenden Kontext (Reihenfolge, Farben, gestrichelte Funkstrecke); im Browser der Download (Name, Größe aus den Rahmen der Fläche, Eckpixel, Flächenfarbe 215, Fußzeile hellgrau) und Javas Meldung, wenn `toBlob` nichts liefert |
|
||||||
|
| `test/report.test.ts`, `test/report.java.json`, `test/report.ours.java.json`, `browser/report.spec.ts` | — | neu (F4.8d): jedes Textstück von Javas Bericht (Wort, Ort, Schrift, Größe, Farbe) gegen den eigenen Satz — alle Geräteseiten von `Internet_Komplett`, die erste Seite, ein langer Titel, ein Projekt ohne Geräte, ein leeres, der Datenaustausch von `einfaches_rechnernetz_komplett` über alle Seiten (Javas Zeilen aus Javas Seiten); Javas iText liest das eigene PDF und findet dieselben Stücke an denselben Orten (SHA-256 festgehalten); im Browser der Bericht nach einem kurzen Lauf mit DHCP: Dateiname, Titel, Bild, Geräte, Datenaustausch |
|
||||||
| `src/sheet/ServicePanels.tsx`, `src/sheet/RowsEditor.tsx`, `src/sheet/SheetTabs.tsx`, `src/sheet/DeviceSheet.tsx` (Seiten) | Java `JDHCPKonfiguration` (`jdhcpkonfiguration_msg1`–`15`), `JPortForwardingDialog` (`jportforwarding_msg4`–`7`, `jgatewayconfiguration_msg19`) | neu (F4.5c): Javas Dialoge als Seiten des Blatts mit „Zurück“ — DHCP-Server (Grundeinstellungen, statische Adresszuweisung; beim Rechner, gesperrt während DHCP die Karte einrichtet, und auf dem LAN-Reiter des Heimrouters), Portfreigaben (Heimrouter); jedes Feld beim Verlassen übernommen statt mit Javas OK-Knopf. Die Zeilentabellen (Weiterleitung, Zuordnungen, Portfreigaben) teilen sich `RowsEditor`: eine neue Zeile, sobald alle Felder gültig sind, Zeile wählen, bearbeiten, löschen. Javas Tooltip zu „Manuelle Einstellungen“ als Text unter dem Kästchen (ein Finger hat kein Überfahren) |
|
| `src/sheet/ServicePanels.tsx`, `src/sheet/RowsEditor.tsx`, `src/sheet/SheetTabs.tsx`, `src/sheet/DeviceSheet.tsx` (Seiten) | Java `JDHCPKonfiguration` (`jdhcpkonfiguration_msg1`–`15`), `JPortForwardingDialog` (`jportforwarding_msg4`–`7`, `jgatewayconfiguration_msg19`) | neu (F4.5c): Javas Dialoge als Seiten des Blatts mit „Zurück“ — DHCP-Server (Grundeinstellungen, statische Adresszuweisung; beim Rechner, gesperrt während DHCP die Karte einrichtet, und auf dem LAN-Reiter des Heimrouters), Portfreigaben (Heimrouter); jedes Feld beim Verlassen übernommen statt mit Javas OK-Knopf. Die Zeilentabellen (Weiterleitung, Zuordnungen, Portfreigaben) teilen sich `RowsEditor`: eine neue Zeile, sobald alle Felder gültig sind, Zeile wählen, bearbeiten, löschen. Javas Tooltip zu „Manuelle Einstellungen“ als Text unter dem Kästchen (ein Finger hat kein Überfahren) |
|
||||||
| `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben |
|
| `src/sheet/RouterSheets.tsx`, `src/sheet/NameField.tsx`, `src/canvas/Canvas.tsx` (`highlightedLink`), `src/theme/tokens.css` (`--canvas-cable-active`) | Java `JVermittlungsrechnerKonfiguration`, `JGatewayConfiguration`, `JWeiterleitungsTabelle` (Wörter `jvermittlungsrechnerkonfiguration_msg6`–`26`, `jweiterleitungstabelle_msg3`–`6`), `JCablePanel.farbeBlinken`, `Kabel.setAktiv` | neu (F4.5b): Blatt des Vermittlungsrechners — Reiter Allgemein (Name, Gateway, Automatisches Routing, IP-Weiterleitung nur angezeigt), je Karte (Reiter mit ihrer Adresse, „Verbunden mit …“, Adresse, Maske, MAC) und Weiterleitungstabelle (alle Einträge oder nur die manuellen, Javas Zeilen grau, eine neue Zeile, die mit vier gültigen Feldern übernommen wird, Zelle bearbeiten, Eintrag löschen; bei RIP gesperrt); Blatt des Heimrouters mit Allgemein, LAN, WAN (DHCP sperrt die WAN-Adresse). Das Kabel der offenen Karte leuchtet grün wie bei Java. Javas „Eintrag Löschen“ klein geschrieben |
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ die Herkunft je Paket. Lizenz dieses Repos: **GPL-3.0** (`LICENSE`).
|
|||||||
| `packages/doc` | gemeinsames Dokument (Yjs): Projekt ⇄ Dokument, Schnappschuss des Kerns, Hausmeister, die Änderungen der Werkzeuge und des Geräte-Blatts (mit Javas Eingabeprüfung) |
|
| `packages/doc` | gemeinsames Dokument (Yjs): Projekt ⇄ Dokument, Schnappschuss des Kerns, Hausmeister, die Änderungen der Werkzeuge und des Geräte-Blatts (mit Javas Eingabeprüfung) |
|
||||||
| `packages/protocol` | die eine Nachrichtenliste zwischen Editor und Simulationsraum (`/sim`) |
|
| `packages/protocol` | die eine Nachrichtenliste zwischen Editor und Simulationsraum (`/sim`) |
|
||||||
| `packages/sim` | Simulationsraum (für Server F und den Web Worker) und `LocalSimHost` |
|
| `packages/sim` | Simulationsraum (für Server F und den Web Worker) und `LocalSimHost` |
|
||||||
| `packages/editor` | Oberfläche im Iframe des Dashboards (React, Vite): Fläche, Palette, Werkzeuge des Entwurfs, Geräte-Blatt, Aktionsmodus (Desktops, Anwendungen, Datenaustausch), Dokumentationsmodus (Text, Gliederungsfläche, Bild exportieren); `npm run dev -w @mdg-filius/editor` öffnet die Entwicklungsseite |
|
| `packages/editor` | Oberfläche im Iframe des Dashboards (React, Vite): Fläche, Palette, Werkzeuge des Entwurfs, Geräte-Blatt, Aktionsmodus (Desktops, Anwendungen, Datenaustausch), Dokumentationsmodus (Text, Gliederungsfläche, Bild exportieren, Bericht als PDF); `npm run dev -w @mdg-filius/editor` öffnet die Entwicklungsseite |
|
||||||
| `server` | Filius-Dienst auf Server F *(folgt)* |
|
| `server` | Filius-Dienst auf Server F *(folgt)* |
|
||||||
|
|
||||||
Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard.
|
Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard.
|
||||||
|
|||||||
Generated
+43
@@ -842,6 +842,24 @@
|
|||||||
"node": "^22.20 || ^24.12 || >=25"
|
"node": "^22.20 || ^24.12 || >=25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pdf-lib/standard-fonts": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pdf-lib/upng": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"pako": "^1.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.62.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
@@ -1896,6 +1914,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/pathe": {
|
"node_modules/pathe": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
@@ -1913,6 +1937,18 @@
|
|||||||
"node": ">= 14.16"
|
"node": ">= 14.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pdf-lib": {
|
||||||
|
"version": "1.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||||
|
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||||
|
"@pdf-lib/upng": "^1.0.1",
|
||||||
|
"pako": "^1.0.11",
|
||||||
|
"tslib": "^1.11.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2226,6 +2262,12 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -2521,6 +2563,7 @@
|
|||||||
"@mdg-filius/fls": "0.0.0",
|
"@mdg-filius/fls": "0.0.0",
|
||||||
"@mdg-filius/protocol": "0.0.0",
|
"@mdg-filius/protocol": "0.0.0",
|
||||||
"@mdg-filius/sim": "0.0.0",
|
"@mdg-filius/sim": "0.0.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"yjs": "^13.6.33"
|
"yjs": "^13.6.33"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export * from './fields.js';
|
|||||||
export * from './files.js';
|
export * from './files.js';
|
||||||
export * from './janitor.js';
|
export * from './janitor.js';
|
||||||
export * from './projectDoc.js';
|
export * from './projectDoc.js';
|
||||||
|
export * from './report.js';
|
||||||
export * from './settings.js';
|
export * from './settings.js';
|
||||||
export * from './simulation.js';
|
export * from './simulation.js';
|
||||||
export * from './sheet.js';
|
export * from './sheet.js';
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// What FILIUS' report (ReportGenerator.addComponentConfigSection) tells of
|
||||||
|
// each device that has cards (InternetKnoten: computers, notebooks, routers,
|
||||||
|
// home routers; not switches or modems), in the order of the devices: its
|
||||||
|
// label, gateway and DNS server, whether DHCP sets it up (computers only),
|
||||||
|
// its cards, its programs (computers only, in the order they were
|
||||||
|
// installed), its DNS records (with a DNS server), its forwarding table (the
|
||||||
|
// rows whose destination is an address, as Java's holeTabelle and its
|
||||||
|
// filter give them) and a home router's port forwarding (the rows whose
|
||||||
|
// protocol Java knows: TCP, UDP, 6 or 17, written TCP or UDP). And the cards
|
||||||
|
// whose data exchange the report lists, with their device's label and
|
||||||
|
// address.
|
||||||
|
//
|
||||||
|
// Better than Java: a protocol written in small letters is written TCP or
|
||||||
|
// UDP too (Java's switch only knows the capitals and the numbers); the
|
||||||
|
// report changes nothing (Java writes the protocols back into the home
|
||||||
|
// router's table).
|
||||||
|
|
||||||
|
import type * as Y from 'yjs';
|
||||||
|
import { DNS_HOSTS_PATH, isValidJavaIPAddress, readDnsHosts, routeTableRows, type InstallableProgram, type NodeKind, type RouteRow } from '@mdg-filius/core';
|
||||||
|
import { nodeFiles } from './files.js';
|
||||||
|
import { inOrder, ROOTS } from './projectDoc.js';
|
||||||
|
import { deviceSheet } from './sheet.js';
|
||||||
|
import { captureCards, nodeLabel } from './view.js';
|
||||||
|
|
||||||
|
export interface ReportCard {
|
||||||
|
readonly macAddress: string;
|
||||||
|
readonly ipAddress: string;
|
||||||
|
readonly subnetMask: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportDevice {
|
||||||
|
readonly id: string;
|
||||||
|
readonly kind: Extract<NodeKind, 'pc' | 'notebook' | 'router' | 'gateway'>;
|
||||||
|
readonly label: string;
|
||||||
|
readonly gateway: string;
|
||||||
|
readonly dnsServer: string;
|
||||||
|
/** Computers only. */
|
||||||
|
readonly dhcpClient?: boolean;
|
||||||
|
readonly cards: readonly ReportCard[];
|
||||||
|
/** Computers only: the programs with a window (not the DHCP server), in the order they were installed. */
|
||||||
|
readonly programs?: readonly InstallableProgram[];
|
||||||
|
/** With a DNS server: its records. */
|
||||||
|
readonly dnsRecords?: readonly { readonly domainName: string; readonly type: string; readonly rdata: string }[];
|
||||||
|
readonly routes: readonly RouteRow[];
|
||||||
|
/** Home routers only: the rows of its port forwarding Java reports. */
|
||||||
|
readonly portForwarding?: readonly (readonly [protocol: string, publicPort: string, lanIPAddress: string, lanPort: string])[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROTOCOLS: Readonly<Record<string, string>> = { tcp: 'TCP', '6': 'TCP', udp: 'UDP', '17': 'UDP' };
|
||||||
|
|
||||||
|
/** The rows Java reports: those whose destination is an address. */
|
||||||
|
const reported = (rows: readonly RouteRow[]) => rows.filter((row) => isValidJavaIPAddress(row.destinationNetwork));
|
||||||
|
|
||||||
|
/** The DNS server's file of records; without it none, as Java reads none. */
|
||||||
|
function hostsText(doc: Y.Doc, id: string): string {
|
||||||
|
try {
|
||||||
|
return nodeFiles(doc, id)?.textFile(DNS_HOSTS_PATH) ?? '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportDevices(doc: Y.Doc): ReportDevice[] {
|
||||||
|
const devices: ReportDevice[] = [];
|
||||||
|
for (const [id, node] of inOrder(doc.getMap<Y.Map<unknown>>(ROOTS.nodes))) {
|
||||||
|
const sheet = deviceSheet(doc, id)!;
|
||||||
|
const label = nodeLabel(node);
|
||||||
|
const device = (node.get('settings') as Y.Map<unknown>).get('device') as Y.Map<unknown> | undefined;
|
||||||
|
const dnsServer = (device?.get('dnsServer') as string | undefined) ?? '';
|
||||||
|
if (sheet.kind === 'pc' || sheet.kind === 'notebook') {
|
||||||
|
const programs = [...(node.get('programs') as Y.Map<unknown>).keys()] as InstallableProgram[];
|
||||||
|
const hosts = programs.includes('dnsServer') ? hostsText(doc, id) : undefined;
|
||||||
|
const card = { ipAddress: sheet.ipAddress, subnetMask: sheet.subnetMask };
|
||||||
|
devices.push({
|
||||||
|
id,
|
||||||
|
kind: sheet.kind,
|
||||||
|
label,
|
||||||
|
gateway: sheet.defaultGateway,
|
||||||
|
dnsServer: sheet.dnsServer,
|
||||||
|
dhcpClient: sheet.dhcpClient,
|
||||||
|
cards: [{ macAddress: sheet.macAddress, ...card }],
|
||||||
|
programs: programs.filter((program) => program !== 'dhcpServer'),
|
||||||
|
...(hosts === undefined ? {} : { dnsRecords: readDnsHosts(hosts).map(({ domainName, type, rdata }) => ({ domainName, type, rdata })) }),
|
||||||
|
routes: reported(routeTableRows([card], [], sheet.defaultGateway)),
|
||||||
|
});
|
||||||
|
} else if (sheet.kind === 'router') {
|
||||||
|
devices.push({
|
||||||
|
id,
|
||||||
|
kind: sheet.kind,
|
||||||
|
label,
|
||||||
|
gateway: sheet.gateway,
|
||||||
|
dnsServer,
|
||||||
|
cards: sheet.interfaces.map(({ macAddress, ipAddress, subnetMask }) => ({ macAddress, ipAddress, subnetMask })),
|
||||||
|
routes: reported(sheet.table),
|
||||||
|
});
|
||||||
|
} else if (sheet.kind === 'gateway') {
|
||||||
|
const cards = [sheet.wan, sheet.lan].map(({ macAddress, ipAddress, subnetMask }) => ({ macAddress, ipAddress, subnetMask }));
|
||||||
|
devices.push({
|
||||||
|
id,
|
||||||
|
kind: sheet.kind,
|
||||||
|
label,
|
||||||
|
gateway: sheet.gateway,
|
||||||
|
dnsServer,
|
||||||
|
cards,
|
||||||
|
routes: reported(routeTableRows(cards, [], sheet.gateway)),
|
||||||
|
portForwarding: sheet.portForwarding.flatMap((row) => {
|
||||||
|
const protocol = PROTOCOLS[row.protocolValue.toLowerCase()];
|
||||||
|
return protocol === undefined ? [] : [[protocol, row.publicPortValue, row.lanIPAddress, row.lanPortValue] as const];
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every card of the reported devices, in their order: its port, its device's label and its address. */
|
||||||
|
export function reportCards(doc: Y.Doc): { readonly portId: string; readonly label: string; readonly ipAddress: string }[] {
|
||||||
|
// Only computers, routers and home routers have cards with an address.
|
||||||
|
return inOrder(doc.getMap<Y.Map<unknown>>(ROOTS.nodes)).flatMap(([id, node]) => captureCards(doc, id).map((card) => ({ portId: card.portId, label: nodeLabel(node), ipAddress: card.ipAddress })));
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// What the report tells of each device (reportDevices) and which cards it
|
||||||
|
// lists (reportCards), on FILIUS' example Internet_Komplett — the whole of it
|
||||||
|
// is compared with Java's report in packages/editor/test/report.test.ts —
|
||||||
|
// and on changes a Java file can carry: a forwarding row whose destination
|
||||||
|
// is no address, a router's DNS server, a computer's DHCP server among its
|
||||||
|
// programs, a home router's port forwarding written with numbers, small
|
||||||
|
// letters or a protocol Java does not know.
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { openProjectArchive } from '@mdg-filius/fls';
|
||||||
|
import { reportCards, reportDevices, writeProject } from '../src/index.js';
|
||||||
|
|
||||||
|
const corpus = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus/java-examples');
|
||||||
|
const FILE = 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls';
|
||||||
|
|
||||||
|
function docOf(file: string): Y.Doc {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
let next = 0;
|
||||||
|
writeProject(
|
||||||
|
doc,
|
||||||
|
openProjectArchive(new Uint8Array(readFileSync(join(corpus, file))), () => `id${next++}`),
|
||||||
|
);
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
const nodeNamed = (doc: Y.Doc, name: string) => [...doc.getMap<Y.Map<unknown>>('nodes').entries()].find(([, node]) => node.get('name') === name)!;
|
||||||
|
const settings = (node: Y.Map<unknown>) => node.get('settings') as Y.Map<unknown>;
|
||||||
|
const row = (fields: Readonly<Record<string, string>>) => {
|
||||||
|
const map = new Y.Map<unknown>();
|
||||||
|
for (const [key, value] of Object.entries(fields)) map.set(key, value);
|
||||||
|
return map;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('the devices of the report', () => {
|
||||||
|
it('tells of the computers, routers and home routers in their order, not of the switches', () => {
|
||||||
|
const devices = reportDevices(docOf(FILE));
|
||||||
|
// 26 devices, four of them switches.
|
||||||
|
expect(devices.length).toBe(22);
|
||||||
|
expect(devices.map((device) => device.kind)).not.toContain('networkSwitch');
|
||||||
|
const server = devices.find((device) => device.label === 'DNS (8.8.8.8)')!;
|
||||||
|
expect(server).toMatchObject({ kind: 'pc', gateway: '8.0.0.1', dnsServer: '', dhcpClient: false, programs: ['dnsServer', 'textEditor', 'commandPrompt'] });
|
||||||
|
expect(server.dnsRecords![1]).toEqual({ domainName: 'gmx.de.', type: 'MX', rdata: 'mail.gmx.de.' });
|
||||||
|
expect(devices.find((device) => device.label === 'Firewall')).toMatchObject({ kind: 'gateway', portForwarding: [['TCP', '80', '10.0.0.1', '80']] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves out a forwarding row whose destination is no address, as Java's report does", () => {
|
||||||
|
const doc = docOf(FILE);
|
||||||
|
const [id, provider] = nodeNamed(doc, 'Provider 1');
|
||||||
|
doc.transact(() => {
|
||||||
|
const routes = new Y.Array<Y.Map<unknown>>();
|
||||||
|
routes.push([
|
||||||
|
row({ destinationNetwork: 'kein Netz', subnetMask: '255.0.0.0', gateway: '2.0.0.2', interfaceIPAddress: '2.0.0.1' }),
|
||||||
|
row({ destinationNetwork: '9.0.0.0', subnetMask: '255.0.0.0', gateway: '2.0.0.2', interfaceIPAddress: '2.0.0.1' }),
|
||||||
|
]);
|
||||||
|
settings(provider).set('routes', routes);
|
||||||
|
});
|
||||||
|
const destinations = reportDevices(doc)
|
||||||
|
.find((device) => device.id === id)!
|
||||||
|
.routes.map((route) => route.destinationNetwork);
|
||||||
|
expect(destinations).toContain('9.0.0.0');
|
||||||
|
expect(destinations).not.toContain('kein Netz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a router's DNS server, as Java's does", () => {
|
||||||
|
const doc = docOf(FILE);
|
||||||
|
const [id, provider] = nodeNamed(doc, 'Provider 1');
|
||||||
|
doc.transact(() => (settings(provider).get('device') as Y.Map<unknown> | undefined)?.set('dnsServer', '8.8.8.8') ?? settings(provider).set('device', row({ dnsServer: '8.8.8.8' })));
|
||||||
|
expect(reportDevices(doc).find((device) => device.id === id)!.dnsServer).toBe('8.8.8.8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the DHCP server out of the programs, as it has no window', () => {
|
||||||
|
const doc = docOf(FILE);
|
||||||
|
const [id, server] = nodeNamed(doc, 'DNS (8.8.8.8)');
|
||||||
|
doc.transact(() => (server.get('programs') as Y.Map<unknown>).set('dhcpServer', new Y.Map()));
|
||||||
|
expect(reportDevices(doc).find((device) => device.id === id)!.programs).toEqual(['dnsServer', 'textEditor', 'commandPrompt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the port forwarding Java reports: TCP and UDP, also written 6, 17 or in small letters; no other protocol', () => {
|
||||||
|
const doc = docOf(FILE);
|
||||||
|
const [id, firewall] = nodeNamed(doc, 'Firewall');
|
||||||
|
doc.transact(() => {
|
||||||
|
const rows = new Y.Array<Y.Map<unknown>>();
|
||||||
|
for (const protocolValue of ['6', '17', 'udp', 'Tcp', 'ICMP']) rows.push([row({ protocolValue, publicPortValue: '8080', lanIPAddress: '10.0.0.1', lanPortValue: '80' })]);
|
||||||
|
settings(firewall).set('portForwarding', rows);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
reportDevices(doc)
|
||||||
|
.find((device) => device.id === id)!
|
||||||
|
.portForwarding!.map(([protocol]) => protocol),
|
||||||
|
).toEqual(['TCP', 'UDP', 'UDP', 'TCP']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the cards of the report', () => {
|
||||||
|
it("lists every card with an address, under its device's label, in the devices' order", () => {
|
||||||
|
const cards = reportCards(docOf(FILE));
|
||||||
|
expect(cards[0]).toMatchObject({ label: '10.10.20.11', ipAddress: '10.10.20.11' });
|
||||||
|
expect(cards.filter((card) => card.label === 'Provider 1').map((card) => card.ipAddress)).toEqual(['42.0.0.1', '6.0.0.2', '2.0.0.1']);
|
||||||
|
// No switch has one.
|
||||||
|
expect(cards.some((card) => card.label.startsWith('Switch'))).toBe(false);
|
||||||
|
expect(cards.length).toBe(reportDevices(docOf(FILE)).reduce((sum, device) => sum + device.cards.length, 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,7 +54,7 @@ test('makes, moves, resizes, colours and types documentation as Java does, and p
|
|||||||
await expect(canvas).toHaveAttribute('data-mode', 'documentation');
|
await expect(canvas).toHaveAttribute('data-mode', 'documentation');
|
||||||
await expect(page.getByRole('navigation', { name: 'Komponenten' })).toHaveCount(0);
|
await expect(page.getByRole('navigation', { name: 'Komponenten' })).toHaveCount(0);
|
||||||
const sidebar = page.getByRole('navigation', { name: 'Dokumentation' });
|
const sidebar = page.getByRole('navigation', { name: 'Dokumentation' });
|
||||||
await expect(sidebar.getByRole('button')).toHaveText(['Text', 'Gliederungsfläche', 'Exportieren']);
|
await expect(sidebar.getByRole('button')).toHaveText(['Text', 'Gliederungsfläche', 'Exportieren', 'Report']);
|
||||||
|
|
||||||
// A rectangle dragged onto the surface: 100 × 50, its middle where it was dropped, grey, with the focus.
|
// A rectangle dragged onto the surface: 100 × 50, its middle where it was dropped, grey, with the focus.
|
||||||
const surface = (await canvas.boundingBox())!;
|
const surface = (await canvas.boundingBox())!;
|
||||||
@@ -241,6 +241,7 @@ test('keeps the documentation mode within reach on a phone', async ({ page }) =>
|
|||||||
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
|
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
|
||||||
await expect(page.getByRole('button', { name: 'Gliederungsfläche' })).toBeInViewport({ ratio: 1 });
|
await expect(page.getByRole('button', { name: 'Gliederungsfläche' })).toBeInViewport({ ratio: 1 });
|
||||||
await expect(page.getByRole('button', { name: 'Exportieren' })).toBeInViewport({ ratio: 1 });
|
await expect(page.getByRole('button', { name: 'Exportieren' })).toBeInViewport({ ratio: 1 });
|
||||||
|
await expect(page.getByRole('button', { name: 'Report' })).toBeInViewport({ ratio: 1 });
|
||||||
const rights = await page.locator('.editor-toolbar > *').evaluateAll((parts) => parts.map((part) => part.getBoundingClientRect().right));
|
const rights = await page.locator('.editor-toolbar > *').evaluateAll((parts) => parts.map((part) => part.getBoundingClientRect().right));
|
||||||
expect(Math.max(...rights)).toBeLessThanOrEqual(360);
|
expect(Math.max(...rights)).toBeLessThanOrEqual(360);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// FILIUS' „Report“ in Chromium (F4.8d), on FILIUS' example
|
||||||
|
// „einfaches_rechnernetz_komplett.fls“, run for a moment (its notebooks ask for
|
||||||
|
// addresses by DHCP at once), then in the documentation mode the report
|
||||||
|
// saved under the project's name — a PDF with Java's title, the overview's
|
||||||
|
// image, every computer's configuration and the data exchange of each card
|
||||||
|
// that saw frames, with the rows' words as the data exchange window has
|
||||||
|
// them.
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { decodePDFRawStream, PDFArray, PDFDocument, PDFName, PDFRawStream } from 'pdf-lib';
|
||||||
|
import { openEditor, serveEditor } from './editorPage.js';
|
||||||
|
|
||||||
|
let server: { readonly base: string; readonly close: () => Promise<void> };
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
server = await serveEditor();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Each page's words, as pdf-lib writes them (hex strings shown with Tj, in Windows-1252). */
|
||||||
|
async function pageTexts(bytes: Uint8Array): Promise<string[][]> {
|
||||||
|
const pdf = await PDFDocument.load(bytes);
|
||||||
|
return pdf.getPages().map((page) => {
|
||||||
|
const contents = page.node.get(PDFName.of('Contents'));
|
||||||
|
const streams = contents instanceof PDFArray ? contents.asArray() : [contents];
|
||||||
|
const words: string[] = [];
|
||||||
|
for (const reference of streams) {
|
||||||
|
const stream = pdf.context.lookup(reference);
|
||||||
|
if (!(stream instanceof PDFRawStream)) continue;
|
||||||
|
const operators = new TextDecoder('latin1').decode(decodePDFRawStream(stream).decode());
|
||||||
|
for (const [, hex] of operators.matchAll(/<([0-9A-Fa-f]*)>\s*Tj/g))
|
||||||
|
words.push(new TextDecoder('windows-1252').decode(Uint8Array.from(hex!.match(/../g) ?? [], (pair) => Number.parseInt(pair, 16))));
|
||||||
|
}
|
||||||
|
return words;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("saves Java's report of the project, with the data exchange so far", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await openEditor(page, server.base, 'einfaches_rechnernetz_komplett.fls');
|
||||||
|
await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click();
|
||||||
|
await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation');
|
||||||
|
// A cable lights up: frames went over it.
|
||||||
|
await expect(page.locator('path[data-active="true"]').first()).toBeAttached({ timeout: 20_000 });
|
||||||
|
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
|
||||||
|
const button = page.getByRole('navigation', { name: 'Dokumentation' }).getByRole('button', { name: 'Report' });
|
||||||
|
await expect(button).toHaveAttribute('title', 'Report als PDF-Dokument erstellen');
|
||||||
|
const saved = page.waitForEvent('download');
|
||||||
|
await button.click();
|
||||||
|
const download = await saved;
|
||||||
|
expect(download.suggestedFilename()).toBe('einfaches_rechnernetz_komplett.pdf');
|
||||||
|
await expect(button).toBeEnabled();
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(readFileSync((await download.path())!));
|
||||||
|
const pdf = await PDFDocument.load(bytes);
|
||||||
|
expect(pdf.getTitle()).toBe('Filius-Dokumentation: einfaches_rechnernetz_komplett');
|
||||||
|
expect(pdf.getPage(0).getSize()).toEqual({ width: 595, height: 842 });
|
||||||
|
const pages = await pageTexts(bytes);
|
||||||
|
// The first page: the title, the project's name, the overview with the image.
|
||||||
|
expect(pages[0]).toEqual(['Filius-Dokumentation: einfaches_rechnernetz_komplett', 'einfaches_rechnernetz_komplett.fls', '1. ', 'Übersicht']);
|
||||||
|
expect(new TextDecoder('latin1').decode(bytes)).toContain('/Subtype /Image');
|
||||||
|
// Then the devices: every computer and notebook, none of the switches.
|
||||||
|
const words = pages.flat();
|
||||||
|
expect(words.filter((word) => /^2\.\d+\. $/.test(word)).length).toBe(9);
|
||||||
|
expect(words).toContain('Server');
|
||||||
|
expect(words).not.toContain('Switch 1');
|
||||||
|
expect(words).toEqual(expect.arrayContaining(['Installiert: ', 'Webserver, Text-Editor, Datei-Explorer, Bildbetrachter, DNS-Server, Befehlszeile', 'www.mein-server.de.', 'Nein']));
|
||||||
|
// Then the data exchange: each card that saw frames, under its device's name and address.
|
||||||
|
const traffic = pages.findIndex((texts) => texts.includes('Datenaustausch'));
|
||||||
|
expect(traffic).toBeGreaterThan(1);
|
||||||
|
const rest = pages.slice(traffic).flat();
|
||||||
|
expect(rest).toEqual(expect.arrayContaining(['3.1. ', 'Nr.', 'Bemerkungen / Details', 'DHCP', 'Anwendung']));
|
||||||
|
expect(rest.some((word) => /^Notebook \d - /.test(word) || /^Server - 192\.168\.0\.100$/.test(word))).toBe(true);
|
||||||
|
expect(rest.some((word) => word.startsWith('DHCPDISCOVER'))).toBe(true);
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"@mdg-filius/fls": "0.0.0",
|
"@mdg-filius/fls": "0.0.0",
|
||||||
"@mdg-filius/protocol": "0.0.0",
|
"@mdg-filius/protocol": "0.0.0",
|
||||||
"@mdg-filius/sim": "0.0.0",
|
"@mdg-filius/sim": "0.0.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"yjs": "^13.6.33"
|
"yjs": "^13.6.33"
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
removeDocumentation,
|
removeDocumentation,
|
||||||
removeLink,
|
removeLink,
|
||||||
removeLinksOf,
|
removeLinksOf,
|
||||||
|
reportCards,
|
||||||
|
reportDevices,
|
||||||
setDocumentationColor,
|
setDocumentationColor,
|
||||||
setDocumentationFont,
|
setDocumentationFont,
|
||||||
setDocumentationFrame,
|
setDocumentationFrame,
|
||||||
@@ -50,6 +52,7 @@ import { DeviceSheet } from './sheet/DeviceSheet.js';
|
|||||||
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
|
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
|
||||||
import { useLanguage, useTranslate } from './i18n/i18n.js';
|
import { useLanguage, useTranslate } from './i18n/i18n.js';
|
||||||
import { CaptureWindow } from './simulation/CaptureWindow.js';
|
import { CaptureWindow } from './simulation/CaptureWindow.js';
|
||||||
|
import { captureWords, reportTraffic } from './simulation/captureCells.js';
|
||||||
import { ModeBar, type EditorMode } from './simulation/ModeBar.js';
|
import { ModeBar, type EditorMode } from './simulation/ModeBar.js';
|
||||||
import { DropControl } from './simulation/DropControl.js';
|
import { DropControl } from './simulation/DropControl.js';
|
||||||
import { ViewerWindow, type ViewerKind } from './simulation/ViewerWindow.js';
|
import { ViewerWindow, type ViewerKind } from './simulation/ViewerWindow.js';
|
||||||
@@ -297,6 +300,16 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
|||||||
saveFile(await networkImage(view, names.footer), names.file);
|
saveFile(await networkImage(view, names.footer), names.file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** „Report“: Java's report with the image, the devices and each card's frames so far, as a PDF under the project's name (pdf-lib loaded only now). */
|
||||||
|
const makeReport = async () => {
|
||||||
|
const names = exportNames(projectName);
|
||||||
|
const cards = reportCards(doc);
|
||||||
|
const [image, frames, { reportPdf }] = await Promise.all([networkImage(view, names.footer), simulation.captures(cards.map((card) => card.portId)), import('./report/reportPdf.js')]);
|
||||||
|
const traffic = reportTraffic(cards, frames, simulation.clock, captureWords(t, language));
|
||||||
|
const bytes = await reportPdf({ ...(projectName === undefined ? {} : { projectName }), png: new Uint8Array(await image.arrayBuffer()), image: { width: 0, height: 0 }, devices: reportDevices(doc), traffic, t });
|
||||||
|
saveFile(new Blob([new Uint8Array(bytes)], { type: 'application/pdf' }), names.report);
|
||||||
|
};
|
||||||
|
|
||||||
const changeMode = (mode: EditorMode) => {
|
const changeMode = (mode: EditorMode) => {
|
||||||
setDocumenting(mode === 'documentation');
|
setDocumenting(mode === 'documentation');
|
||||||
if (mode === 'simulation') simulation.start(language);
|
if (mode === 'simulation') simulation.start(language);
|
||||||
@@ -363,7 +376,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', height: '100%' }}>
|
<div style={{ display: 'flex', height: '100%' }}>
|
||||||
{simulating ? null : documenting ? (
|
{simulating ? null : documenting ? (
|
||||||
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} />
|
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} onReport={makeReport} />
|
||||||
) : (
|
) : (
|
||||||
<Palette
|
<Palette
|
||||||
cableTool={cableTool}
|
cableTool={cableTool}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -201,10 +201,11 @@ export function paintNetworkImage(context: Painter, view: TopologyView, icons: R
|
|||||||
return layout;
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The footer's words and the image's file name: the project's name and that name as a PNG, as Java names it after its file; without a name „Filius“. */
|
/** The footer's words and the files' names: the project's name, the image and the report named after it, as Java names them after its file; without a name „Filius“. */
|
||||||
export function exportNames(projectName: string | undefined): { readonly footer: string; readonly file: string } {
|
export function exportNames(projectName: string | undefined): { readonly footer: string; readonly file: string; readonly report: string } {
|
||||||
const name = projectName?.trim() ?? '';
|
const name = projectName?.trim() ?? '';
|
||||||
if (name === '') return { footer: 'Filius', file: 'Filius.png' };
|
if (name === '') return { footer: 'Filius', file: 'Filius.png', report: 'Filius.pdf' };
|
||||||
const dot = name.lastIndexOf('.');
|
const dot = name.lastIndexOf('.');
|
||||||
return { footer: name, file: `${dot > 0 ? name.slice(0, dot) : name}.png` };
|
const stem = dot > 0 ? name.slice(0, dot) : name;
|
||||||
|
return { footer: name, file: `${stem}.png`, report: `${stem}.pdf` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
// and „Gliederungsfläche“ with Java's icons. An item is dragged onto the
|
// and „Gliederungsfläche“ with Java's icons. An item is dragged onto the
|
||||||
// surface, where it lands with its middle under the pointer, as Java's does;
|
// surface, where it lands with its middle under the pointer, as Java's does;
|
||||||
// tapped, it lands in the middle of the view (Java's tap makes nothing).
|
// tapped, it lands in the middle of the view (Java's tap makes nothing).
|
||||||
// „Exportieren“ saves the surface as an image (F4.8c); if that fails, Java's
|
// „Exportieren“ saves the surface as an image (F4.8c), „Report“ the report as
|
||||||
// „Fehler beim Speichern aufgetreten!“ shows below it.
|
// a PDF (F4.8d); if that fails, Java's „Fehler beim Speichern aufgetreten!“
|
||||||
|
// shows below them.
|
||||||
|
|
||||||
import { useRef, useState, type CSSProperties } from 'react';
|
import { useRef, useState, type CSSProperties } from 'react';
|
||||||
import type { DocumentationItemKind } from '@mdg-filius/core';
|
import type { DocumentationItemKind } from '@mdg-filius/core';
|
||||||
@@ -12,12 +13,15 @@ import { useTranslate } from '../i18n/i18n.js';
|
|||||||
import addRectangle from '../assets/documentation/add_small.png';
|
import addRectangle from '../assets/documentation/add_small.png';
|
||||||
import addText from '../assets/documentation/add_text_small.png';
|
import addText from '../assets/documentation/add_text_small.png';
|
||||||
import exportIcon from '../assets/documentation/download_small.png';
|
import exportIcon from '../assets/documentation/download_small.png';
|
||||||
|
import reportIcon from '../assets/documentation/pdf_small.png';
|
||||||
|
|
||||||
export interface DocumentationSidebarProps {
|
export interface DocumentationSidebarProps {
|
||||||
/** An item dropped at a point of the screen, or tapped (no point). */
|
/** An item dropped at a point of the screen, or tapped (no point). */
|
||||||
readonly onItem: (kind: DocumentationItemKind, at: { readonly clientX: number; readonly clientY: number } | null) => void;
|
readonly onItem: (kind: DocumentationItemKind, at: { readonly clientX: number; readonly clientY: number } | null) => void;
|
||||||
/** „Exportieren“: the surface saved as an image; rejected, it failed. */
|
/** „Exportieren“: the surface saved as an image; rejected, it failed. */
|
||||||
readonly onExport: () => Promise<void>;
|
readonly onExport: () => Promise<void>;
|
||||||
|
/** „Report“: the report saved as a PDF; rejected, it failed. */
|
||||||
|
readonly onReport: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEMS: readonly { readonly kind: DocumentationItemKind; readonly icon: string; readonly label: string }[] = [
|
const ITEMS: readonly { readonly kind: DocumentationItemKind; readonly icon: string; readonly label: string }[] = [
|
||||||
@@ -44,7 +48,7 @@ const item: CSSProperties = {
|
|||||||
touchAction: 'none',
|
touchAction: 'none',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DocumentationSidebar({ onItem, onExport }: DocumentationSidebarProps) {
|
export function DocumentationSidebar({ onItem, onExport, onReport }: DocumentationSidebarProps) {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const [ghost, setGhost] = useState<{ kind: DocumentationItemKind; x: number; y: number } | null>(null);
|
const [ghost, setGhost] = useState<{ kind: DocumentationItemKind; x: number; y: number } | null>(null);
|
||||||
const press = useRef<{ kind: DocumentationItemKind; x: number; y: number; dragging: boolean } | null>(null);
|
const press = useRef<{ kind: DocumentationItemKind; x: number; y: number; dragging: boolean } | null>(null);
|
||||||
@@ -52,10 +56,10 @@ export function DocumentationSidebar({ onItem, onExport }: DocumentationSidebarP
|
|||||||
const afterDrag = useRef(false);
|
const afterDrag = useRef(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const exportImage = () => {
|
const save = (make: () => Promise<void>) => () => {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
onExport().then(
|
make().then(
|
||||||
() => setExporting(false),
|
() => setExporting(false),
|
||||||
() => {
|
() => {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
@@ -124,10 +128,14 @@ export function DocumentationSidebar({ onItem, onExport }: DocumentationSidebarP
|
|||||||
{t(each.label)}
|
{t(each.label)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button type="button" style={{ ...item, cursor: exporting ? 'progress' : 'pointer' }} title={t('docu.exportHint')} disabled={exporting} onClick={exportImage}>
|
<button type="button" style={{ ...item, cursor: exporting ? 'progress' : 'pointer' }} title={t('docu.exportHint')} disabled={exporting} onClick={save(onExport)}>
|
||||||
<img src={exportIcon} alt="" width={40} height={40} draggable={false} />
|
<img src={exportIcon} alt="" width={40} height={40} draggable={false} />
|
||||||
{t('docu.export')}
|
{t('docu.export')}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" style={{ ...item, cursor: exporting ? 'progress' : 'pointer' }} title={t('docu.reportHint')} disabled={exporting} onClick={save(onReport)}>
|
||||||
|
<img src={reportIcon} alt="" width={40} height={40} draggable={false} />
|
||||||
|
{t('docu.report')}
|
||||||
|
</button>
|
||||||
{failed ? (
|
{failed ? (
|
||||||
<p role="alert" style={{ margin: 0, fontSize: 12, color: 'var(--danger)' }}>
|
<p role="alert" style={{ margin: 0, fontSize: 12, color: 'var(--danger)' }}>
|
||||||
{t('docu.saveFailed')}
|
{t('docu.saveFailed')}
|
||||||
|
|||||||
@@ -109,6 +109,8 @@
|
|||||||
"docu.fontSize": "Schriftgröße",
|
"docu.fontSize": "Schriftgröße",
|
||||||
"docu.fontStyle": "Schriftstil",
|
"docu.fontStyle": "Schriftstil",
|
||||||
"docu.rectangle": "Gliederungsfläche",
|
"docu.rectangle": "Gliederungsfläche",
|
||||||
|
"docu.report": "Report",
|
||||||
|
"docu.reportHint": "Report als PDF-Dokument erstellen",
|
||||||
"docu.saveFailed": "Fehler beim Speichern aufgetreten!",
|
"docu.saveFailed": "Fehler beim Speichern aufgetreten!",
|
||||||
"docu.sidebar": "Dokumentation",
|
"docu.sidebar": "Dokumentation",
|
||||||
"docu.sizeLarge": "Groß",
|
"docu.sizeLarge": "Groß",
|
||||||
@@ -350,6 +352,20 @@
|
|||||||
"program.textEditor": "Text-Editor",
|
"program.textEditor": "Text-Editor",
|
||||||
"program.webBrowser": "Webbrowser",
|
"program.webBrowser": "Webbrowser",
|
||||||
"program.webServer": "Webserver",
|
"program.webServer": "Webserver",
|
||||||
|
"report.apps": "Anwendungen",
|
||||||
|
"report.baseConfig": "Basiskonfiguration",
|
||||||
|
"report.components": "Komponenten",
|
||||||
|
"report.data": "Daten",
|
||||||
|
"report.dnsRecords": "Konfiguration DNS-Server",
|
||||||
|
"report.domain": "Domainname",
|
||||||
|
"report.nics": "Netzwerkschnittstellen",
|
||||||
|
"report.no": "Nein",
|
||||||
|
"report.overview": "Übersicht",
|
||||||
|
"report.routingTable": "Weiterleitungstabelle",
|
||||||
|
"report.title": "Filius-Dokumentation",
|
||||||
|
"report.traffic": "Datenaustausch",
|
||||||
|
"report.type": "Typ",
|
||||||
|
"report.yes": "Ja",
|
||||||
"route.destinationNetwork": "Ziel",
|
"route.destinationNetwork": "Ziel",
|
||||||
"route.gateway": "Nächstes Gateway",
|
"route.gateway": "Nächstes Gateway",
|
||||||
"route.interfaceIPAddress": "Über Schnittstelle",
|
"route.interfaceIPAddress": "Über Schnittstelle",
|
||||||
|
|||||||
@@ -109,6 +109,8 @@
|
|||||||
"docu.fontSize": "Font Size",
|
"docu.fontSize": "Font Size",
|
||||||
"docu.fontStyle": "Font Style",
|
"docu.fontStyle": "Font Style",
|
||||||
"docu.rectangle": "Structure Field",
|
"docu.rectangle": "Structure Field",
|
||||||
|
"docu.report": "Report",
|
||||||
|
"docu.reportHint": "Create report as PDF document",
|
||||||
"docu.saveFailed": "Error occurred!",
|
"docu.saveFailed": "Error occurred!",
|
||||||
"docu.sidebar": "Documentation",
|
"docu.sidebar": "Documentation",
|
||||||
"docu.sizeLarge": "Large",
|
"docu.sizeLarge": "Large",
|
||||||
@@ -350,6 +352,20 @@
|
|||||||
"program.textEditor": "Text editor",
|
"program.textEditor": "Text editor",
|
||||||
"program.webBrowser": "Webbrowser",
|
"program.webBrowser": "Webbrowser",
|
||||||
"program.webServer": "Webserver",
|
"program.webServer": "Webserver",
|
||||||
|
"report.apps": "Applications",
|
||||||
|
"report.baseConfig": "Base Configuration",
|
||||||
|
"report.components": "Components",
|
||||||
|
"report.data": "Data",
|
||||||
|
"report.dnsRecords": "Configuration DNS Server",
|
||||||
|
"report.domain": "Domain Name",
|
||||||
|
"report.nics": "Network Interfaces",
|
||||||
|
"report.no": "No",
|
||||||
|
"report.overview": "Overview",
|
||||||
|
"report.routingTable": "Routing/Forwarding Table",
|
||||||
|
"report.title": "Filius Documentation",
|
||||||
|
"report.traffic": "Data Exchange",
|
||||||
|
"report.type": "Type",
|
||||||
|
"report.yes": "Yes",
|
||||||
"route.destinationNetwork": "Destination",
|
"route.destinationNetwork": "Destination",
|
||||||
"route.gateway": "Next gateway",
|
"route.gateway": "Next gateway",
|
||||||
"route.interfaceIPAddress": "NIC",
|
"route.interfaceIPAddress": "NIC",
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// FILIUS' report (ReportGenerator, measured with tools/java-oracle's
|
||||||
|
// ReportRun), laid out as Java lays it out: the title (Java's word and the
|
||||||
|
// project's name), the project's name small below it; „1. Übersicht“ with the
|
||||||
|
// exported image; on a new page „2. Komponenten“ with each device that has
|
||||||
|
// cards — its base configuration, its cards, a computer's programs, a DNS
|
||||||
|
// server's records, its forwarding table, a home router's port forwarding —;
|
||||||
|
// on a new page „3. Datenaustausch“ with each card that saw frames, its rows
|
||||||
|
// as the data exchange window has them with addresses (Lauscher.getDaten
|
||||||
|
// with inheritAddress). Fonts, sizes and colours are Java's.
|
||||||
|
//
|
||||||
|
// Not adopted from Java: "true" and "false" for DHCP — Java's own „Ja“ and
|
||||||
|
// „Nein“ (main_dlg_YES, main_dlg_NO) instead. The programs are in the order
|
||||||
|
// they were installed and the cards in the order of the devices (Java's are
|
||||||
|
// in the order of a hash table). Below the title the project's name, not a
|
||||||
|
// path, which no browser knows.
|
||||||
|
|
||||||
|
import type { ReportDevice } from '@mdg-filius/doc';
|
||||||
|
import { ReportWriter, type ReportFonts, type ReportOp, type Rgb, type TableCell, type TextStyle } from './reportWriter.js';
|
||||||
|
|
||||||
|
const BLACK: Rgb = [0, 0, 0];
|
||||||
|
/** BaseColor.DARK_GRAY. */
|
||||||
|
const DARK_GRAY: Rgb = [64, 64, 64];
|
||||||
|
const TITLE_COLOR: Rgb = [30, 50, 120];
|
||||||
|
const TRAFFIC_HEADER: Rgb = [230, 240, 255];
|
||||||
|
const TRAFFIC_STRIPE: Rgb = [240, 240, 240];
|
||||||
|
|
||||||
|
export const SMALL_FONT: TextStyle = { font: 'Helvetica', size: 8, color: BLACK };
|
||||||
|
export const SMALL_BOLD_FONT: TextStyle = { font: 'Helvetica-Bold', size: 8, color: BLACK };
|
||||||
|
export const BOLD_FONT: TextStyle = { font: 'Helvetica-Bold', size: 10, color: BLACK };
|
||||||
|
export const DEFAULT_FONT: TextStyle = { font: 'Helvetica', size: 10, color: BLACK };
|
||||||
|
export const TITLE_FONT: TextStyle = { font: 'Helvetica-Bold', size: 18, color: TITLE_COLOR };
|
||||||
|
/** A section's heading: 16 less twice its depth. */
|
||||||
|
export const sectionFont = (level: 1 | 2 | 3): TextStyle => ({ font: 'Helvetica-Bold', size: 16 - 2 * level, color: DARK_GRAY });
|
||||||
|
|
||||||
|
/** The columns of the data exchange (Lauscher's), fixed. */
|
||||||
|
export const TRAFFIC_WIDTHS = [20, 60, 80, 80, 40, 50, 180] as const;
|
||||||
|
|
||||||
|
export interface ReportInput {
|
||||||
|
/** The project's name, its file's. */
|
||||||
|
readonly projectName?: string;
|
||||||
|
/** The exported image's size in pixels. */
|
||||||
|
readonly image: { readonly width: number; readonly height: number };
|
||||||
|
readonly devices: readonly ReportDevice[];
|
||||||
|
/** The cards that saw frames: the heading („name - address“) and the rows' cells. */
|
||||||
|
readonly traffic: readonly { readonly title: string; readonly header: readonly string[]; readonly rows: readonly (readonly string[])[] }[];
|
||||||
|
/** The editor's words. */
|
||||||
|
readonly t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The report's title: Java's word and, with a name, the name without its extension. */
|
||||||
|
export function reportTitle(t: (key: string) => string, projectName: string | undefined): string {
|
||||||
|
const name = projectName?.trim() ?? '';
|
||||||
|
if (name === '') return t('report.title');
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
return `${t('report.title')}: ${dot >= 0 ? name.slice(0, dot) : name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The report's pages, as Java's ReportGenerator adds to its document. */
|
||||||
|
export function reportPages(input: ReportInput, fonts: ReportFonts): ReportOp[][] {
|
||||||
|
const { t } = input;
|
||||||
|
const writer = new ReportWriter(fonts);
|
||||||
|
const numbers = { first: 0, second: 0 };
|
||||||
|
// Section.constructTitle: the first level numbered "1. ", the second "1.2. ", the third not.
|
||||||
|
const section = (title: string, level: 1 | 2 | 3) => {
|
||||||
|
let number = '';
|
||||||
|
if (level === 1) {
|
||||||
|
numbers.first += 1;
|
||||||
|
numbers.second = 0;
|
||||||
|
number = `${numbers.first}. `;
|
||||||
|
} else if (level === 2) {
|
||||||
|
numbers.second += 1;
|
||||||
|
number = `${numbers.first}.${numbers.second}. `;
|
||||||
|
}
|
||||||
|
const style = sectionFont(level);
|
||||||
|
writer.paragraph(
|
||||||
|
number === ''
|
||||||
|
? [{ text: title, style }]
|
||||||
|
: [
|
||||||
|
{ text: number, style },
|
||||||
|
{ text: title, style },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const header = (text: string): TableCell => ({ text, style: BOLD_FONT });
|
||||||
|
const cell = (text: string): TableCell => ({ text, style: DEFAULT_FONT });
|
||||||
|
|
||||||
|
writer.paragraph([{ text: reportTitle(t, input.projectName), style: TITLE_FONT }], 'center');
|
||||||
|
const name = input.projectName?.trim() ?? '';
|
||||||
|
if (name !== '') writer.chunk(name, SMALL_FONT);
|
||||||
|
writer.newline();
|
||||||
|
|
||||||
|
section(t('report.overview'), 1);
|
||||||
|
writer.image(input.image);
|
||||||
|
writer.nextPage();
|
||||||
|
|
||||||
|
section(t('report.components'), 1);
|
||||||
|
for (const device of input.devices) {
|
||||||
|
section(device.label, 2);
|
||||||
|
section(t('report.baseConfig'), 3);
|
||||||
|
const base = [
|
||||||
|
[header(t('sheet.defaultGateway')), cell(device.gateway)],
|
||||||
|
[header(t('sheet.dnsServer')), cell(device.dnsServer)],
|
||||||
|
];
|
||||||
|
if (device.dhcpClient !== undefined) base.push([header(t('sheet.dhcpClient')), cell(t(device.dhcpClient ? 'report.yes' : 'report.no'))]);
|
||||||
|
writer.table({ widths: [180, 360], align: 'left', rows: base });
|
||||||
|
writer.newline();
|
||||||
|
|
||||||
|
section(t('report.nics'), 3);
|
||||||
|
writer.table({
|
||||||
|
widths: [180, 180, 180],
|
||||||
|
align: 'left',
|
||||||
|
rows: [
|
||||||
|
[header(t('sheet.macAddress')), header(t('sheet.ipAddress')), header(t('sheet.subnetMask'))],
|
||||||
|
...device.cards.map((card) => [cell(card.macAddress), cell(card.ipAddress), cell(card.subnetMask)]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
writer.newline();
|
||||||
|
|
||||||
|
if (device.programs !== undefined) {
|
||||||
|
section(t('report.apps'), 3);
|
||||||
|
writer.chunk(`${t('installation.installed')} `, BOLD_FONT);
|
||||||
|
writer.chunk(device.programs.length === 0 ? '-' : device.programs.map((program) => t(`program.${program}`)).join(', '), DEFAULT_FONT);
|
||||||
|
writer.newline();
|
||||||
|
writer.newline();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (device.dnsRecords !== undefined) {
|
||||||
|
section(t('report.dnsRecords'), 3);
|
||||||
|
writer.table({
|
||||||
|
widths: [180, 60, 260],
|
||||||
|
align: 'left',
|
||||||
|
rows: [
|
||||||
|
[header(t('report.domain')), header(t('report.type')), header(t('report.data'))],
|
||||||
|
...device.dnsRecords.map((record) => [cell(record.domainName), cell(record.type), cell(record.rdata)]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
writer.newline();
|
||||||
|
}
|
||||||
|
|
||||||
|
section(t('report.routingTable'), 3);
|
||||||
|
writer.table({
|
||||||
|
widths: [180, 180, 180, 180],
|
||||||
|
align: 'left',
|
||||||
|
rows: [
|
||||||
|
[header(t('route.destinationNetwork')), header(t('route.subnetMask')), header(t('route.gateway')), header(t('route.interfaceIPAddress'))],
|
||||||
|
...device.routes.map((route) => [cell(route.destinationNetwork), cell(route.subnetMask), cell(route.nextHop), cell(route.interfaceIPAddress)]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
writer.newline();
|
||||||
|
|
||||||
|
if (device.portForwarding !== undefined && device.portForwarding.length > 0) {
|
||||||
|
section(t('sheet.portForwarding'), 3);
|
||||||
|
writer.table({
|
||||||
|
widths: [180, 180, 180, 180],
|
||||||
|
align: 'left',
|
||||||
|
rows: [
|
||||||
|
[header(t('forwarding.protocolValue')), header(t('forwarding.publicPortValue')), header(t('forwarding.lanIPAddress')), header(t('forwarding.lanPortValue'))],
|
||||||
|
...device.portForwarding.map((row) => row.map(cell)),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
writer.newline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer.nextPage();
|
||||||
|
|
||||||
|
if (input.traffic.length > 0) section(t('report.traffic'), 1);
|
||||||
|
for (const card of input.traffic) {
|
||||||
|
section(card.title, 2);
|
||||||
|
writer.table({
|
||||||
|
widths: TRAFFIC_WIDTHS,
|
||||||
|
fixed: true,
|
||||||
|
align: 'center',
|
||||||
|
rows: [
|
||||||
|
card.header.map((text) => ({ text, style: SMALL_BOLD_FONT, background: TRAFFIC_HEADER })),
|
||||||
|
...card.rows.map((row, index) => row.map((text): TableCell => (index % 2 === 1 ? { text, style: SMALL_FONT, background: TRAFFIC_STRIPE } : { text, style: SMALL_FONT }))),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
writer.newline();
|
||||||
|
}
|
||||||
|
return writer.finish();
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// The report as a PDF file, with pdf-lib: the pages report.ts lays out drawn
|
||||||
|
// in the PDF's own Helvetica (as iText's report has it, without embedding a
|
||||||
|
// font), the exported image on the first. Widths are measured character by
|
||||||
|
// character, without kerning, as iText measures and as pdf-lib draws (its
|
||||||
|
// widthOfTextAtSize kerns). A character Helvetica cannot write (it writes
|
||||||
|
// Windows-1252) is written "?".
|
||||||
|
|
||||||
|
import { PDFDocument, rgb, StandardFonts, type PDFFont } from 'pdf-lib';
|
||||||
|
import { reportPages, reportTitle, type ReportInput } from './report.js';
|
||||||
|
import type { ReportFont, ReportFonts, ReportOp } from './reportWriter.js';
|
||||||
|
|
||||||
|
/** ReportGenerator.initDocument's creator. */
|
||||||
|
export const REPORT_CREATOR = 'Filius (www.lernsoftware-filius.de)';
|
||||||
|
|
||||||
|
/** The fonts' measures and what they can write. */
|
||||||
|
export function pdfFonts(fonts: Readonly<Record<ReportFont, PDFFont>>): ReportFonts {
|
||||||
|
const writable = new Set(fonts.Helvetica.getCharacterSet());
|
||||||
|
const widths = new Map<string, number>();
|
||||||
|
const width = (character: string, font: ReportFont) => {
|
||||||
|
const key = `${font}\u0000${character}`;
|
||||||
|
let known = widths.get(key);
|
||||||
|
if (known === undefined) {
|
||||||
|
known = fonts[font].widthOfTextAtSize(character, 1000);
|
||||||
|
widths.set(key, known);
|
||||||
|
}
|
||||||
|
return known;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
width: (text, font, size) => {
|
||||||
|
let sum = 0;
|
||||||
|
for (const character of text) sum += width(character, font);
|
||||||
|
return (sum * size) / 1000;
|
||||||
|
},
|
||||||
|
clean: (text) => [...text.replaceAll('\t', ' ')].map((character) => (writable.has(character.codePointAt(0)!) ? character : '?')).join(''),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The report as a PDF: its pages, the image (a PNG) on the first, Java's title and creator. */
|
||||||
|
export async function reportPdf(input: ReportInput & { readonly png: Uint8Array }, date: Date = new Date()): Promise<Uint8Array> {
|
||||||
|
const pdf = await PDFDocument.create();
|
||||||
|
const fonts: Record<ReportFont, PDFFont> = {
|
||||||
|
Helvetica: await pdf.embedFont(StandardFonts.Helvetica),
|
||||||
|
'Helvetica-Bold': await pdf.embedFont(StandardFonts.HelveticaBold),
|
||||||
|
};
|
||||||
|
const image = await pdf.embedPng(input.png);
|
||||||
|
const pages = reportPages({ ...input, image: { width: image.width, height: image.height } }, pdfFonts(fonts));
|
||||||
|
pdf.setTitle(reportTitle(input.t, input.projectName));
|
||||||
|
pdf.setCreator(REPORT_CREATOR);
|
||||||
|
pdf.setCreationDate(date);
|
||||||
|
pdf.setModificationDate(date);
|
||||||
|
const color = ([red, green, blue]: readonly number[]) => rgb(red! / 255, green! / 255, blue! / 255);
|
||||||
|
for (const ops of pages) {
|
||||||
|
const page = pdf.addPage([595, 842]);
|
||||||
|
for (const op of ops as readonly ReportOp[]) {
|
||||||
|
if (op.kind === 'text') page.drawText(op.text, { x: op.x, y: op.y, size: op.size, font: fonts[op.font], color: color(op.color) });
|
||||||
|
else if (op.kind === 'rect') page.drawRectangle({ x: op.x, y: op.y, width: op.width, height: op.height, color: color(op.color) });
|
||||||
|
else page.drawImage(image, { x: op.x, y: op.y, width: op.width, height: op.height });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pdf.save();
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// The page layout of FILIUS' report, as iText 5 lays out what Java's
|
||||||
|
// ReportGenerator adds to its Document (measured with tools/java-oracle's
|
||||||
|
// ReportRun): A4 with 36 to spare around; a paragraph's lines 1.5 times its
|
||||||
|
// largest font apart, and loose text (a chunk added to the document) in the
|
||||||
|
// current line, at the last paragraph's spacing, until a new line ends it;
|
||||||
|
// lines broken after a space or a hyphen, a word wider than the line where
|
||||||
|
// it reaches the edge; a line that would end below the margin begins the
|
||||||
|
// next page. A table is 80 % of the width (or as wide as its columns when
|
||||||
|
// they are fixed), its columns in proportion, each cell's text 2 inside it,
|
||||||
|
// its lines as far apart as its font is large; a row that would end below
|
||||||
|
// the margin begins the next page. An image is as wide as the page, half a
|
||||||
|
// line below the last one. What is laid out is a list of pages of things to
|
||||||
|
// draw, in PDF coordinates (from the bottom up).
|
||||||
|
//
|
||||||
|
// Better than Java: an image taller than the rest of the page begins the
|
||||||
|
// next one, and one taller than a page is made smaller to fit (iText draws
|
||||||
|
// it beyond the page's edge).
|
||||||
|
|
||||||
|
export type ReportFont = 'Helvetica' | 'Helvetica-Bold';
|
||||||
|
export type Rgb = readonly [number, number, number];
|
||||||
|
|
||||||
|
export interface TextStyle {
|
||||||
|
readonly font: ReportFont;
|
||||||
|
readonly size: number;
|
||||||
|
readonly color: Rgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportOp =
|
||||||
|
| { readonly kind: 'text'; readonly text: string; readonly x: number; readonly y: number; readonly font: ReportFont; readonly size: number; readonly color: Rgb }
|
||||||
|
| { readonly kind: 'rect'; readonly x: number; readonly y: number; readonly width: number; readonly height: number; readonly color: Rgb }
|
||||||
|
| { readonly kind: 'image'; readonly x: number; readonly y: number; readonly width: number; readonly height: number };
|
||||||
|
|
||||||
|
/** How wide a text is in a font (without kerning, as iText measures), and the text as the font can write it. */
|
||||||
|
export interface ReportFonts {
|
||||||
|
readonly width: (text: string, font: ReportFont, size: number) => number;
|
||||||
|
readonly clean: (text: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableCell {
|
||||||
|
readonly text: string;
|
||||||
|
readonly style: TextStyle;
|
||||||
|
readonly background?: Rgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableSpec {
|
||||||
|
/** The columns' widths: in proportion, or fixed when `fixed`. */
|
||||||
|
readonly widths: readonly number[];
|
||||||
|
readonly fixed?: boolean;
|
||||||
|
readonly align: 'left' | 'center';
|
||||||
|
readonly rows: readonly (readonly TableCell[])[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PageSize.A4 and Document's margins. */
|
||||||
|
export const PAGE_WIDTH = 595;
|
||||||
|
export const PAGE_HEIGHT = 842;
|
||||||
|
export const MARGIN = 36;
|
||||||
|
const TOP = PAGE_HEIGHT - MARGIN;
|
||||||
|
const BOTTOM = MARGIN;
|
||||||
|
const LEFT = MARGIN;
|
||||||
|
const WIDTH = PAGE_WIDTH - 2 * MARGIN;
|
||||||
|
/** PdfPTable's width without a fixed one: 80 % of the page's. */
|
||||||
|
const TABLE_SHARE = 0.8;
|
||||||
|
/** PdfPCell's padding. */
|
||||||
|
const PADDING = 2;
|
||||||
|
|
||||||
|
/** Words in a style, as a paragraph or a chunk takes them. */
|
||||||
|
export interface Words {
|
||||||
|
readonly text: string;
|
||||||
|
readonly style: TextStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Piece extends Words {
|
||||||
|
/** The chunk it is of: a line draws each chunk's part as one text, as iText does. */
|
||||||
|
readonly chunk: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A text's pieces to break a line after: up to and with each space or hyphen. */
|
||||||
|
const breakable = (text: string) => text.match(/[^ -]*[ -]|[^ -]+/g) ?? [];
|
||||||
|
|
||||||
|
export class ReportWriter {
|
||||||
|
private readonly pages: ReportOp[][] = [[]];
|
||||||
|
private y = TOP;
|
||||||
|
/** The spacing of loose text: the last paragraph's. */
|
||||||
|
private leading = 0;
|
||||||
|
private pending: Piece[] = [];
|
||||||
|
|
||||||
|
constructor(private readonly fonts: ReportFonts) {}
|
||||||
|
|
||||||
|
private get page(): ReportOp[] {
|
||||||
|
return this.pages[this.pages.length - 1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private newPage(): void {
|
||||||
|
this.pages.push([]);
|
||||||
|
this.y = TOP;
|
||||||
|
}
|
||||||
|
|
||||||
|
private width(piece: Words): number {
|
||||||
|
return this.fonts.width(piece.text, piece.style.font, piece.style.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pieces broken into lines of a width. */
|
||||||
|
private lines(pieces: readonly Piece[], width: number): Piece[][] {
|
||||||
|
const lines: Piece[][] = [];
|
||||||
|
let line: Piece[] = [];
|
||||||
|
let used = 0;
|
||||||
|
const push = () => {
|
||||||
|
lines.push(line);
|
||||||
|
line = [];
|
||||||
|
used = 0;
|
||||||
|
};
|
||||||
|
for (const piece of pieces) {
|
||||||
|
for (const part of breakable(piece.text)) {
|
||||||
|
const word = { text: part, style: piece.style, chunk: piece.chunk };
|
||||||
|
const fits = used + this.width({ ...word, text: part.trimEnd() }) <= width;
|
||||||
|
if (fits) {
|
||||||
|
line.push(word);
|
||||||
|
used += this.width(word);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.length > 0) push();
|
||||||
|
// On a line of its own, broken where it reaches the edge (a word that fits is not).
|
||||||
|
for (const character of part) {
|
||||||
|
const one = { text: character, style: piece.style, chunk: piece.chunk };
|
||||||
|
if (line.length > 0 && used + this.width({ ...one, text: character.trimEnd() }) > width) push();
|
||||||
|
line.push(one);
|
||||||
|
used += this.width(one);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line.length > 0 || lines.length === 0) push();
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A line's pieces as texts to draw: one per run of a style, without the spaces at its end. */
|
||||||
|
private runs(line: readonly Piece[]): Piece[] {
|
||||||
|
const runs: Piece[] = [];
|
||||||
|
for (const piece of line) {
|
||||||
|
const last = runs[runs.length - 1];
|
||||||
|
if (last !== undefined && last.chunk === piece.chunk) runs[runs.length - 1] = { text: last.text + piece.text, style: last.style, chunk: last.chunk };
|
||||||
|
else runs.push(piece);
|
||||||
|
}
|
||||||
|
while (runs.length > 0) {
|
||||||
|
const last = runs[runs.length - 1]!;
|
||||||
|
const text = last.text.trimEnd();
|
||||||
|
if (text !== '') {
|
||||||
|
runs[runs.length - 1] = { text, style: last.style, chunk: last.chunk };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
runs.pop();
|
||||||
|
}
|
||||||
|
return runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes lines at a spacing, each a line below the last, on the next page where it would end below the margin. */
|
||||||
|
private writeLines(lines: readonly Piece[][], leading: number, align: 'left' | 'center'): void {
|
||||||
|
for (const line of lines) {
|
||||||
|
if (this.y - leading < BOTTOM) this.newPage();
|
||||||
|
this.y -= leading;
|
||||||
|
const runs = this.runs(line);
|
||||||
|
const used = runs.reduce((sum, run) => sum + this.width(run), 0);
|
||||||
|
let x = align === 'center' ? LEFT + (WIDTH - used) / 2 : LEFT;
|
||||||
|
for (const run of runs) {
|
||||||
|
this.page.push({ kind: 'text', text: run.text, x, y: this.y, font: run.style.font, size: run.style.size, color: run.style.color });
|
||||||
|
x += this.width(run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the current line of loose text, if there is one. */
|
||||||
|
private endPending(evenEmpty: boolean): void {
|
||||||
|
if (this.pending.length === 0 && !evenEmpty) return;
|
||||||
|
this.writeLines(this.lines(this.pending, WIDTH), this.leading, 'left');
|
||||||
|
this.pending = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private chunks = 0;
|
||||||
|
|
||||||
|
/** The pieces as the fonts write them, each its own chunk. */
|
||||||
|
private clean(pieces: readonly Words[]): Piece[] {
|
||||||
|
return pieces.map((piece) => ({ text: this.fonts.clean(piece.text), style: piece.style, chunk: this.chunks++ }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A paragraph (Paragraph): its own lines, 1.5 times its largest font apart; that spacing goes on for loose text. */
|
||||||
|
paragraph(pieces: readonly Words[], align: 'left' | 'center' = 'left'): void {
|
||||||
|
this.endPending(false);
|
||||||
|
this.leading = 1.5 * Math.max(...pieces.map((piece) => piece.style.size));
|
||||||
|
this.writeLines(this.lines(this.clean(pieces), WIDTH), this.leading, align);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loose text (a Chunk added to the document): in the current line. */
|
||||||
|
chunk(text: string, style: TextStyle): void {
|
||||||
|
this.pending.push(...this.clean([{ text, style }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new line (Chunk.NEWLINE): ends the current line, an empty one too. */
|
||||||
|
newline(): void {
|
||||||
|
this.endPending(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A table (PdfPTable), row by row; a row that would end below the margin begins the next page. */
|
||||||
|
table(spec: TableSpec): void {
|
||||||
|
this.endPending(false);
|
||||||
|
const total = spec.fixed === true ? spec.widths.reduce((sum, each) => sum + each, 0) : WIDTH * TABLE_SHARE;
|
||||||
|
const sum = spec.widths.reduce((all, each) => all + each, 0);
|
||||||
|
const widths = spec.widths.map((each) => (spec.fixed === true ? each : (each / sum) * total));
|
||||||
|
const left = spec.align === 'center' ? LEFT + (WIDTH - total) / 2 : LEFT;
|
||||||
|
for (const row of spec.rows) {
|
||||||
|
const cells = row.map((cell, index) => {
|
||||||
|
const lines = cell.text.split('\n').flatMap((part) => this.lines(this.clean([{ text: part, style: cell.style }]), widths[index]! - 2 * PADDING));
|
||||||
|
return { cell, lines };
|
||||||
|
});
|
||||||
|
// A cell's lines as far apart as its font is large.
|
||||||
|
const height = 2 * PADDING + Math.max(...cells.map(({ cell, lines }) => lines.length * cell.style.size));
|
||||||
|
if (this.y - height < BOTTOM) this.newPage();
|
||||||
|
let x = left;
|
||||||
|
cells.forEach(({ cell, lines }, index) => {
|
||||||
|
if (cell.background !== undefined) this.page.push({ kind: 'rect', x, y: this.y - height, width: widths[index]!, height, color: cell.background });
|
||||||
|
lines.forEach((line, number) => {
|
||||||
|
const y = this.y - PADDING - (number + 1) * cell.style.size;
|
||||||
|
let at = x + PADDING;
|
||||||
|
for (const run of this.runs(line)) {
|
||||||
|
this.page.push({ kind: 'text', text: run.text, x: at, y, font: run.style.font, size: run.style.size, color: run.style.color });
|
||||||
|
at += this.width(run);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
x += widths[index]!;
|
||||||
|
});
|
||||||
|
this.y -= height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An image (Image scaled to the page's width), half a line below the last line. */
|
||||||
|
image(pixels: { readonly width: number; readonly height: number }): void {
|
||||||
|
this.endPending(false);
|
||||||
|
let width = WIDTH;
|
||||||
|
let height = (WIDTH * pixels.height) / pixels.width;
|
||||||
|
let top = this.y - this.leading / 2;
|
||||||
|
if (top - height < BOTTOM && this.page.length > 0) {
|
||||||
|
this.newPage();
|
||||||
|
top = this.y - this.leading / 2;
|
||||||
|
}
|
||||||
|
if (top - height < BOTTOM) {
|
||||||
|
height = top - BOTTOM;
|
||||||
|
width = (height * pixels.width) / pixels.height;
|
||||||
|
}
|
||||||
|
this.page.push({ kind: 'image', x: LEFT, y: top - height, width, height });
|
||||||
|
this.y = top - height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new page (Chunk.NEXTPAGE). */
|
||||||
|
nextPage(): void {
|
||||||
|
this.endPending(false);
|
||||||
|
this.newPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The pages, the last one without an empty end. */
|
||||||
|
finish(): ReportOp[][] {
|
||||||
|
this.endPending(false);
|
||||||
|
return this.page.length === 0 && this.pages.length > 1 ? this.pages.slice(0, -1) : [...this.pages];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,3 +69,23 @@ export function exportText(frames: readonly TraceFrame[], clock: number, words:
|
|||||||
frames.map((frame) => frameCells(frame, clock, words)),
|
frames.map((frame) => frameCells(frame, clock, words)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The card's rows as the report writes them (Lauscher.getDaten with addresses): every layer's row of every frame, seven cells each. */
|
||||||
|
export function reportRows(frames: readonly TraceFrame[], clock: number, words: CaptureWords): string[][] {
|
||||||
|
return frames.flatMap((frame) =>
|
||||||
|
frame.rows.map((row) => [String(row.number), timeOfDay(clock, row.timeMilliseconds, true), row.source ?? '', row.destination ?? '', row.protocol, words.layers[row.layer], commentOf(row, words)]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The report's data exchange: each card that saw frames, under its device's label and its address, with its rows. */
|
||||||
|
export function reportTraffic(
|
||||||
|
cards: readonly { readonly portId: string; readonly label: string; readonly ipAddress: string }[],
|
||||||
|
frames: ReadonlyMap<string, readonly TraceFrame[]>,
|
||||||
|
clock: number,
|
||||||
|
words: CaptureWords,
|
||||||
|
): { title: string; header: readonly string[]; rows: string[][] }[] {
|
||||||
|
return cards.flatMap((card) => {
|
||||||
|
const seen = frames.get(card.portId) ?? [];
|
||||||
|
return seen.length === 0 ? [] : [{ title: `${card.label} - ${card.ipAddress}`, header: words.header, rows: reportRows(seen, clock, words) }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export interface Simulation {
|
|||||||
clearCapture(): void;
|
clearCapture(): void;
|
||||||
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
|
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
|
||||||
watch(topics: readonly Topic[]): () => void;
|
watch(topics: readonly Topic[]): () => void;
|
||||||
|
/** Every frame of these cards so far, as the room has them now (for the report); rejected if the room does not answer within 10 s. */
|
||||||
|
captures(portIds: readonly string[]): Promise<ReadonlyMap<string, readonly TraceFrame[]>>;
|
||||||
/** An action in a program's window (the room answers with its new lines). */
|
/** An action in a program's window (the room answers with its new lines). */
|
||||||
act(node: string, app: AppKind, action: string, args?: Readonly<Record<string, unknown>>): void;
|
act(node: string, app: AppKind, action: string, args?: Readonly<Record<string, unknown>>): void;
|
||||||
}
|
}
|
||||||
@@ -66,6 +68,8 @@ const NO_LINKS: ReadonlySet<string> = new Set();
|
|||||||
export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimHost): Simulation {
|
export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimHost): Simulation {
|
||||||
const host = useRef<SimHost | null>(null);
|
const host = useRef<SimHost | null>(null);
|
||||||
const watched = useRef(new Map<Topic, number>());
|
const watched = useRef(new Map<Topic, number>());
|
||||||
|
/** The cards a report waits for: each answered with all its frames once subscribed anew. */
|
||||||
|
const awaiting = useRef(new Set<{ readonly wanted: ReadonlySet<string>; readonly got: Map<string, readonly TraceFrame[]>; readonly done: () => void }>());
|
||||||
const [mode, setMode] = useState<SimMode>('design');
|
const [mode, setMode] = useState<SimMode>('design');
|
||||||
const [speed, setSpeedState] = useState(100);
|
const [speed, setSpeedState] = useState(100);
|
||||||
const [activeLinks, setActiveLinks] = useState<ReadonlySet<string>>(NO_LINKS);
|
const [activeLinks, setActiveLinks] = useState<ReadonlySet<string>>(NO_LINKS);
|
||||||
@@ -104,6 +108,13 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
case 'trace':
|
case 'trace':
|
||||||
|
if (message.reset) {
|
||||||
|
for (const each of awaiting.current) {
|
||||||
|
if (!each.wanted.has(message.portId)) continue;
|
||||||
|
each.got.set(message.portId, message.frames);
|
||||||
|
if (each.got.size === each.wanted.size) each.done();
|
||||||
|
}
|
||||||
|
}
|
||||||
setTraces((before) => new Map(before).set(message.portId, message.reset ? message.frames : (before.get(message.portId) ?? []).concat(message.frames)));
|
setTraces((before) => new Map(before).set(message.portId, message.reset ? message.frames : (before.get(message.portId) ?? []).concat(message.frames)));
|
||||||
return;
|
return;
|
||||||
case 'dropped':
|
case 'dropped':
|
||||||
@@ -153,9 +164,40 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const captures = useCallback((portIds: readonly string[]) => {
|
||||||
|
const made = host.current;
|
||||||
|
const wanted = new Set(portIds);
|
||||||
|
if (made === null || wanted.size === 0) return Promise.resolve(new Map<string, readonly TraceFrame[]>());
|
||||||
|
return new Promise<ReadonlyMap<string, readonly TraceFrame[]>>((resolve, reject) => {
|
||||||
|
const topics = [...wanted].map((portId): Topic => `trace:${portId}`);
|
||||||
|
const finish = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
awaiting.current.delete(request);
|
||||||
|
// What no window watches is let go again; what one does stays.
|
||||||
|
const unwatched = topics.filter((topic) => !watched.current.has(topic));
|
||||||
|
if (unwatched.length > 0) host.current?.send({ v: 1, kind: 'unsub', topics: unwatched });
|
||||||
|
};
|
||||||
|
const request = {
|
||||||
|
wanted,
|
||||||
|
got: new Map<string, readonly TraceFrame[]>(),
|
||||||
|
done: () => {
|
||||||
|
finish();
|
||||||
|
resolve(request.got);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
finish();
|
||||||
|
reject(new Error('the room sent no frames'));
|
||||||
|
}, 10_000);
|
||||||
|
awaiting.current.add(request);
|
||||||
|
// Subscribed anew, even where a window watches: the room answers each with all its frames.
|
||||||
|
made.send({ v: 1, kind: 'sub', topics });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const act = useCallback((node: string, app: AppKind, action: string, args: Readonly<Record<string, unknown>> = {}) => {
|
const act = useCallback((node: string, app: AppKind, action: string, args: Readonly<Record<string, unknown>> = {}) => {
|
||||||
host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() });
|
host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { mode, speed, activeLinks, nodes, apps, views, failed, clock, dropped, traces, start, stop, setSpeed, drop, clearCapture, watch, act };
|
return { mode, speed, activeLinks, nodes, apps, views, failed, clock, dropped, traces, start, stop, setSpeed, drop, clearCapture, watch, captures, act };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { captureRows, TCP_FLAG, type CapturedFrame, type EthernetFrame } from '@mdg-filius/core';
|
import { captureRows, TCP_FLAG, type CapturedFrame, type EthernetFrame } from '@mdg-filius/core';
|
||||||
import type { TraceFrame } from '@mdg-filius/protocol';
|
import type { TraceFrame } from '@mdg-filius/protocol';
|
||||||
import { translate } from '../src/i18n/i18n.js';
|
import { translate } from '../src/i18n/i18n.js';
|
||||||
import { captureWords, exportText, frameCells, frameDetails } from '../src/simulation/captureCells.js';
|
import { captureWords, exportText, frameCells, frameDetails, reportRows, reportTraffic } from '../src/simulation/captureCells.js';
|
||||||
|
|
||||||
/** A TCP segment with data from 10.0.0.1:50000 to 10.0.0.2:80, as the room sends it. */
|
/** A TCP segment with data from 10.0.0.1:50000 to 10.0.0.2:80, as the room sends it. */
|
||||||
function frame(number: number, timeMilliseconds: number): TraceFrame {
|
function frame(number: number, timeMilliseconds: number): TraceFrame {
|
||||||
@@ -76,3 +76,26 @@ describe("the data exchange window's cells", () => {
|
|||||||
expect(text.match(/^\+-/gm)).toHaveLength(2);
|
expect(text.match(/^\+-/gm)).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the report's data exchange", () => {
|
||||||
|
it('writes every layer of a frame as a row, with addresses, as Java\'s report does', () => {
|
||||||
|
expect(reportRows([frame(3, 1_234)], clock, words('de'))).toEqual([
|
||||||
|
['3', '08:30:01.234', '02:00:00:00:00:01', '02:00:00:00:00:02', '', 'Netzzugang', '0x800'],
|
||||||
|
['3', '08:30:01.234', '10.0.0.1', '10.0.0.2', 'IP', 'Vermittlung', 'Protokoll: 6, TTL: 64'],
|
||||||
|
['3', '08:30:01.234', '10.0.0.1:50000', '10.0.0.2:80', 'TCP', 'Transport', 'SEQ: 2.000.001, ACK: 1.000.001'],
|
||||||
|
['3', '08:30:01.234', '10.0.0.1:50000', '10.0.0.2:80', 'HTTP', 'Anwendung', 'GET / HTTP/1.1\nHost: x\n'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists each card that saw frames, under its device's label and address; one without frames not", () => {
|
||||||
|
const cards = [
|
||||||
|
{ portId: 'a', label: 'Rechner 1', ipAddress: '10.0.0.1' },
|
||||||
|
{ portId: 'b', label: 'Rechner 2', ipAddress: '10.0.0.2' },
|
||||||
|
{ portId: 'c', label: 'Rechner 3', ipAddress: '10.0.0.3' },
|
||||||
|
];
|
||||||
|
const traffic = reportTraffic(cards, new Map([['a', [frame(1, 0)]], ['b', []]]), clock, words('de'));
|
||||||
|
expect(traffic.map((card) => card.title)).toEqual(['Rechner 1 - 10.0.0.1']);
|
||||||
|
expect(traffic[0]!.header).toEqual(words('de').header);
|
||||||
|
expect(traffic[0]!.rows.length).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -258,6 +258,22 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
|
|||||||
'docu.color': 'guievents_msg13',
|
'docu.color': 'guievents_msg13',
|
||||||
'docu.export': 'docusidebar_msg5',
|
'docu.export': 'docusidebar_msg5',
|
||||||
'docu.exportHint': 'docusidebar_msg6',
|
'docu.exportHint': 'docusidebar_msg6',
|
||||||
|
'docu.report': 'docusidebar_msg7',
|
||||||
|
'docu.reportHint': 'docusidebar_msg8',
|
||||||
|
'report.title': 'report_title',
|
||||||
|
'report.overview': 'report_overview',
|
||||||
|
'report.components': 'report_component_config',
|
||||||
|
'report.baseConfig': 'report_base_config',
|
||||||
|
'report.nics': 'report_nics',
|
||||||
|
'report.apps': 'report_apps',
|
||||||
|
'report.routingTable': 'report_routing_table',
|
||||||
|
'report.traffic': 'report_network_traffic',
|
||||||
|
'report.dnsRecords': 'report_dns_rr',
|
||||||
|
'report.domain': 'report_domain',
|
||||||
|
'report.type': 'report_type',
|
||||||
|
'report.data': 'report_data',
|
||||||
|
'report.yes': 'main_dlg_YES',
|
||||||
|
'report.no': 'main_dlg_NO',
|
||||||
'capture.title': 'lauscherdialog_msg1',
|
'capture.title': 'lauscherdialog_msg1',
|
||||||
'capture.number': 'rp_lauscher_msg1',
|
'capture.number': 'rp_lauscher_msg1',
|
||||||
'capture.time': 'rp_lauscher_msg2',
|
'capture.time': 'rp_lauscher_msg2',
|
||||||
|
|||||||
@@ -144,16 +144,16 @@ describe('the words of a text in the image', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("the image's names", () => {
|
describe("the image's names", () => {
|
||||||
it("writes the project's name in the footer and names the image after it, as Java names it after its file", () => {
|
it("writes the project's name in the footer and names the image and the report after it, as Java names them after its file", () => {
|
||||||
expect(exportNames('nat_with_mapped_port.fls')).toEqual({ footer: 'nat_with_mapped_port.fls', file: 'nat_with_mapped_port.png' });
|
expect(exportNames('nat_with_mapped_port.fls')).toEqual({ footer: 'nat_with_mapped_port.fls', file: 'nat_with_mapped_port.png', report: 'nat_with_mapped_port.pdf' });
|
||||||
expect(exportNames('netz.v2.fls')).toEqual({ footer: 'netz.v2.fls', file: 'netz.v2.png' });
|
expect(exportNames('netz.v2.fls')).toEqual({ footer: 'netz.v2.fls', file: 'netz.v2.png', report: 'netz.v2.pdf' });
|
||||||
expect(exportNames('Netz')).toEqual({ footer: 'Netz', file: 'Netz.png' });
|
expect(exportNames('Netz')).toEqual({ footer: 'Netz', file: 'Netz.png', report: 'Netz.pdf' });
|
||||||
expect(exportNames('.fls')).toEqual({ footer: '.fls', file: '.fls.png' });
|
expect(exportNames('.fls')).toEqual({ footer: '.fls', file: '.fls.png', report: '.fls.pdf' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes „Filius“ without a name, as Java does without a file', () => {
|
it('writes „Filius“ without a name, as Java does without a file', () => {
|
||||||
expect(exportNames(undefined)).toEqual({ footer: 'Filius', file: 'Filius.png' });
|
expect(exportNames(undefined)).toEqual({ footer: 'Filius', file: 'Filius.png', report: 'Filius.pdf' });
|
||||||
expect(exportNames(' ')).toEqual({ footer: 'Filius', file: 'Filius.png' });
|
expect(exportNames(' ')).toEqual({ footer: 'Filius', file: 'Filius.png', report: 'Filius.pdf' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
|||||||
|
// FILIUS' report against Java's own (tools/java-oracle's ReportRun,
|
||||||
|
// report.java.json): every piece of text on Java's pages — its words, place,
|
||||||
|
// font, size and colour — laid out here from the same project: the title,
|
||||||
|
// the devices' pages of Internet_Komplett (real MAC addresses, no run), the
|
||||||
|
// data exchange of einfaches_rechnernetz_komplett after a run of 3 s (its
|
||||||
|
// rows taken from Java's pages, so that the layout is compared, not the
|
||||||
|
// run), a project without devices and an empty one.
|
||||||
|
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { PDFDocument, StandardFonts } from 'pdf-lib';
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { openProjectArchive } from '@mdg-filius/fls';
|
||||||
|
import { reportDevices, writeProject, type ReportDevice } from '@mdg-filius/doc';
|
||||||
|
import { translate } from '../src/i18n/i18n.js';
|
||||||
|
import { reportPages, reportTitle, SMALL_FONT, TRAFFIC_WIDTHS, type ReportInput } from '../src/report/report.js';
|
||||||
|
import { pdfFonts, REPORT_CREATOR, reportPdf } from '../src/report/reportPdf.js';
|
||||||
|
import { ReportWriter, type ReportFonts, type ReportOp } from '../src/report/reportWriter.js';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
interface JavaPdf {
|
||||||
|
readonly title: string;
|
||||||
|
readonly creator: string;
|
||||||
|
readonly pages: readonly {
|
||||||
|
readonly size: readonly number[];
|
||||||
|
readonly texts: readonly string[];
|
||||||
|
readonly images: readonly { x: number; y: number; width: number; height: number; pixels: readonly number[] }[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
const java = JSON.parse(readFileSync(join(here, 'report.java.json'), 'utf8')) as {
|
||||||
|
readonly projects: Readonly<Record<string, { readonly pdf: JavaPdf; readonly packetLogCards: readonly { mac: string; rows: number }[] }>>;
|
||||||
|
readonly empty: { readonly pdf: JavaPdf };
|
||||||
|
};
|
||||||
|
|
||||||
|
const t = (key: string) => translate('de', key);
|
||||||
|
let fonts: ReportFonts;
|
||||||
|
beforeAll(async () => {
|
||||||
|
const pdf = await PDFDocument.create();
|
||||||
|
fonts = pdfFonts({ Helvetica: await pdf.embedFont(StandardFonts.Helvetica), 'Helvetica-Bold': await pdf.embedFont(StandardFonts.HelveticaBold) });
|
||||||
|
});
|
||||||
|
|
||||||
|
function devicesOf(file: string): ReportDevice[] {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
let next = 0;
|
||||||
|
writeProject(
|
||||||
|
doc,
|
||||||
|
openProjectArchive(new Uint8Array(readFileSync(join(here, '../../../corpus/java-examples', file))), () => `id${next++}`),
|
||||||
|
);
|
||||||
|
return reportDevices(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A piece of text as Java's file has it: x|y|font|size|colour|text, the text without the spaces at its end. */
|
||||||
|
const javaText = (piece: string) => {
|
||||||
|
const [x, y, font, size, color, ...words] = piece.split('|');
|
||||||
|
return `${Number(x).toFixed(2)}|${Number(y).toFixed(2)}|${font}|${Number(size)}|${color}|${words.join('|').trimEnd()}`;
|
||||||
|
};
|
||||||
|
const ourText = (op: ReportOp) => (op.kind === 'text' ? `${op.x.toFixed(2)}|${op.y.toFixed(2)}|${op.font}|${op.size}|${op.color.join(',')}|${op.text.trimEnd()}` : undefined);
|
||||||
|
/** From the top down, left to right: iText writes paragraphs and tables in layers of their own, so the order of drawing differs. */
|
||||||
|
const byPlace = (pieces: readonly string[]) =>
|
||||||
|
[...pieces].sort((lhs, rhs) => {
|
||||||
|
const [lx, ly] = lhs.split('|').map(Number);
|
||||||
|
const [rx, ry] = rhs.split('|').map(Number);
|
||||||
|
return ry! - ly! || lx! - rx! || (lhs < rhs ? -1 : 1);
|
||||||
|
});
|
||||||
|
const texts = (ops: readonly ReportOp[]) => byPlace(ops.map(ourText).filter((text): text is string => text !== undefined));
|
||||||
|
const javaTexts = (pieces: readonly string[]) => byPlace(pieces.map(javaText));
|
||||||
|
|
||||||
|
describe("the report's devices, as Java's report", () => {
|
||||||
|
const file = 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls';
|
||||||
|
|
||||||
|
it('writes every device of Internet_Komplett as Java does, piece by piece', () => {
|
||||||
|
const javaPdf = java.projects[file]!.pdf;
|
||||||
|
const pages = reportPages(
|
||||||
|
{ projectName: file, image: { width: javaPdf.pages[0]!.images[0]!.pixels[0]!, height: javaPdf.pages[0]!.images[0]!.pixels[1]! }, devices: devicesOf(file), traffic: [], t },
|
||||||
|
fonts,
|
||||||
|
);
|
||||||
|
expect(pages.length).toBe(javaPdf.pages.length);
|
||||||
|
// Java's "true" and "false" are Java's own „Ja“ and „Nein“ here.
|
||||||
|
const ours = (index: number) => texts(pages[index]!).map((text) => text.replace(/\|Ja$/, '|true').replace(/\|Nein$/, '|false'));
|
||||||
|
for (let index = 1; index < pages.length; index++) expect(ours(index), `page ${index + 1}`).toEqual(javaTexts(javaPdf.pages[index]!.texts));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the report's first page, as Java's", () => {
|
||||||
|
const image = (pdf: JavaPdf) => ({ width: pdf.pages[0]!.images[0]!.pixels[0]!, height: pdf.pages[0]!.images[0]!.pixels[1]! });
|
||||||
|
const imageOps = (ops: readonly ReportOp[]) =>
|
||||||
|
ops.filter((op) => op.kind === 'image').map((op) => ({ x: op.x, y: Math.round(op.y * 100) / 100, width: op.width, height: Math.round(op.height * 100) / 100 }));
|
||||||
|
const javaImages = (pdf: JavaPdf, page: number) => pdf.pages[page]!.images.map(({ x, y, width, height }) => ({ x, y, width, height }));
|
||||||
|
|
||||||
|
it('writes the title, the name below it and the overview with the image as Java does', () => {
|
||||||
|
const file = 'einfaches_rechnernetz_komplett.fls';
|
||||||
|
const javaPdf = java.projects[file]!.pdf;
|
||||||
|
const pages = reportPages({ projectName: file, image: image(javaPdf), devices: [], traffic: [], t }, fonts);
|
||||||
|
// Below the title Java writes the file's path, here its name: the same place, font and size.
|
||||||
|
const withoutPath = (pieces: readonly string[]) => pieces.map((piece) => (piece.includes('|Helvetica|8|') ? piece.replace(/\|[^|]*$/, '|<name>') : piece));
|
||||||
|
expect(withoutPath(texts(pages[0]!))).toEqual(withoutPath(javaTexts(javaPdf.pages[0]!.texts)));
|
||||||
|
expect(texts(pages[0]!)).toContain('36.00|752.00|Helvetica|8|0,0,0|einfaches_rechnernetz_komplett.fls');
|
||||||
|
expect(imageOps(pages[0]!)).toEqual(javaImages(javaPdf, 0));
|
||||||
|
expect(javaPdf.title).toBe(reportTitle(t, file));
|
||||||
|
expect(javaPdf.pages.every((page) => page.size.join() === '595,842')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('breaks a long title as Java does, centred', () => {
|
||||||
|
const file = 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls';
|
||||||
|
const titleOf = (pieces: readonly string[]) => pieces.filter((piece) => piece.includes('|Helvetica-Bold|18|'));
|
||||||
|
const pages = reportPages({ projectName: file, image: image(java.projects[file]!.pdf), devices: [], traffic: [], t }, fonts);
|
||||||
|
expect(titleOf(texts(pages[0]!))).toEqual(titleOf(javaTexts(java.projects[file]!.pdf.pages[0]!.texts)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a project without devices as Java does: the overview, then „Komponenten“ alone', () => {
|
||||||
|
const javaPdf = java.projects['documentation.fls']!.pdf;
|
||||||
|
const pages = reportPages({ projectName: 'documentation.fls', image: image(javaPdf), devices: [], traffic: [], t }, fonts);
|
||||||
|
expect(pages.length).toBe(2);
|
||||||
|
expect(imageOps(pages[0]!)).toEqual(javaImages(javaPdf, 0));
|
||||||
|
expect(texts(pages[1]!)).toEqual(javaTexts(javaPdf.pages[1]!.texts));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes an empty project's report as Java does, but its image fits on its page (Java's runs beyond it)", () => {
|
||||||
|
const javaPdf = java.empty.pdf;
|
||||||
|
const pixels = { width: javaPdf.pages[1]!.images[0]!.pixels[0]!, height: javaPdf.pages[1]!.images[0]!.pixels[1]! };
|
||||||
|
const pages = reportPages({ image: pixels, devices: [], traffic: [], t }, fonts);
|
||||||
|
expect(pages.length).toBe(javaPdf.pages.length);
|
||||||
|
[0, 2].forEach((page) => expect(texts(pages[page]!)).toEqual(javaTexts(javaPdf.pages[page]!.texts)));
|
||||||
|
// Java's: as wide as the page, 915 high, from 795.5 down to -119.75; here as high as the page leaves.
|
||||||
|
expect(javaImages(javaPdf, 1)).toEqual([{ x: 36, y: -119.75, width: 523, height: 915.25 }]);
|
||||||
|
expect(imageOps(pages[1]!)).toEqual([{ x: 36, y: 36, width: Math.round(((759.5 * 20) / 35) * 1e13) / 1e13, height: 759.5 }]);
|
||||||
|
expect(javaPdf.title).toBe(reportTitle(t, undefined));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Java's data exchange, taken back from its pages: each card's heading and its rows' cells (by their columns). */
|
||||||
|
function javaTraffic(pdf: JavaPdf): { title: string; header: string[]; rows: string[][] }[] {
|
||||||
|
const columns = [44.5, 64.5, 124.5, 204.5, 284.5, 324.5, 374.5];
|
||||||
|
const cards: { title: string; header: string[]; rows: string[][] }[] = [];
|
||||||
|
let inTraffic = false;
|
||||||
|
for (const page of pdf.pages) {
|
||||||
|
const lines = new Map<number, { x: number; font: string; size: number; text: string }[]>();
|
||||||
|
for (const piece of page.texts) {
|
||||||
|
const [x, y, font, size, , ...words] = piece.split('|');
|
||||||
|
lines.set(Number(y), [...(lines.get(Number(y)) ?? []), { x: Number(x), font: font!, size: Number(size), text: words.join('|').trimEnd() }]);
|
||||||
|
}
|
||||||
|
for (const y of [...lines.keys()].sort((lhs, rhs) => rhs - lhs)) {
|
||||||
|
const line = lines.get(y)!.sort((lhs, rhs) => lhs.x - rhs.x);
|
||||||
|
if (line[0]!.size === 14) inTraffic = line[1]?.text === t('report.traffic');
|
||||||
|
if (!inTraffic || line[0]!.size === 14) continue;
|
||||||
|
if (line[0]!.size === 12) {
|
||||||
|
cards.push({ title: line[1]!.text, header: [], rows: [] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const card = cards[cards.length - 1]!;
|
||||||
|
if (line[0]!.font === 'Helvetica-Bold') {
|
||||||
|
card.header = line.map((each) => each.text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line[0]!.x === columns[0]) {
|
||||||
|
const row = columns.map(() => '');
|
||||||
|
for (const each of line) row[columns.indexOf(each.x)] = each.text;
|
||||||
|
card.rows.push(row);
|
||||||
|
} else {
|
||||||
|
// A comment's further line.
|
||||||
|
const row = card.rows[card.rows.length - 1]!;
|
||||||
|
row[6] = `${row[6]}\n${line.map((each) => each.text).join(' ')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the report's data exchange, as Java's", () => {
|
||||||
|
it("lays out Java's rows of each card as Java does: small, the columns fixed and centred, across pages", () => {
|
||||||
|
const file = 'einfaches_rechnernetz_komplett.fls';
|
||||||
|
const javaPdf = java.projects[file]!.pdf;
|
||||||
|
const traffic = javaTraffic(javaPdf);
|
||||||
|
// Every card the Lauscher had, with all its rows.
|
||||||
|
expect(traffic.map((card) => card.rows.length)).toEqual(java.projects[file]!.packetLogCards.map((card) => card.rows));
|
||||||
|
expect(traffic[0]!.header).toEqual(['number', 'time', 'source', 'destination', 'protocol', 'layer', 'comment'].map((key) => t(`capture.${key}`)));
|
||||||
|
const pages = reportPages({ projectName: file, image: { width: 100, height: 50 }, devices: [], traffic, t }, fonts);
|
||||||
|
const firstJava = javaPdf.pages.findIndex((page) => page.texts.some((piece) => piece.endsWith(`|${t('report.traffic')}`)));
|
||||||
|
const firstOurs = pages.findIndex((page) => texts(page).some((piece) => piece.endsWith(`|${t('report.traffic')}`)));
|
||||||
|
expect(pages.length - firstOurs).toBe(javaPdf.pages.length - firstJava);
|
||||||
|
for (let index = 0; firstOurs + index < pages.length; index++) {
|
||||||
|
expect(texts(pages[firstOurs + index]!), `page ${index + 1} of the data exchange`).toEqual(javaTexts(javaPdf.pages[firstJava + index]!.texts));
|
||||||
|
}
|
||||||
|
expect(TRAFFIC_WIDTHS.reduce((sum, width) => sum + width, 0)).toBe(510);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the header and every second row as Java does', () => {
|
||||||
|
const pages = reportPages(
|
||||||
|
{
|
||||||
|
image: { width: 100, height: 50 },
|
||||||
|
devices: [],
|
||||||
|
traffic: [
|
||||||
|
{
|
||||||
|
title: 'A - 1.2.3.4',
|
||||||
|
header: ['N', 'Z', 'Q', 'D', 'P', 'S', 'B'],
|
||||||
|
rows: [
|
||||||
|
['1', '', '', '', '', '', ''],
|
||||||
|
['2', '', '', '', '', '', ''],
|
||||||
|
['3', '', '', '', '', '', ''],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
t,
|
||||||
|
},
|
||||||
|
fonts,
|
||||||
|
);
|
||||||
|
const rects = pages.at(-1)!.filter((op) => op.kind === 'rect');
|
||||||
|
// The header's seven cells light blue, the second row's grey; each cell its column wide, its row high.
|
||||||
|
expect(rects.map((op) => (op.kind === 'rect' ? op.color.join() : ''))).toEqual([...Array(7).fill('230,240,255'), ...Array(7).fill('240,240,240')]);
|
||||||
|
expect(rects.map((op) => (op.kind === 'rect' ? op.width : 0))).toEqual([...TRAFFIC_WIDTHS, ...TRAFFIC_WIDTHS]);
|
||||||
|
expect(rects[0]).toMatchObject({ x: 42.5, height: 12 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the report as a PDF', () => {
|
||||||
|
const file = 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls';
|
||||||
|
const input = (): ReportInput & { png: Uint8Array } => ({
|
||||||
|
projectName: file,
|
||||||
|
png: new Uint8Array(readFileSync(join(here, '../src/assets/documentation/add_small.png'))),
|
||||||
|
image: { width: 0, height: 0 },
|
||||||
|
devices: devicesOf(file),
|
||||||
|
// A character Helvetica cannot write ("→") becomes "?".
|
||||||
|
traffic: [{ title: 'Heimrouter - 192.168.1.1', header: ['N', 'Z', 'Q', 'D', 'P', 'S', 'B'], rows: [['1', '12:00:00.000', 'a → b', 'Ü', 'TCP', 'Transport', 'eins\nzwei']] }],
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
const date = new Date(Date.UTC(2026, 9, 4, 12, 0, 0));
|
||||||
|
|
||||||
|
it("is a PDF of A4 pages with Java's title and creator", async () => {
|
||||||
|
const bytes = await reportPdf(input(), date);
|
||||||
|
const loaded = await PDFDocument.load(bytes);
|
||||||
|
expect(loaded.getTitle()).toBe(`Filius-Dokumentation: ${file.replace('.fls', '')}`);
|
||||||
|
expect(loaded.getCreator()).toBe(REPORT_CREATOR);
|
||||||
|
expect(loaded.getPages().map((page) => page.getSize())).toEqual(Array(12).fill({ width: 595, height: 842 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is read by Java’s own PDF reader (iText) with the same text in the same places', async () => {
|
||||||
|
const bytes = await reportPdf(input(), date);
|
||||||
|
const digest = createHash('sha256').update(bytes).digest('hex');
|
||||||
|
if (process.env.UPDATE_REPORT === '1') writeFileSync(join(here, '../../../tools/java-oracle/.build/report-ours.pdf'), bytes);
|
||||||
|
const read = (JSON.parse(readFileSync(join(here, 'report.ours.java.json'), 'utf8')) as Record<string, JavaPdf & { sha256: string }>)['report-ours.pdf']!;
|
||||||
|
// Java read this very file (UPDATE_REPORT=1, then ReportRun --read).
|
||||||
|
expect(read.sha256).toBe(digest);
|
||||||
|
const pages = reportPages({ ...input(), image: { width: 100, height: 100 } }, fonts);
|
||||||
|
expect(read.pages.length).toBe(pages.length);
|
||||||
|
pages.forEach((page, index) => expect(texts(page), `page ${index + 1}`).toEqual(javaTexts(read.pages[index]!.texts)));
|
||||||
|
expect(read.pages[0]!.images).toEqual([{ x: 36, y: 143.5, width: 523, height: 523, pixels: [40, 40] }]);
|
||||||
|
expect(texts(pages.at(-1)!)).toContain('124.50|745.00|Helvetica|8|0,0,0|a ? b');
|
||||||
|
expect([read.title, read.creator]).toEqual([`Filius-Dokumentation: ${file.replace('.fls', '')}`, REPORT_CREATOR]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the report's layout rules", () => {
|
||||||
|
/** Every character 10 wide at size 10. */
|
||||||
|
const even: ReportFonts = { width: (text, _font, size) => text.length * size, clean: (text) => text };
|
||||||
|
const style = { font: 'Helvetica', size: 10, color: [0, 0, 0] } as const;
|
||||||
|
|
||||||
|
it("breaks after a hyphen as after a space, as Java's path below a long title", () => {
|
||||||
|
const javaPdf = java.projects['Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls']!.pdf;
|
||||||
|
const javaPath = javaTexts(javaPdf.pages[0]!.texts).filter((piece) => piece.includes('|Helvetica|8|'));
|
||||||
|
const writer = new ReportWriter(fonts);
|
||||||
|
writer.paragraph(
|
||||||
|
[{ text: reportTitle(t, 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls'), style: { font: 'Helvetica-Bold', size: 18, color: [30, 50, 120] } }],
|
||||||
|
'center',
|
||||||
|
);
|
||||||
|
writer.chunk(javaPath.map((piece) => piece.split('|').slice(5).join('|')).join(''), SMALL_FONT);
|
||||||
|
writer.newline();
|
||||||
|
expect(texts(writer.finish()[0]!).filter((piece) => piece.includes('|Helvetica|8|'))).toEqual(javaPath);
|
||||||
|
expect(javaPath[0]).toMatch(/java-$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a space at the end of a line take no room', () => {
|
||||||
|
const writer = new ReportWriter(even);
|
||||||
|
writer.table({ widths: [104], fixed: true, align: 'left', rows: [[{ text: 'ab cdefghi j', style }]] });
|
||||||
|
// "ab cdefghi" fills the 100 inside the cell; the space after it does not push "cdefghi" to the next line.
|
||||||
|
expect(texts(writer.finish()[0]!).map((piece) => piece.split('|').at(-1))).toEqual(['ab cdefghi', 'j']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a character a line where the cell is narrower than one', () => {
|
||||||
|
const writer = new ReportWriter(even);
|
||||||
|
writer.table({ widths: [8], fixed: true, align: 'left', rows: [[{ text: 'ab', style }]] });
|
||||||
|
const ops = writer.finish()[0]!;
|
||||||
|
expect(texts(ops).map((piece) => piece.split('|').slice(1, 2).concat(piece.split('|').slice(-1)))).toEqual([
|
||||||
|
['794.00', 'a'],
|
||||||
|
['784.00', 'b'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws nothing for a cell of spaces, and gives it a line', () => {
|
||||||
|
const writer = new ReportWriter(even);
|
||||||
|
writer.table({
|
||||||
|
widths: [50, 50],
|
||||||
|
fixed: true,
|
||||||
|
align: 'left',
|
||||||
|
rows: [
|
||||||
|
[
|
||||||
|
{ text: ' ', style },
|
||||||
|
{ text: '', style },
|
||||||
|
],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
writer.paragraph([{ text: 'danach', style }]);
|
||||||
|
expect(texts(writer.finish()[0]!)).toEqual(['36.00|777.00|Helvetica|10|0,0,0|danach']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('begins the next page with a line that would end below the margin of 36', () => {
|
||||||
|
const rows = (count: number) => Array.from({ length: count }, () => [{ text: 'x', style: SMALL_FONT }]);
|
||||||
|
const at = (count: number) => {
|
||||||
|
const writer = new ReportWriter(even);
|
||||||
|
// Rows 12 high from 806 down, then a paragraph's line 15 below.
|
||||||
|
writer.table({ widths: [20], fixed: true, align: 'left', rows: rows(count) });
|
||||||
|
writer.paragraph([{ text: 'Zeile', style }]);
|
||||||
|
const pages = writer.finish();
|
||||||
|
return [pages.length, texts(pages.at(-1)!).at(-1)!.split('|')[1]];
|
||||||
|
};
|
||||||
|
// 806 - 62 × 12 = 62: the line at 47; 806 - 63 × 12 = 50: at 35 it would end below 36.
|
||||||
|
expect(at(62)).toEqual([1, '47.00']);
|
||||||
|
expect(at(63)).toEqual([2, '791.00']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a tab as a space and a character Helvetica has not got as "?"', () => {
|
||||||
|
expect(fonts.clean('a\tb → ü€')).toBe('a b ? ü€');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1731,3 +1731,53 @@ Gemessen (04.10.2026):
|
|||||||
Falle der Sonde: Das Druckpanel nimmt Javas Geräte-Etiketten aus der
|
Falle der Sonde: Das Druckpanel nimmt Javas Geräte-Etiketten aus der
|
||||||
Oberfläche (eine Swing-Komponente hat nur einen Elternteil); erst
|
Oberfläche (eine Swing-Komponente hat nur einen Elternteil); erst
|
||||||
`updateViewport` legt sie zurück.
|
`updateViewport` legt sie zurück.
|
||||||
|
|
||||||
|
### Dokumentation: „Report“ (F4.8d)
|
||||||
|
|
||||||
|
`ReportRun` öffnet ein Projekt wie Javas Menü, lässt es auf Wunsch eine
|
||||||
|
Weile laufen (alle Knoten `starten`, dann `beenden`; der Lauscher behält die
|
||||||
|
Rahmen wie nach einer Simulation) und schreibt mit
|
||||||
|
`ReportGenerator.generateReport` Javas PDF. Das liest es mit iTexts eigenem
|
||||||
|
Parser zurück: jedes Textstück mit Ort, Schrift, Größe (aus dem privaten
|
||||||
|
Grafikzustand per Reflection) und Farbe, jedes Bild mit Ort und Größe, Titel
|
||||||
|
und Ersteller. Drei Projekte (`einfaches_rechnernetz_komplett` nach 3 s
|
||||||
|
Lauf, `Internet_Komplett_…_DE`, die Fixture `documentation.fls`) und ein
|
||||||
|
leeres (`packages/editor/test/report.java.json`). Mit `--read` liest es ein
|
||||||
|
beliebiges PDF allein, mit SHA-256: so liest Javas iText den Bericht des
|
||||||
|
Editors (`report.ours.java.json`).
|
||||||
|
|
||||||
|
Gemessen (04.10.2026):
|
||||||
|
|
||||||
|
* A4, 36 Rand; Titel Helvetica-Bold 18 in (30, 50, 120), mittig; darunter
|
||||||
|
der Pfad in Helvetica 8; „1. Übersicht“ mit Javas Bild in Seitenbreite,
|
||||||
|
eine halbe Zeile unter der Überschrift; neue Seite „2. Komponenten“; neue
|
||||||
|
Seite „3. Datenaustausch“, nur wenn der Lauscher Rahmen hat.
|
||||||
|
* Überschriften Helvetica-Bold 14/12/10 in Dunkelgrau (64), Ebene 1 und 2
|
||||||
|
nummeriert („2.1. “), Ebene 3 nicht. Zeilenabstand das 1,5-Fache der
|
||||||
|
Schrift; lose Textstücke (`Chunk`) laufen in derselben Zeile mit dem
|
||||||
|
Abstand des letzten Absatzes, bis `Chunk.NEWLINE` sie beendet.
|
||||||
|
* Tabellen: 80 % der Breite, links, Spalten im Verhältnis (180:360,
|
||||||
|
3 × 180, 180:60:260, 4 × 180); Zellen mit 2 Innenabstand, Zeilen so weit
|
||||||
|
auseinander wie die Schrift groß ist; eine Zeile, die unter den Rand
|
||||||
|
reicht, beginnt die nächste Seite (ohne Kopfzeile). Datenaustausch: feste
|
||||||
|
Spalten 20/60/80/80/40/50/180, mittig, Helvetica 8, Kopf hellblau (230,
|
||||||
|
240, 255), jede zweite Zeile grau (240).
|
||||||
|
* Je Gerät mit Karten (nicht Switch, nicht Modem): Gateway, DNS-Server, bei
|
||||||
|
Rechnern „DHCP zur Konfiguration verwenden“ als `true`/`false`; Karten;
|
||||||
|
bei Rechnern „Installiert: “ und die Programme (oder „-“), in der
|
||||||
|
Reihenfolge einer HashMap; mit DNS-Server dessen Einträge;
|
||||||
|
Weiterleitungstabelle (nur Zeilen mit einer Adresse als Ziel); beim
|
||||||
|
Heimrouter die Portweiterleitung, wenn eine Zeile ein bekanntes Protokoll
|
||||||
|
hat. Datenaustausch je Karte mit Rahmen („Name - Adresse“), die Zeilen
|
||||||
|
von `Lauscher.getDaten(id, true, 0)`, in der Reihenfolge einer HashMap.
|
||||||
|
* Leeres Projekt: Titel ohne Namen, kein Pfad; das leere 20 × 35-Bild wird
|
||||||
|
auf Seitenbreite 915 hoch und läuft über den Rand einer eigenen Seite.
|
||||||
|
* Java-Fehler am Rande: der Bericht schreibt die Protokolle der
|
||||||
|
Portweiterleitung („6“ → „TCP“) ins Modell zurück und gibt eine
|
||||||
|
Debug-Zeile auf der Konsole aus.
|
||||||
|
|
||||||
|
Fallen der Sonde: iText legt Absätze und Tabellen in eigene Ebenen, die
|
||||||
|
Zeichenreihenfolge im PDF ist darum nicht die der Seite (der Test vergleicht
|
||||||
|
nach Ort). Die Uhrzeiten des Datenaustauschs sind Javas Wanduhr; der Test
|
||||||
|
nimmt Javas Zeilen aus Javas Seiten, damit der Satz verglichen wird, nicht
|
||||||
|
der Lauf.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
|
|||||||
fi
|
fi
|
||||||
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
|
||||||
mkdir -p "$here/.build"
|
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" "$here/src/ExportRun.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" "$here/src/ReportRun.java"
|
||||||
# FILIUS writes its settings below $HOME; keep them out of the real one.
|
# FILIUS writes its settings below $HOME; keep them out of the real one.
|
||||||
mkdir -p "$here/.build/home"
|
mkdir -p "$here/.build/home"
|
||||||
# Removed first: a crashed run must not leave the previous result standing.
|
# Removed first: a crashed run must not leave the previous result standing.
|
||||||
@@ -510,6 +510,24 @@ HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -
|
|||||||
"$root/packages/doc/test/fixtures/documentation.fls" "$upstream/beispiele/modem_beispiel.fls" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || true
|
"$root/packages/doc/test/fixtures/documentation.fls" "$upstream/beispiele/modem_beispiel.fls" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || true
|
||||||
test -s "$output"
|
test -s "$output"
|
||||||
|
|
||||||
|
# FILIUS' „Report“ (ReportRun): the PDF Java writes of three projects (one run
|
||||||
|
# for 3 s, its packet log in it) and an empty one, read back with iText —
|
||||||
|
# every piece of text with its place, font, size and colour
|
||||||
|
# (packages/editor/test/report.test.ts). Then the editor's own report, which
|
||||||
|
# that test writes with UPDATE_REPORT=1, read back the same way, with its SHA-256.
|
||||||
|
output="$root/packages/editor/test/report.java.json"
|
||||||
|
rm -f "$output"
|
||||||
|
HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" ReportRun \
|
||||||
|
"$output" "$here/.build/report" "$upstream/beispiele/einfaches_rechnernetz_komplett.fls:3000" \
|
||||||
|
"$upstream/beispiele/Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls" \
|
||||||
|
"$root/packages/doc/test/fixtures/documentation.fls" 2>&1 | grep -v 'logback\|^$\|^\s*at \|^TCP ' || true
|
||||||
|
test -s "$output"
|
||||||
|
(cd "$root" && UPDATE_REPORT=1 npx vitest run packages/editor/test/report.test.ts >/dev/null 2>&1 || true)
|
||||||
|
output="$root/packages/editor/test/report.ours.java.json"
|
||||||
|
rm -f "$output"
|
||||||
|
HOME="$here/.build/home" java -cp "$classpath:$here/.build" ReportRun --read "$output" "$here/.build/report-ours.pdf" 2>&1 | grep -v 'logback\|^$' || true
|
||||||
|
test -s "$output"
|
||||||
|
|
||||||
# FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS
|
# 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)
|
# 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).
|
# and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts).
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// FILIUS' „Report“ in the documentation mode measured (ReportGenerator):
|
||||||
|
// each project opened as the menu opens it, run for a while when asked (its
|
||||||
|
// frames in FILIUS' packet log, the Lauscher, as after a simulation), then
|
||||||
|
// the PDF Java writes, read back with iText's own parser: its pages and
|
||||||
|
// their size, every piece of text with its place, font, size and colour,
|
||||||
|
// every image with its place and size, the document's title and creator;
|
||||||
|
// and what the report was made of (the Lauscher's cards, the nodes' cards).
|
||||||
|
// An empty project too (no file). The reference for the editor's report
|
||||||
|
// (packages/editor/test/report.test.ts).
|
||||||
|
//
|
||||||
|
// Usage: java -cp filius.jar:lib/*:classes ReportRun <out.json> <pdf folder> <file.fls>[:<milliseconds to run>]...
|
||||||
|
// java -cp filius.jar:lib/*:classes ReportRun --read <out.json> <file.pdf>... (a PDF read back alone, with its SHA-256)
|
||||||
|
|
||||||
|
import com.itextpdf.text.BaseColor;
|
||||||
|
import com.itextpdf.text.pdf.PdfReader;
|
||||||
|
import com.itextpdf.text.pdf.parser.GraphicsState;
|
||||||
|
import com.itextpdf.text.pdf.parser.ImageRenderInfo;
|
||||||
|
import com.itextpdf.text.pdf.parser.Matrix;
|
||||||
|
import com.itextpdf.text.pdf.parser.PdfReaderContentParser;
|
||||||
|
import com.itextpdf.text.pdf.parser.RenderListener;
|
||||||
|
import com.itextpdf.text.pdf.parser.TextRenderInfo;
|
||||||
|
import com.itextpdf.text.pdf.parser.Vector;
|
||||||
|
import filius.gui.GUIContainer;
|
||||||
|
import filius.gui.documentation.ReportGenerator;
|
||||||
|
import filius.gui.netzwerksicht.GUIKnotenItem;
|
||||||
|
import filius.hardware.NetzwerkInterface;
|
||||||
|
import filius.hardware.knoten.InternetKnoten;
|
||||||
|
import filius.rahmenprogramm.Information;
|
||||||
|
import filius.rahmenprogramm.SzenarioVerwaltung;
|
||||||
|
import filius.rahmenprogramm.nachrichten.Lauscher;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
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 javax.swing.SwingUtilities;
|
||||||
|
|
||||||
|
public class ReportRun {
|
||||||
|
static float round(float value) {
|
||||||
|
return Math.round(value * 100) / 100f;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String colour(BaseColor color) {
|
||||||
|
return color == null ? null : color.getRed() + "," + color.getGreen() + "," + color.getBlue();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every piece of text and every image of a page, in the order the page draws them. */
|
||||||
|
static class Listener implements RenderListener {
|
||||||
|
final List<Object> texts = new ArrayList<>();
|
||||||
|
final List<Object> images = new ArrayList<>();
|
||||||
|
|
||||||
|
public void beginTextBlock() {}
|
||||||
|
|
||||||
|
public void endTextBlock() {}
|
||||||
|
|
||||||
|
public void renderText(TextRenderInfo info) {
|
||||||
|
// Spaces alone (iText writes the ends of lines and Chunk.NEWLINE so) carry nothing.
|
||||||
|
if (info.getText().trim().isEmpty()) return;
|
||||||
|
try {
|
||||||
|
Field field = TextRenderInfo.class.getDeclaredField("gs");
|
||||||
|
field.setAccessible(true);
|
||||||
|
GraphicsState state = (GraphicsState) field.get(info);
|
||||||
|
Vector start = info.getBaseline().getStartPoint();
|
||||||
|
// x|y|font|size|colour|text, one string a piece, so that the file stays small.
|
||||||
|
texts.add(round(start.get(Vector.I1)) + "|" + round(start.get(Vector.I2)) + "|" + info.getFont().getPostscriptFontName() + "|"
|
||||||
|
+ round(state.getFontSize()) + "|" + colour(info.getFillColor()) + "|" + info.getText());
|
||||||
|
} catch (ReflectiveOperationException error) {
|
||||||
|
throw new IllegalStateException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void renderImage(ImageRenderInfo info) {
|
||||||
|
Matrix matrix = info.getImageCTM();
|
||||||
|
Map<String, Object> image = new LinkedHashMap<>();
|
||||||
|
image.put("x", round(matrix.get(Matrix.I31)));
|
||||||
|
image.put("y", round(matrix.get(Matrix.I32)));
|
||||||
|
image.put("width", round(matrix.get(Matrix.I11)));
|
||||||
|
image.put("height", round(matrix.get(Matrix.I22)));
|
||||||
|
try {
|
||||||
|
image.put("pixels", List.of(info.getImage().getBufferedImage().getWidth(), info.getImage().getBufferedImage().getHeight()));
|
||||||
|
} catch (Exception error) {
|
||||||
|
image.put("pixels", null);
|
||||||
|
}
|
||||||
|
images.add(image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, Object> read(String pdf) throws Exception {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
PdfReader reader = new PdfReader(pdf);
|
||||||
|
Map<String, String> info = reader.getInfo();
|
||||||
|
result.put("title", info.get("Title"));
|
||||||
|
result.put("creator", info.get("Creator"));
|
||||||
|
List<Object> pages = new ArrayList<>();
|
||||||
|
PdfReaderContentParser parser = new PdfReaderContentParser(reader);
|
||||||
|
for (int number = 1; number <= reader.getNumberOfPages(); number++) {
|
||||||
|
Listener listener = parser.processContent(number, new Listener());
|
||||||
|
Map<String, Object> page = new LinkedHashMap<>();
|
||||||
|
page.put("size", List.of(round(reader.getPageSize(number).getWidth()), round(reader.getPageSize(number).getHeight())));
|
||||||
|
page.put("texts", listener.texts);
|
||||||
|
page.put("images", listener.images);
|
||||||
|
pages.add(page);
|
||||||
|
}
|
||||||
|
result.put("pages", pages);
|
||||||
|
reader.close();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, Object> measure(String name, File pdfFolder) throws Exception {
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
// What the report is made of: the Lauscher's cards and every card of the nodes.
|
||||||
|
List<Object> packetLogCards = new ArrayList<>();
|
||||||
|
for (String id : Lauscher.getLauscher().getInterfaceIDs()) {
|
||||||
|
Map<String, Object> card = new LinkedHashMap<>();
|
||||||
|
card.put("mac", id);
|
||||||
|
card.put("rows", Lauscher.getLauscher().getDaten(id, true, 0).length);
|
||||||
|
packetLogCards.add(card);
|
||||||
|
}
|
||||||
|
result.put("packetLogCards", packetLogCards);
|
||||||
|
List<Object> nodes = new ArrayList<>();
|
||||||
|
for (GUIKnotenItem item : GUIContainer.getGUIContainer().getKnotenItems()) {
|
||||||
|
Map<String, Object> node = new LinkedHashMap<>();
|
||||||
|
node.put("name", item.getKnoten().holeAnzeigeName());
|
||||||
|
node.put("type", item.getKnoten().getClass().getSimpleName());
|
||||||
|
List<Object> macs = new ArrayList<>();
|
||||||
|
if (item.getKnoten() instanceof InternetKnoten) {
|
||||||
|
for (NetzwerkInterface nic : ((InternetKnoten) item.getKnoten()).getNetzwerkInterfaces()) macs.add(nic.getMac());
|
||||||
|
}
|
||||||
|
node.put("macs", macs);
|
||||||
|
nodes.add(node);
|
||||||
|
}
|
||||||
|
result.put("nodes", nodes);
|
||||||
|
String pdf = new File(pdfFolder, name.replace(".fls", "") + ".pdf").getPath();
|
||||||
|
ReportGenerator.getInstance().generateReport(pdf);
|
||||||
|
result.put("pdf", read(pdf));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
void run() throws Exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onEdt(Step step) throws Exception {
|
||||||
|
Exception[] failed = new Exception[1];
|
||||||
|
SwingUtilities.invokeAndWait(() -> {
|
||||||
|
try {
|
||||||
|
step.run();
|
||||||
|
} catch (Exception error) {
|
||||||
|
failed[0] = error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (failed[0] != null) throw failed[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
static void run(String[] args) throws Exception {
|
||||||
|
if (args[0].equals("--read")) {
|
||||||
|
Map<String, Object> read = new LinkedHashMap<>();
|
||||||
|
for (int index = 2; index < args.length; index++) {
|
||||||
|
Map<String, Object> pdf = read(args[index]);
|
||||||
|
byte[] digest = java.security.MessageDigest.getInstance("SHA-256").digest(java.nio.file.Files.readAllBytes(Path.of(args[index])));
|
||||||
|
StringBuilder hex = new StringBuilder();
|
||||||
|
for (byte each : digest) hex.append(String.format("%02x", each));
|
||||||
|
pdf.put("sha256", hex.toString());
|
||||||
|
read.put(Path.of(args[index]).getFileName().toString(), pdf);
|
||||||
|
}
|
||||||
|
try (FileOutputStream out = new FileOutputStream(args[1])) {
|
||||||
|
out.write((FlsOracle.Json.write(read, "") + "\n").getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
GUIContainer.getGUIContainer();
|
||||||
|
File pdfFolder = new File(args[1]);
|
||||||
|
pdfFolder.mkdirs();
|
||||||
|
Map<String, Object> output = new LinkedHashMap<>();
|
||||||
|
Map<String, Object> projects = new LinkedHashMap<>();
|
||||||
|
for (int index = 2; index < args.length; index++) {
|
||||||
|
String[] parts = args[index].split(":");
|
||||||
|
String file = parts[0];
|
||||||
|
long runFor = parts.length > 1 ? Long.parseLong(parts[1]) : 0;
|
||||||
|
String name = Path.of(file).getFileName().toString();
|
||||||
|
onEdt(() -> {
|
||||||
|
// As GUIMainMenu opens a file; the packet log starts empty, as after Lauscher.reset.
|
||||||
|
Information.getInformation().reset();
|
||||||
|
Lauscher.getLauscher().reset();
|
||||||
|
SzenarioVerwaltung.getInstance().laden(file, GUIContainer.getGUIContainer().getKnotenItems(),
|
||||||
|
GUIContainer.getGUIContainer().getCableItems(), GUIContainer.getGUIContainer().getDocuItems());
|
||||||
|
GUIContainer.getGUIContainer().setProperty(null);
|
||||||
|
GUIContainer.getGUIContainer().updateViewport();
|
||||||
|
GUIContainer.getGUIContainer().updateCables();
|
||||||
|
});
|
||||||
|
if (runFor > 0) {
|
||||||
|
// As GUIMainMenu.selectMode starts and ends a simulation: every node in the window's order.
|
||||||
|
for (GUIKnotenItem item : GUIContainer.getGUIContainer().getKnotenItems()) item.getKnoten().getSystemSoftware().starten();
|
||||||
|
Thread.sleep(runFor);
|
||||||
|
for (GUIKnotenItem item : GUIContainer.getGUIContainer().getKnotenItems()) item.getKnoten().getSystemSoftware().beenden();
|
||||||
|
Thread.sleep(500);
|
||||||
|
}
|
||||||
|
Map<String, Object>[] measured = new Map[1];
|
||||||
|
onEdt(() -> measured[0] = measure(name, pdfFolder));
|
||||||
|
projects.put(name, measured[0]);
|
||||||
|
}
|
||||||
|
output.put("projects", projects);
|
||||||
|
|
||||||
|
Map<String, Object>[] empty = new Map[1];
|
||||||
|
onEdt(() -> {
|
||||||
|
GUIContainer container = GUIContainer.getGUIContainer();
|
||||||
|
container.getKnotenItems().clear();
|
||||||
|
container.getCableItems().clear();
|
||||||
|
container.getDocuItems().clear();
|
||||||
|
SzenarioVerwaltung.getInstance().reset();
|
||||||
|
Lauscher.getLauscher().reset();
|
||||||
|
container.updateViewport();
|
||||||
|
empty[0] = measure("empty.fls", pdfFolder);
|
||||||
|
});
|
||||||
|
output.put("empty", empty[0]);
|
||||||
|
|
||||||
|
try (FileOutputStream out = new FileOutputStream(args[0])) {
|
||||||
|
out.write((FlsOracle.Json.write(output, "") + "\n").getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user