diff --git a/CHANGES.md b/CHANGES.md index e30723b..9916ad1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -236,6 +236,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `src/simulation/ViewerWindow.tsx`, `src/simulation/DropControl.tsx`, `src/simulation/timeOfDay.ts`, `src/simulation/useSimulation.ts` (`clock`, `dropped`, `drop`), `src/Editor.tsx`, `src/sheet/sheet.css` (`.editor-toolbar`) | Java `SatViewer`, `NatViewer` (`guievents_msg8`–`10`, `27`–`34`, `36`, `sw_switchfirmware_msg1`), `SimulationControl` (`simulation_control_drop_packets`), `GUIKnotenItem` (Klick im Aktionsmodus), gemessen mit `ViewersRun` | neu (F4.6g5): ein Tippen auf einen Switch zeigt seine Tabelle, auf einen Heimrouter seine NAT-Tabelle, in einem verschiebbaren Fenster mit Javas Spalten und Uhrzeiten; „Pakete verwerfen“ wird gehalten (Finger, Maus, Leertaste/Enter), daneben die Zahl und aufklappbar die Liste der verworfenen Frames mit Uhrzeit und Javas Text. Besser als Java: „Tabelle löschen“/„zurücksetzen“ ist ein Knopf (Java: Menü der rechten Maustaste), die Liste klappt weg; die Werkzeugleiste bricht auf schmalen Bildschirmen um | | `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/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/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/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 | @@ -250,6 +251,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand | `browser/viewers.spec.ts`, `test/timeOfDay.test.ts` | — | neu (F4.6g5): auf `nat_with_mapped_port` die NAT-Tabelle (Portweiterleitung, dann ein Ping, zurückgesetzt), die Switch-Tabelle (gelernt, gelöscht), „Pakete verwerfen“ gehalten, während ein Ping läuft (Antworten verloren, Liste in Javas Worten); auf einem Handy bleibt „Pakete verwerfen“ erreichbar; die Uhrzeit wie Javas `HH:mm:ss(.SSS)` | | `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 | +| `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 | | `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 | diff --git a/README.md b/README.md index 8219fe6..d10dded 100644 --- a/README.md +++ b/README.md @@ -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/protocol` | die eine Nachrichtenliste zwischen Editor und Simulationsraum (`/sim`) | | `packages/sim` | Simulationsraum (für Server F und den Web Worker) und `LocalSimHost` | -| `packages/editor` | Oberfläche im Iframe des Dashboards (React, Vite): bisher Fläche, Palette, Werkzeuge des Entwurfs und Geräte-Blatt; `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); `npm run dev -w @mdg-filius/editor` öffnet die Entwicklungsseite | | `server` | Filius-Dienst auf Server F *(folgt)* | Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard. diff --git a/packages/editor/browser/documentation.spec.ts b/packages/editor/browser/documentation.spec.ts index 56f91fe..88021c5 100644 --- a/packages/editor/browser/documentation.spec.ts +++ b/packages/editor/browser/documentation.spec.ts @@ -4,9 +4,13 @@ // pointer), moved, resized to Java's smallest size, coloured from Java's // list; the sidebar's text tapped into the view, typed into, made large and // italic; Java's paint in the documentation mode and in the design; Delete; -// a simulation started ends the mode; and a phone. +// a simulation started ends the mode; „Exportieren“ (F4.8c): the image +// saved under the project's name, cut and painted as Java's, and Java's +// message when saving fails; and a phone. +import { readFileSync } from 'node:fs'; import { expect, test, type Page } from '@playwright/test'; +import { JAVA_LABEL_FONT } from '../src/canvas/layout.js'; import { drag, drawing, openEditor, serveEditor } from './editorPage.js'; let server: { readonly base: string; readonly close: () => Promise }; @@ -50,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(page.getByRole('navigation', { name: 'Komponenten' })).toHaveCount(0); const sidebar = page.getByRole('navigation', { name: 'Dokumentation' }); - await expect(sidebar.getByRole('button')).toHaveText(['Text', 'Gliederungsfläche']); + await expect(sidebar.getByRole('button')).toHaveText(['Text', 'Gliederungsfläche', 'Exportieren']); // A rectangle dragged onto the surface: 100 × 50, its middle where it was dropped, grey, with the focus. const surface = (await canvas.boundingBox())!; @@ -159,11 +163,84 @@ test('makes, moves, resizes, colours and types documentation as Java does, and p await expect(page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' })).toHaveAttribute('aria-pressed', 'true'); }); +test("exports the surface as Java's image, under the project's name", async ({ page }) => { + await openEditor(page, server.base, 'nat_with_mapped_port.fls'); + await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click(); + const button = page.getByRole('navigation', { name: 'Dokumentation' }).getByRole('button', { name: 'Exportieren' }); + await expect(button).toHaveAttribute('title', 'Als Bild exportieren'); + const saved = page.waitForEvent('download'); + await button.click(); + const download = await saved; + expect(download.suggestedFilename()).toBe('nat_with_mapped_port.png'); + // Ready for the next one. + await expect(button).toBeEnabled(); + const base64 = readFileSync((await download.path())!).toString('base64'); + + // What it should show, from the surface: every device's button and every documentation element, 10 around, the footer 20 below. + const boxes = await page.locator('g.device').evaluateAll((devices) => devices.map((device) => device.getAttribute('data-box')!.split(' ').map(Number))); + const docs = (await drawing(page)).docs; + const parts = [...boxes.map(([x, y, width, height]) => ({ x: x!, y: y!, width: width!, height: height! })), ...docs]; + const left = Math.min(...parts.map((part) => part.x)); + const top = Math.min(...parts.map((part) => part.y)); + const right = Math.max(...parts.map((part) => part.x + part.width)); + const bottom = Math.max(...parts.map((part) => part.y + part.height)); + const footerWidth = await page.evaluate( + ({ font, text }) => { + const context = document.createElement('canvas').getContext('2d')!; + context.font = font; + return Math.round(context.measureText(text).width) + 20; + }, + { font: JAVA_LABEL_FONT, text: 'nat_with_mapped_port.fls' }, + ); + const rectangle = docs.find((item) => item.kind === 'rectangle')!; + const image = await page.evaluate( + async ({ base64, rectangle, footer }) => { + const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' })); + const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); + const context = canvas.getContext('2d')!; + context.drawImage(bitmap, 0, 0); + const pixel = (x: number, y: number) => [...context.getImageData(x, y, 1, 1).data]; + const footerPixels = context.getImageData(footer.x, footer.y, footer.width, footer.height).data; + let grey = 0; + for (let index = 0; index < footerPixels.length; index += 4) { + if (footerPixels[index] === 192 && footerPixels[index + 1] === 192 && footerPixels[index + 2] === 192 && footerPixels[index + 3] === 255) grey += 1; + } + return { width: bitmap.width, height: bitmap.height, corner: pixel(0, 0), rectangle: pixel(rectangle.x, rectangle.y), grey }; + }, + { + base64, + rectangle: { x: rectangle.x + 8 - (left - 10), y: rectangle.y + 8 - (top - 10) }, + footer: { x: 10, y: bottom + 20 - (top - 10), width: footerWidth, height: 17 }, + }, + ); + expect([image.width, image.height]).toEqual([Math.max(right, left + 10 + footerWidth) + 10 - (left - 10), bottom + 20 + 17 + 20 - (top - 10)]); + // Transparent around; a grey rectangle (204) as the design paints it, a fifth of the way to white; the footer light grey. + expect(image.corner).toEqual([0, 0, 0, 0]); + expect(image.rectangle).toEqual([215, 215, 215, 255]); + expect(image.grey).toBeGreaterThan(20); +}); + +test("shows Java's message when the image cannot be saved", async ({ page }) => { + await openEditor(page, server.base, 'nat_with_mapped_port.fls'); + await page.evaluate(() => { + HTMLCanvasElement.prototype.toBlob = function (callback: BlobCallback) { + callback(null); + }; + }); + await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click(); + const button = page.getByRole('button', { name: 'Exportieren' }); + await button.click(); + await expect(page.getByRole('alert')).toHaveText('Fehler beim Speichern aufgetreten!'); + await expect(button).toBeEnabled(); +}); + test('keeps the documentation mode within reach on a phone', async ({ page }) => { await page.setViewportSize({ width: 360, height: 740 }); await openEditor(page, server.base, 'nat_with_mapped_port.fls'); 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: 'Exportieren' })).toBeInViewport({ ratio: 1 }); const rights = await page.locator('.editor-toolbar > *').evaluateAll((parts) => parts.map((part) => part.getBoundingClientRect().right)); expect(Math.max(...rights)).toBeLessThanOrEqual(360); }); diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index 42c84c4..b94a7ec 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -41,6 +41,9 @@ import { ContextMenu, type MenuItem } from './design/ContextMenu.js'; import { DocumentationMenu } from './design/DocumentationMenu.js'; import { DocumentationSidebar } from './design/DocumentationSidebar.js'; import { DocumentationText } from './design/DocumentationText.js'; +import { networkImage } from './design/exportImage.js'; +import { exportNames } from './canvas/networkImage.js'; +import { saveFile } from './download.js'; import { Palette, type PaletteKind } from './design/Palette.js'; import { RouterDialog } from './design/RouterDialog.js'; import { DeviceSheet } from './sheet/DeviceSheet.js'; @@ -132,7 +135,14 @@ const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop return open.some((each) => each.id === id && each.z === top) ? open : open.map((each) => (each.id === id ? { ...each, z: top + 1 } : each)); }; -export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) { +export interface EditorProps { + readonly doc: Y.Doc; + readonly createSimHost?: SimHostFactory; + /** The project's name (its file's), for what is exported. */ + readonly projectName?: string; +} + +export function Editor({ doc, createSimHost, projectName }: EditorProps) { const t = useTranslate(); const language = useLanguage(); const view = useTopologyView(doc); @@ -281,6 +291,12 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c setSelection({ kind: 'doc', id }); }; + /** „Exportieren“: the surface as Java's image, saved under the project's name. */ + const exportImage = async () => { + const names = exportNames(projectName); + saveFile(await networkImage(view, names.footer), names.file); + }; + const changeMode = (mode: EditorMode) => { setDocumenting(mode === 'documentation'); if (mode === 'simulation') simulation.start(language); @@ -347,7 +363,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c return (
{simulating ? null : documenting ? ( - + ) : ( x2 && y1 > y2) || (x1 < x2 && y1 < y2)) controlX = 3 * controlX; const controlY = Math.trunc((y1 - top + y2 - top) / 4); - return `M ${x1} ${y1} Q ${left + controlX} ${top + controlY} ${x2} ${y2}`; + return { x: left + controlX, y: top + controlY }; +} + +/** JCablePanel's curve, as an SVG path. */ +export function cablePath(first: Point, second: Point): string { + const control = cableControl(first, second); + return `M ${first.x} ${first.y} Q ${control.x} ${control.y} ${second.x} ${second.y}`; } /** The rectangle around all of them. */ diff --git a/packages/editor/src/canvas/networkImage.ts b/packages/editor/src/canvas/networkImage.ts new file mode 100644 index 0000000..df7107d --- /dev/null +++ b/packages/editor/src/canvas/networkImage.ts @@ -0,0 +1,210 @@ +// FILIUS' „Exportieren“ in the documentation mode (GUIContainer.createNetworkImage, +// GUIPrintPanel; measured with tools/java-oracle's ExportRun): the network +// and its documentation as a PNG, painted as the design shows them — the +// documentation below, an opaque rectangle a fifth of the way to white and +// a text without its border; cables over it, devices over the cables; +// nothing chosen — on a transparent background. The image is cut to what +// is there: 10 to spare on the left, above and on the right; 20 below the +// lowest element a light grey footer with the project's name, flush with +// the leftmost element, and 20 to spare below it. Java's own measure: a +// footer as wide as its words and 20 more, as high as its font. +// +// Not adopted from Java: an empty project gives Java an image of 20 × 35 +// with nothing in it (its footer lands beyond reach, an integer overflow); +// here it is the footer alone. Java cuts at its surface's edges (nothing +// lies beyond them there); the editor has no edges, so nothing is cut. The +// footer is the project's name: a browser knows no file's path. + +import type { DocumentationView, TopologyView } from '@mdg-filius/doc'; +import { rectangleBorder, rectangleFill, textColor, fontFamily } from './documentationPaint.js'; +import { bounds, cableControl, cableFrame, deviceBox, JAVA_LABEL_ASCENT, JAVA_LABEL_FONT, JAVA_LABEL_FONT_HEIGHT, type DeviceBox, type IconName } from './layout.js'; +import type { Rect, Size } from './viewport.js'; + +/** GUIPrintPanel.EMPTY_BORDER: what is left around the elements. */ +export const EXPORT_MARGIN = 10; +/** How far below the lowest element the footer begins, and what is left below it. */ +export const FOOTER_GAP = 20; +/** GUIFooterPanel: Color.lightGray. */ +export const FOOTER_COLOR = 'rgb(192, 192, 192)'; +/** JCablePanel's colours: a cable, a wireless link (dashed). */ +export const CABLE_COLOR = 'rgb(64, 64, 64)'; +export const WIRELESS_COLOR = 'rgb(192, 192, 192)'; +/** A device's label, as Java's look and feel writes it. */ +export const LABEL_COLOR = 'rgb(0, 0, 0)'; + +export interface ExportLayout { + /** The part of the surface the image shows. */ + readonly image: Rect; + /** The footer's label. */ + readonly footer: Rect; +} + +/** Where the image and its footer lie, around the elements' rectangles (GUIPrintPanel.getClipX … getClipHeight). */ +export function exportLayout(parts: readonly Rect[], footer: Size): ExportLayout { + const content = parts.length === 0 ? null : bounds(parts); + const left = content?.x ?? 0; + const footerTop = content === null ? 0 : content.y + content.height + FOOTER_GAP; + const top = content?.y ?? footerTop; + // The footer's label already holds 20 more than its words; Java leaves 10 beyond its own clip of it. + const right = Math.max(content === null ? -Infinity : content.x + content.width, left + EXPORT_MARGIN + footer.width) + EXPORT_MARGIN; + const bottom = footerTop + footer.height + FOOTER_GAP; + return { + image: { x: left - EXPORT_MARGIN, y: top - EXPORT_MARGIN, width: right - (left - EXPORT_MARGIN), height: bottom - (top - EXPORT_MARGIN) }, + footer: { x: left, y: footerTop, width: footer.width, height: footer.height }, + }; +} + +/** The lines a text is written in within a width: its own lines, each broken after a space, a word wider than the width broken where it reaches it. */ +export function wrapLines(text: string, width: number, measure: (text: string) => number): string[] { + const lines: string[] = []; + for (const paragraph of text.split('\n')) { + let line = ''; + // Each word with the spaces after it: they stay with it, at a line's end too. + for (const word of paragraph.match(/\S*\s*/g)!.filter((each) => each !== '')) { + if (measure((line + word).trimEnd()) <= width) { + line += word; + continue; + } + if (line !== '') lines.push(line); + line = ''; + for (const character of word) { + if (line !== '' && measure((line + character).trimEnd()) > width) { + lines.push(line); + line = ''; + } + line += character; + } + } + lines.push(line); + } + return lines; +} + +/** What of a 2D context the export paints with. */ +export type Painter = Pick< + CanvasRenderingContext2D, + | 'save' + | 'restore' + | 'translate' + | 'beginPath' + | 'moveTo' + | 'quadraticCurveTo' + | 'roundRect' + | 'rect' + | 'clip' + | 'fill' + | 'stroke' + | 'fillText' + | 'measureText' + | 'drawImage' + | 'setLineDash' + | 'font' + | 'fillStyle' + | 'strokeStyle' + | 'lineWidth' + | 'textAlign' + | 'textBaseline' +>; + +/** The devices' buttons and the rectangles of everything in the image (devices, cables, documentation), as Java's panels count them. */ +export function networkParts(view: TopologyView, labelWidth: (text: string) => number): { readonly boxes: ReadonlyMap; readonly parts: readonly Rect[] } { + const boxes = new Map(view.nodes.map((node) => [node.id, deviceBox(node, labelWidth(node.label))])); + const parts: Rect[] = [...boxes.values()]; + for (const link of view.links) { + const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)]; + if (source !== undefined && target !== undefined) parts.push(cableFrame(source.center, target.center)); + } + for (const item of view.docs) parts.push({ x: item.x, y: item.y, width: item.width, height: item.height }); + return { boxes, parts }; +} + +function paintDocumentation(context: Painter, item: DocumentationView) { + if (item.kind === 'rectangle') { + // As the surface draws it without the focus: inset by 1, its border 1 wide, corners of 5. + context.beginPath(); + context.roundRect(item.x + 1, item.y + 1, Math.max(0, item.width - 2), Math.max(0, item.height - 2), 5); + context.fillStyle = rectangleFill(item.color, false); + context.fill(); + context.lineWidth = 1; + context.strokeStyle = rectangleBorder(item.color); + context.stroke(); + return; + } + context.save(); + context.beginPath(); + context.rect(item.x, item.y, item.width, item.height); + context.clip(); + context.font = `${item.italic ? 'italic ' : ''}${item.bold ? 'bold ' : ''}${item.size}px ${fontFamily(item.font)}`; + context.fillStyle = textColor(item.color); + context.textAlign = 'left'; + context.textBaseline = 'alphabetic'; + // The surface's line height ("normal"): the font's own ascent and descent. + const metrics = context.measureText(item.text); + const ascent = metrics.fontBoundingBoxAscent; + const height = ascent + metrics.fontBoundingBoxDescent; + // Inside a padding of 1, as the surface writes it. + wrapLines(item.text, item.width - 2, (text) => context.measureText(text).width).forEach((line, index) => { + context.fillText(line, item.x + 1, item.y + 1 + ascent + index * height); + }); + context.restore(); +} + +/** The devices' buttons and the image's layout, with the labels measured in Java's font on the context. */ +export function measureNetworkImage(context: Painter, view: TopologyView, footerText: string): { readonly boxes: ReadonlyMap; readonly layout: ExportLayout } { + context.font = JAVA_LABEL_FONT; + const labelWidth = (text: string) => context.measureText(text).width; + const { boxes, parts } = networkParts(view, labelWidth); + return { boxes, layout: exportLayout(parts, { width: Math.round(labelWidth(footerText)) + 20, height: JAVA_LABEL_FONT_HEIGHT }) }; +} + +/** + * Paints the image onto a context as large as its layout's image: the + * documentation, the cables, the devices, the footer. The icons are Java's, + * loaded already. + */ +export function paintNetworkImage(context: Painter, view: TopologyView, icons: Readonly>, footerText: string): ExportLayout { + const { boxes, layout } = measureNetworkImage(context, view, footerText); + context.save(); + context.translate(-layout.image.x, -layout.image.y); + + for (const item of view.docs) paintDocumentation(context, item); + + context.lineWidth = 2; + for (const link of view.links) { + const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)]; + if (source === undefined || target === undefined) continue; + const control = cableControl(source.center, target.center); + context.beginPath(); + context.moveTo(source.center.x, source.center.y); + context.quadraticCurveTo(control.x, control.y, target.center.x, target.center.y); + context.strokeStyle = link.wireless ? WIRELESS_COLOR : CABLE_COLOR; + context.setLineDash(link.wireless ? [10] : []); + context.stroke(); + } + context.setLineDash([]); + + context.font = JAVA_LABEL_FONT; + context.textBaseline = 'alphabetic'; + for (const node of view.nodes) { + const box = boxes.get(node.id)!; + context.drawImage(icons[box.icon], box.iconRect.x, box.iconRect.y, box.iconRect.width, box.iconRect.height); + context.fillStyle = LABEL_COLOR; + context.textAlign = 'center'; + context.fillText(node.label, box.label.x, box.label.y); + } + + // A JLabel's words: at its left, its font's height high. + context.fillStyle = FOOTER_COLOR; + context.textAlign = 'left'; + context.fillText(footerText, layout.footer.x, layout.footer.y + JAVA_LABEL_ASCENT); + context.restore(); + 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“. */ +export function exportNames(projectName: string | undefined): { readonly footer: string; readonly file: string } { + const name = projectName?.trim() ?? ''; + if (name === '') return { footer: 'Filius', file: 'Filius.png' }; + const dot = name.lastIndexOf('.'); + return { footer: name, file: `${dot > 0 ? name.slice(0, dot) : name}.png` }; +} diff --git a/packages/editor/src/design/DocumentationSidebar.tsx b/packages/editor/src/design/DocumentationSidebar.tsx index 2b52d7d..772b2a7 100644 --- a/packages/editor/src/design/DocumentationSidebar.tsx +++ b/packages/editor/src/design/DocumentationSidebar.tsx @@ -2,6 +2,8 @@ // 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; // 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 +// „Fehler beim Speichern aufgetreten!“ shows below it. import { useRef, useState, type CSSProperties } from 'react'; import type { DocumentationItemKind } from '@mdg-filius/core'; @@ -9,10 +11,13 @@ import { DRAG_THRESHOLD } from '../canvas/Canvas.js'; import { useTranslate } from '../i18n/i18n.js'; import addRectangle from '../assets/documentation/add_small.png'; import addText from '../assets/documentation/add_text_small.png'; +import exportIcon from '../assets/documentation/download_small.png'; export interface DocumentationSidebarProps { /** 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; + /** „Exportieren“: the surface saved as an image; rejected, it failed. */ + readonly onExport: () => Promise; } const ITEMS: readonly { readonly kind: DocumentationItemKind; readonly icon: string; readonly label: string }[] = [ @@ -39,12 +44,25 @@ const item: CSSProperties = { touchAction: 'none', }; -export function DocumentationSidebar({ onItem }: DocumentationSidebarProps) { +export function DocumentationSidebar({ onItem, onExport }: DocumentationSidebarProps) { const t = useTranslate(); 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); /** The click the browser sends after a drag ends on the button: not a tap. */ const afterDrag = useRef(false); + const [exporting, setExporting] = useState(false); + const [failed, setFailed] = useState(false); + const exportImage = () => { + setExporting(true); + setFailed(false); + onExport().then( + () => setExporting(false), + () => { + setExporting(false); + setFailed(true); + }, + ); + }; const onPointerDown = (kind: DocumentationItemKind) => (event: React.PointerEvent) => { press.current = { kind, x: event.clientX, y: event.clientY, dragging: false }; @@ -106,6 +124,15 @@ export function DocumentationSidebar({ onItem }: DocumentationSidebarProps) { {t(each.label)} ))} + + {failed ? ( +

+ {t('docu.saveFailed')} +

+ ) : null} {ghost === null ? null : ( // The item's own size, its middle under the pointer, as Java drags it.
> { + const loaded = await Promise.all( + Object.entries(ICON_URLS).map(async ([name, url]) => { + const image = new Image(); + image.src = url; + await image.decode(); + return [name, image] as const; + }), + ); + return Object.fromEntries(loaded) as Record; +} + +/** The network and its documentation as a PNG, with the footer's words. */ +export async function networkImage(view: TopologyView, footerText: string): Promise { + const icons = await loadIcons(); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (context === null) throw new Error('no 2D context'); + const { layout } = measureNetworkImage(context, view, footerText); + // Setting the size starts the context afresh: the painting measures again. + canvas.width = Math.ceil(layout.image.width); + canvas.height = Math.ceil(layout.image.height); + paintNetworkImage(context, view, icons, footerText); + return new Promise((resolve, reject) => canvas.toBlob((blob) => (blob === null ? reject(new Error('no image')) : resolve(blob)), 'image/png')); +} diff --git a/packages/editor/src/download.ts b/packages/editor/src/download.ts new file mode 100644 index 0000000..d3bbf64 --- /dev/null +++ b/packages/editor/src/download.ts @@ -0,0 +1,10 @@ +// A file handed to the person: the browser saves it under the name given. + +export function saveFile(blob: Blob, name: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = name; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); +} diff --git a/packages/editor/src/i18n/de.json b/packages/editor/src/i18n/de.json index c502cb2..b668435 100644 --- a/packages/editor/src/i18n/de.json +++ b/packages/editor/src/i18n/de.json @@ -104,9 +104,12 @@ "dnsServer.tabNS": "Nameserver (NS)", "dnsServer.tables": "Einträge", "docu.color": "Farbe", + "docu.export": "Exportieren", + "docu.exportHint": "Als Bild exportieren", "docu.fontSize": "Schriftgröße", "docu.fontStyle": "Schriftstil", "docu.rectangle": "Gliederungsfläche", + "docu.saveFailed": "Fehler beim Speichern aufgetreten!", "docu.sidebar": "Dokumentation", "docu.sizeLarge": "Groß", "docu.sizeNormal": "Normal", diff --git a/packages/editor/src/i18n/en.json b/packages/editor/src/i18n/en.json index af18b2e..0141aa1 100644 --- a/packages/editor/src/i18n/en.json +++ b/packages/editor/src/i18n/en.json @@ -104,9 +104,12 @@ "dnsServer.tabNS": "Name server (NS)", "dnsServer.tables": "Records", "docu.color": "Colour", + "docu.export": "Export", + "docu.exportHint": "Export as Image", "docu.fontSize": "Font Size", "docu.fontStyle": "Font Style", "docu.rectangle": "Structure Field", + "docu.saveFailed": "Error occurred!", "docu.sidebar": "Documentation", "docu.sizeLarge": "Large", "docu.sizeNormal": "Normal", diff --git a/packages/editor/src/main.tsx b/packages/editor/src/main.tsx index 3c9cb52..beec65c 100644 --- a/packages/editor/src/main.tsx +++ b/packages/editor/src/main.tsx @@ -29,6 +29,8 @@ function App() { const params = new URLSearchParams(window.location.search); const [language, setLanguage] = useState(params.get('lang') === 'en' ? 'en' : 'de'); const [doc, setDoc] = useState(null); + /** The open file's name, for what the editor exports. */ + const [fileName, setFileName] = useState(undefined); const [example, setExample] = useState(params.get('example') ?? ''); useEffect(() => { @@ -51,13 +53,17 @@ function App() { const doc = new Y.Doc(); writeProject(doc, emptyProject()); setDoc(doc); + setFileName(undefined); return; } const url = exampleUrl(example); if (url === undefined) return; void fetch(url) .then((response) => response.arrayBuffer()) - .then((buffer) => setDoc(docOf(new Uint8Array(buffer)))); + .then((buffer) => { + setDoc(docOf(new Uint8Array(buffer))); + setFileName(example); + }); }, [example]); // The development page lets its browser tests read the document's drawing and write into a file as another would (never the editor embedded in the dashboard). @@ -102,13 +108,17 @@ function App() { accept=".fls" onChange={(event) => { const file = event.target.files?.[0]; - if (file !== undefined) void file.arrayBuffer().then((buffer) => setDoc(docOf(new Uint8Array(buffer)))); + if (file !== undefined) + void file.arrayBuffer().then((buffer) => { + setDoc(docOf(new Uint8Array(buffer))); + setFileName(file.name); + }); }} />
- {doc === null ? null : } + {doc === null ? null : }
diff --git a/packages/editor/src/simulation/CaptureWindow.tsx b/packages/editor/src/simulation/CaptureWindow.tsx index 38a9dbb..8cf9ae6 100644 --- a/packages/editor/src/simulation/CaptureWindow.tsx +++ b/packages/editor/src/simulation/CaptureWindow.tsx @@ -14,6 +14,7 @@ import { memo, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; import type { TraceFrame } from '@mdg-filius/protocol'; +import { saveFile } from '../download.js'; import { useLanguage, useTranslate } from '../i18n/i18n.js'; import { captureWords, exportText, frameCells, frameDetails, type CaptureWords } from './captureCells.js'; import type { Simulation } from './useSimulation.js'; @@ -111,14 +112,7 @@ export function CaptureWindow({ tabs, selected, simulation, x, y, z, onSelect, o const choose = useMemo(() => (number: number) => onChoose(selected, number), [onChoose, selected]); - const download = () => { - const url = URL.createObjectURL(new Blob([exportText(frames, clock, words)], { type: 'text/plain;charset=utf-8' })); - const link = document.createElement('a'); - link.href = url; - link.download = `${tab?.title ?? title}.txt`; - link.click(); - setTimeout(() => URL.revokeObjectURL(url), 0); - }; + const download = () => saveFile(new Blob([exportText(frames, clock, words)], { type: 'text/plain;charset=utf-8' }), `${tab?.title ?? title}.txt`); return (
diff --git a/packages/editor/test/i18n.test.ts b/packages/editor/test/i18n.test.ts index 80bf187..b95ea72 100644 --- a/packages/editor/test/i18n.test.ts +++ b/packages/editor/test/i18n.test.ts @@ -256,6 +256,8 @@ const FROM_JAVA: Readonly> = { 'docu.styleItalic': 'guievents_msg20', 'docu.styleBold': 'guievents_msg21', 'docu.color': 'guievents_msg13', + 'docu.export': 'docusidebar_msg5', + 'docu.exportHint': 'docusidebar_msg6', 'capture.title': 'lauscherdialog_msg1', 'capture.number': 'rp_lauscher_msg1', 'capture.time': 'rp_lauscher_msg2', @@ -361,6 +363,8 @@ const FROM_JAVA: Readonly> = { /** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */ const FROM_JAVA_MENDED: Readonly string]>> = { + // Java's English misspells it. + 'docu.saveFailed': ['guimainmemu_msg11', (java) => java.replace('occured', 'occurred')], 'router.prompt': ['guicontainer_msg1', (java) => java.replace(' \\n ', ' ').replace('Vermittlungsrechnerns', 'Vermittlungsrechners')], // A label above its list, without the colon of Java's label beside it. 'sheet.network': ['jhostkonfiguration_msg15', (java) => java.replace(/:$/, '')], diff --git a/packages/editor/test/networkImage.java.json b/packages/editor/test/networkImage.java.json new file mode 100644 index 0000000..53b9fe9 --- /dev/null +++ b/packages/editor/test/networkImage.java.json @@ -0,0 +1,803 @@ +{ + "projects": { + "nat_with_mapped_port.fls": { + "nodes": [ + { + "name": "Switch", + "bounds": [ + 300, + 131, + 91, + 72 + ] + }, + { + "name": "Switch", + "bounds": [ + 676, + 161, + 91, + 72 + ] + }, + { + "name": "Vermittlungsrechner", + "bounds": [ + 551, + 90, + 167, + 78 + ] + }, + { + "name": "Vermittlungsrechner", + "bounds": [ + 559, + 263, + 167, + 78 + ] + }, + { + "name": "Vermittlungsrechner", + "bounds": [ + 562, + 426, + 167, + 78 + ] + }, + { + "name": "DNS-Server", + "bounds": [ + 837, + 119, + 101, + 114 + ] + }, + { + "name": "192.168.0.100", + "bounds": [ + 152, + 54, + 116, + 100 + ] + }, + { + "name": "192.168.0.101", + "bounds": [ + 157, + 202, + 116, + 100 + ] + }, + { + "name": "Webserver www.jupiter.de", + "bounds": [ + 804, + 307, + 213, + 114 + ] + }, + { + "name": "Webserver - 192.168.0.10", + "bounds": [ + 99, + 363, + 198, + 114 + ] + }, + { + "name": "Switch", + "bounds": [ + 313, + 444, + 91, + 72 + ] + }, + { + "name": "192.168.0.100", + "bounds": [ + 260, + 538, + 116, + 100 + ] + }, + { + "name": "Heimrouter Vincent", + "bounds": [ + 384, + 438, + 158, + 78 + ] + }, + { + "name": "Heimrouter Paul", + "bounds": [ + 375, + 122, + 136, + 78 + ] + }, + { + "name": "Switch", + "bounds": [ + 694, + 342, + 91, + 72 + ] + } + ], + "cables": [ + [ + 208, + 102, + 139, + 67 + ], + [ + 213, + 165, + 134, + 89 + ], + [ + 632, + 127, + 91, + 72 + ], + [ + 719, + 174, + 170, + 25 + ], + [ + 640, + 195, + 83, + 109 + ], + [ + 196, + 418, + 164, + 64 + ], + [ + 316, + 478, + 44, + 112 + ], + [ + 343, + 159, + 102, + 10 + ], + [ + 441, + 127, + 195, + 36 + ], + [ + 356, + 475, + 109, + 7 + ], + [ + 461, + 463, + 186, + 16 + ], + [ + 640, + 300, + 101, + 80 + ], + [ + 643, + 376, + 98, + 91 + ], + [ + 737, + 362, + 175, + 18 + ] + ], + "docs": [ + { + "type": 1, + "bounds": [ + 94, + 34, + 428, + 292 + ], + "color": "204,204,204,255" + }, + { + "type": 2, + "bounds": [ + 316, + 42, + 142, + 26 + ], + "color": "0,0,0,255" + }, + { + "type": 1, + "bounds": [ + 95, + 349, + 428, + 296 + ], + "color": "204,204,204,255" + }, + { + "type": 2, + "bounds": [ + 322, + 358, + 169, + 27 + ], + "color": "0,0,0,255" + }, + { + "type": 2, + "bounds": [ + 388, + 523, + 129, + 73 + ], + "color": "0,0,0,255" + }, + { + "type": 2, + "bounds": [ + 113, + 483, + 167, + 108 + ], + "color": "0,0,0,255" + }, + { + "type": 1, + "bounds": [ + 792, + 52, + 224, + 489 + ], + "color": "204,204,204,255" + }, + { + "type": 2, + "bounds": [ + 828, + 430, + 148, + 50 + ], + "color": "0,0,0,255" + }, + { + "type": 2, + "bounds": [ + 802, + 57, + 187, + 50 + ], + "color": "0,0,0,255" + } + ], + "path": "nat_with_mapped_port.fls", + "panelSize": [ + 2020, + 1620 + ], + "network": { + "minX": 99.0, + "maxX": 1017.0, + "minY": 54.0, + "maxY": 638.0 + }, + "documentation": { + "minX": 94.0, + "maxX": 1016.0, + "minY": 34.0, + "maxY": 645.0 + }, + "footerArea": { + "minX": 104.0, + "maxX": 814.0, + "minY": 675.0, + "maxY": 690.0 + }, + "footer": { + "text": "", + "bounds": [ + 104, + 675, + 710, + 15 + ], + "stringWidth": 690, + "fontHeight": 15, + "foreground": "192,192,192,255" + }, + "clip": [ + 94, + 34, + 943, + 676 + ], + "imageSize": [ + 943, + 676 + ], + "cornerPixel": "0,0,0,0", + "footerColours": [ + [ + "0,0,0,0", + 8267 + ], + [ + "192,192,192,255", + 2383 + ] + ], + "rectangleCentre": [ + [ + "215,215,215,255", + 1 + ] + ], + "rectangleCentreColor": "204,204,204,255" + }, + "zwei_rechnernetze_komplett.fls": { + "nodes": [ + { + "name": "Switch 1", + "bounds": [ + 164, + 178, + 91, + 72 + ] + }, + { + "name": "Switch 2", + "bounds": [ + 612, + 104, + 91, + 72 + ] + }, + { + "name": "DNS-Server 2.10", + "bounds": [ + 694, + 19, + 134, + 114 + ] + }, + { + "name": "Notebook 1.10", + "bounds": [ + 53, + 100, + 123, + 100 + ] + }, + { + "name": "Notebook 1.11", + "bounds": [ + 70, + 235, + 123, + 100 + ] + }, + { + "name": "Vermittlungsrechner", + "bounds": [ + 373, + 106, + 167, + 78 + ] + } + ], + "cables": [ + [ + 112, + 148, + 99, + 68 + ], + [ + 129, + 212, + 82, + 75 + ], + [ + 655, + 74, + 108, + 68 + ], + [ + 207, + 143, + 251, + 73 + ], + [ + 454, + 138, + 205, + 9 + ] + ], + "docs": [], + "path": "zwei_rechnernetze_komplett.fls", + "panelSize": [ + 2020, + 1620 + ], + "network": { + "minX": 53.0, + "maxX": 828.0, + "minY": 19.0, + "maxY": 335.0 + }, + "documentation": { + "minX": 2.147483647E9, + "maxX": 0.0, + "minY": 2.147483647E9, + "maxY": 0.0 + }, + "footerArea": { + "minX": 63.0, + "maxX": 817.0, + "minY": 365.0, + "maxY": 380.0 + }, + "footer": { + "text": "", + "bounds": [ + 63, + 365, + 754, + 15 + ], + "stringWidth": 734, + "fontHeight": 15, + "foreground": "192,192,192,255" + }, + "clip": [ + 53, + 19, + 795, + 381 + ], + "imageSize": [ + 795, + 381 + ], + "cornerPixel": "0,0,0,0", + "footerColours": [ + [ + "0,0,0,0", + 8805 + ], + [ + "192,192,192,255", + 2505 + ] + ] + }, + "documentation.fls": { + "nodes": [], + "cables": [], + "docs": [ + { + "type": 2, + "bounds": [ + 40, + 40, + 160, + 60 + ], + "color": "79,130,189,255" + }, + { + "type": 2, + "bounds": [ + 40, + 140, + 120, + 50 + ], + "color": "0,0,0,255" + }, + { + "type": 1, + "bounds": [ + 20, + 20, + 300, + 200 + ], + "color": "255,201,201,255" + }, + { + "type": 1, + "bounds": [ + 400, + 20, + 100, + 50 + ], + "color": "204,204,204,255" + } + ], + "path": "documentation.fls", + "panelSize": [ + 2020, + 1620 + ], + "network": { + "minX": 2.147483647E9, + "maxX": 0.0, + "minY": 2.147483647E9, + "maxY": 0.0 + }, + "documentation": { + "minX": 20.0, + "maxX": 500.0, + "minY": 20.0, + "maxY": 220.0 + }, + "footerArea": { + "minX": 30.0, + "maxX": 399.0, + "minY": 250.0, + "maxY": 265.0 + }, + "footer": { + "text": "", + "bounds": [ + 30, + 250, + 369, + 15 + ], + "stringWidth": 349, + "fontHeight": 15, + "foreground": "192,192,192,255" + }, + "clip": [ + 20, + 20, + 500, + 265 + ], + "imageSize": [ + 500, + 265 + ], + "cornerPixel": "0,0,0,0", + "footerColours": [ + [ + "0,0,0,0", + 4393 + ], + [ + "192,192,192,255", + 1142 + ] + ], + "rectangleCentre": [ + [ + "255,212,212,255", + 1 + ] + ], + "rectangleCentreColor": "255,201,201,255" + }, + "modem_beispiel.fls": { + "nodes": [ + { + "name": "Rechner 0.11", + "bounds": [ + 586, + 46, + 112, + 114 + ] + }, + { + "name": "Notebook 0.10", + "bounds": [ + 42, + 182, + 123, + 100 + ] + }, + { + "name": "Modem (Zugreifend)", + "bounds": [ + 237, + 212, + 165, + 76 + ] + }, + { + "name": "Modem (Zugangspunkt)", + "bounds": [ + 396, + 79, + 190, + 76 + ] + } + ], + "cables": [ + [ + 101, + 230, + 220, + 22 + ], + [ + 489, + 101, + 155, + 18 + ] + ], + "docs": [], + "path": "modem_beispiel.fls", + "panelSize": [ + 2020, + 1620 + ], + "network": { + "minX": 42.0, + "maxX": 698.0, + "minY": 46.0, + "maxY": 288.0 + }, + "documentation": { + "minX": 2.147483647E9, + "maxX": 0.0, + "minY": 2.147483647E9, + "maxY": 0.0 + }, + "footerArea": { + "minX": 52.0, + "maxX": 715.0, + "minY": 318.0, + "maxY": 333.0 + }, + "footer": { + "text": "", + "bounds": [ + 52, + 318, + 663, + 15 + ], + "stringWidth": 643, + "fontHeight": 15, + "foreground": "192,192,192,255" + }, + "clip": [ + 42, + 46, + 693, + 307 + ], + "imageSize": [ + 693, + 307 + ], + "cornerPixel": "0,0,0,0", + "footerColours": [ + [ + "0,0,0,0", + 7708 + ], + [ + "192,192,192,255", + 2237 + ] + ] + } + }, + "empty": { + "nodes": [], + "cables": [], + "docs": [], + "path": null, + "panelSize": [ + 2020, + 1620 + ], + "network": { + "minX": 2.147483647E9, + "maxX": 0.0, + "minY": 2.147483647E9, + "maxY": 0.0 + }, + "documentation": { + "minX": 2.147483647E9, + "maxX": 0.0, + "minY": 2.147483647E9, + "maxY": 0.0 + }, + "footerArea": { + "minX": 0.0, + "maxX": 0.0, + "minY": 30.0, + "maxY": 45.0 + }, + "footer": { + "text": "Filius", + "bounds": [ + -2147483639, + 30, + 57, + 15 + ], + "stringWidth": 37, + "fontHeight": 15, + "foreground": "192,192,192,255" + }, + "clip": [ + 0, + 30, + 20, + 35 + ], + "imageSize": [ + 20, + 35 + ], + "cornerPixel": "0,0,0,0", + "footerColours": [] + } +} diff --git a/packages/editor/test/networkImage.test.ts b/packages/editor/test/networkImage.test.ts new file mode 100644 index 0000000..9cff215 --- /dev/null +++ b/packages/editor/test/networkImage.test.ts @@ -0,0 +1,317 @@ +// FILIUS' „Exportieren“ (GUIContainer.createNetworkImage), against Java's own +// print panel (tools/java-oracle's ExportRun on four projects and an empty +// one, networkImage.java.json): the part of the surface the image shows and +// where its footer lies, from the bounds of Java's own elements and footer +// (their sizes depend on the font of the machine Java runs on); Java's +// cable bounds; the colours Java paints (a transparent background, the +// footer light grey, a rectangle as the design shows it); and the painting +// itself on a recording context. + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import type { DocumentationView, TopologyView } from '@mdg-filius/doc'; +import { rectangleBorder, rectangleFill } from '../src/canvas/documentationPaint.js'; +import { cableControl, cableFrame, deviceBox, JAVA_LABEL_FONT, JAVA_LABEL_FONT_HEIGHT, type IconName } from '../src/canvas/layout.js'; +import { + CABLE_COLOR, + exportLayout, + exportNames, + FOOTER_COLOR, + LABEL_COLOR, + measureNetworkImage, + networkParts, + paintNetworkImage, + WIRELESS_COLOR, + wrapLines, + type Painter, +} from '../src/canvas/networkImage.js'; + +type Box = [number, number, number, number]; +interface JavaProject { + readonly nodes: readonly { readonly name: string; readonly bounds: Box }[]; + readonly cables: readonly Box[]; + readonly docs: readonly { readonly type: number; readonly bounds: Box; readonly color: string }[]; + readonly footer: { readonly text: string; readonly bounds: Box; readonly stringWidth: number; readonly fontHeight: number; readonly foreground: string }; + readonly clip: Box; + readonly imageSize: [number, number]; + readonly cornerPixel: string; + readonly footerColours: readonly [string, number][]; + readonly rectangleCentre?: readonly [string, number][]; + readonly rectangleCentreColor?: string; +} +const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'networkImage.java.json'), 'utf8')) as { + readonly projects: Readonly>; + readonly empty: JavaProject; +}; + +const rect = ([x, y, width, height]: Box) => ({ x, y, width, height }); +const rgb = (text: string) => { + const [red, green, blue, alpha] = text.split(',').map(Number); + return `rgb(${red}, ${green}, ${blue})${alpha === 255 ? '' : ` alpha ${alpha}`}`; +}; + +describe("the exported image's layout, as Java's print panel", () => { + it.each(Object.keys(java.projects))('cuts %s as Java does: 10 around, the footer 20 below the lowest element, 20 below the footer', (name) => { + const project = java.projects[name]!; + // Java's footer: its words and 20 more wide, its font high. + expect(project.footer.bounds[2]).toBe(project.footer.stringWidth + 20); + expect(project.footer.bounds[3]).toBe(project.footer.fontHeight); + expect(project.footer.text).toBe(''); + const parts = [...project.nodes.map((node) => rect(node.bounds)), ...project.cables.map(rect), ...project.docs.map((doc) => rect(doc.bounds))]; + const layout = exportLayout(parts, { width: project.footer.bounds[2], height: project.footer.bounds[3] }); + // Java's panel holds the elements 10 to the right and down (EMPTY_BORDER); its clip is in the panel's terms. + const [x, y, width, height] = project.clip; + expect(layout.image).toEqual({ x: x - 10, y: y - 10, width, height }); + expect(layout.footer).toEqual({ x: project.footer.bounds[0] - 10, y: project.footer.bounds[1] - 10, width: project.footer.bounds[2], height: project.footer.bounds[3] }); + expect(project.imageSize).toEqual([width, height]); + }); + + it('is as wide as the footer and 20 more where the footer is wider than the elements', () => { + const layout = exportLayout([{ x: 50, y: 40, width: 100, height: 60 }], { width: 300, height: 17 }); + expect(layout.footer).toEqual({ x: 50, y: 120, width: 300, height: 17 }); + expect(layout.image).toEqual({ x: 40, y: 30, width: 330, height: 127 }); + }); + + it("shows an empty project's footer alone (Java's image of 20 × 35 shows nothing: its footer overflows)", () => { + expect(java.empty.imageSize).toEqual([20, 35]); + expect(java.empty.footerColours).toEqual([]); + expect(java.empty.footer.text).toBe('Filius'); + const layout = exportLayout([], { width: 57, height: 17 }); + expect(layout.footer).toEqual({ x: 0, y: 0, width: 57, height: 17 }); + expect(layout.image).toEqual({ x: -10, y: -10, width: 87, height: 47 }); + }); + + it("does not cut at the surface's edges, which the editor does not have", () => { + expect(exportLayout([{ x: -40, y: -30, width: 20, height: 10 }], { width: 10, height: 17 }).image).toEqual({ x: -50, y: -40, width: 40, height: 77 }); + }); + + it("takes Java's cable bounds: the two middles with 2 to spare", () => { + const project = java.projects['nat_with_mapped_port.fls']!; + const middles = project.nodes.map(({ bounds: [x, y, width, height] }) => ({ x: Math.trunc(x + 0.5 * width), y: Math.trunc(y + 0.5 * height) })); + for (const cable of project.cables) { + const matches = middles.flatMap((first, index) => middles.slice(index + 1).filter((second) => JSON.stringify(cableFrame(first, second)) === JSON.stringify(rect(cable)))); + expect(matches.length, JSON.stringify(cable)).toBeGreaterThan(0); + } + expect(cableFrame({ x: 50, y: 5 }, { x: 10, y: 20 })).toEqual({ x: 8, y: 3, width: 44, height: 19 }); + }); + + it("draws cables in JCablePanel's colours: dark grey (64), a wireless link light grey (192)", () => { + expect([CABLE_COLOR, WIRELESS_COLOR]).toEqual(['rgb(64, 64, 64)', 'rgb(192, 192, 192)']); + }); + + it("paints as Java's image does: transparent around, the footer light grey, a rectangle as the design shows it", () => { + for (const project of [...Object.values(java.projects), java.empty]) expect(project.cornerPixel).toBe('0,0,0,0'); + for (const project of Object.values(java.projects)) { + expect(rgb(project.footer.foreground)).toBe(FOOTER_COLOR); + expect(project.footerColours.map(([colour]) => colour)).toContain(project.footer.foreground); + if (project.rectangleCentre === undefined) continue; + const [red, green, blue] = project.rectangleCentreColor!.split(',').map((part) => Number(part) / 255); + expect(rgb(project.rectangleCentre[0]![0])).toBe(rectangleFill({ red: red!, green: green!, blue: blue!, alpha: 1 }, false)); + } + }); +}); + +describe('the words of a text in the image', () => { + const measure = (text: string) => text.length * 10; + + it('keeps lines that fit, and its own lines, empty ones too', () => { + expect(wrapLines('Netz A', 100, measure)).toEqual(['Netz A']); + expect(wrapLines('eins\n\nzwei', 100, measure)).toEqual(['eins', '', 'zwei']); + expect(wrapLines('', 100, measure)).toEqual(['']); + }); + + it('breaks after a space, the space staying at the end of its line', () => { + expect(wrapLines('Netz A und B', 80, measure)).toEqual(['Netz A ', 'und B']); + // A trailing space does not count: "abcd efgh " fits in 90. + expect(wrapLines('abcd efgh ijk', 90, measure)).toEqual(['abcd efgh ', 'ijk']); + expect(wrapLines(' eingerückt', 200, measure)).toEqual([' eingerückt']); + }); + + it('breaks after a tab as after a space', () => { + expect(wrapLines('abc\tdefghij', 70, measure)).toEqual(['abc\t', 'defghij']); + }); + + it('holds a character a line, where one is wider than the line', () => { + expect(wrapLines('ab', 5, measure)).toEqual(['a', 'b']); + }); + + it('breaks a word wider than the line where it reaches the edge', () => { + expect(wrapLines('Vermittlungsrechner', 50, measure)).toEqual(['Vermi', 'ttlun', 'gsrec', 'hner']); + expect(wrapLines('ab Vermittlung', 50, measure)).toEqual(['ab ', 'Vermi', 'ttlun', 'g']); + }); +}); + +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", () => { + expect(exportNames('nat_with_mapped_port.fls')).toEqual({ footer: 'nat_with_mapped_port.fls', file: 'nat_with_mapped_port.png' }); + expect(exportNames('netz.v2.fls')).toEqual({ footer: 'netz.v2.fls', file: 'netz.v2.png' }); + expect(exportNames('Netz')).toEqual({ footer: 'Netz', file: 'Netz.png' }); + expect(exportNames('.fls')).toEqual({ footer: '.fls', file: '.fls.png' }); + }); + + it('writes „Filius“ without a name, as Java does without a file', () => { + expect(exportNames(undefined)).toEqual({ footer: 'Filius', file: 'Filius.png' }); + expect(exportNames(' ')).toEqual({ footer: 'Filius', file: 'Filius.png' }); + }); +}); + +/** A 2D context that writes down what is painted, its text 7 wide a character, a font's ascent 0.9 of its size, its descent 0.25. */ +function recorder() { + const calls: unknown[][] = []; + const state = { font: '10px sans-serif', fillStyle: '' as unknown, strokeStyle: '' as unknown, lineWidth: 1, textAlign: 'start', textBaseline: 'alphabetic' }; + const size = () => Number(/(\d+)px/.exec(state.font)![1]); + const record = + (name: string) => + (...args: unknown[]) => { + calls.push([name, ...args]); + }; + const context = { + save: record('save'), + restore: record('restore'), + translate: record('translate'), + beginPath: record('beginPath'), + moveTo: record('moveTo'), + quadraticCurveTo: record('quadraticCurveTo'), + roundRect: record('roundRect'), + rect: record('rect'), + clip: record('clip'), + fill: () => calls.push(['fill', state.fillStyle]), + stroke: () => calls.push(['stroke', state.strokeStyle, state.lineWidth]), + fillText: (text: string, x: number, y: number) => calls.push(['fillText', text, x, y, state.font, state.fillStyle, state.textAlign]), + measureText: (text: string) => ({ width: text.length * 7, fontBoundingBoxAscent: size() * 0.9, fontBoundingBoxDescent: size() * 0.25 }), + drawImage: (image: unknown, x: number, y: number, width: number, height: number) => calls.push(['drawImage', image, x, y, width, height]), + setLineDash: record('setLineDash'), + }; + for (const key of Object.keys(state) as (keyof typeof state)[]) { + Object.defineProperty(context, key, { + get: () => state[key], + set: (value: never) => { + state[key] = value; + }, + }); + } + return { context: context as unknown as Painter, calls }; +} + +const grey = { red: 204 / 255, green: 204 / 255, blue: 204 / 255, alpha: 1 }; +const doc = (fields: Partial & Pick): DocumentationView => ({ + x: 0, + y: 0, + width: 100, + height: 50, + text: '', + color: grey, + font: 'Dialog', + size: 12, + bold: false, + italic: false, + order: 0, + ...fields, +}); +const VIEW: TopologyView = { + nodes: [ + { id: 'a', kind: 'pc', name: 'A', label: 'Rechner A', x: 100, y: 80, cloud: false, ports: [] }, + { id: 'b', kind: 'networkSwitch', name: 'S', label: 'Switch', x: 300, y: 200, cloud: false, ports: [] }, + { id: 'c', kind: 'notebook', name: 'N', label: 'N', x: 500, y: 60, cloud: false, ports: [] }, + ], + links: [ + { id: 'l1', sourceNode: 'a', sourcePort: 'p', targetNode: 'b', targetPort: 'q', wireless: false }, + { id: 'l2', sourceNode: 'c', sourcePort: 'p', targetNode: 'b', targetPort: 'r', wireless: true }, + // Its device gone (before the janitor): left out, as the surface leaves it. + { id: 'l3', sourceNode: 'a', sourcePort: 'p', targetNode: 'gone', targetPort: 'r', wireless: false }, + ], + docs: [ + doc({ id: 'r', kind: 'rectangle', x: 60, y: 40, width: 200, height: 120, color: { red: 1, green: 201 / 255, blue: 201 / 255, alpha: 1 } }), + doc({ id: 't', kind: 'text', x: 70, y: 300, width: 64, height: 40, text: 'Netz A123 B', size: 18, italic: true, color: { red: 0, green: 0, blue: 0, alpha: 1 } }), + ], +}; +const ICONS = Object.fromEntries(['server', 'laptop', 'switch', 'cloud', 'router', 'gateway', 'modem'].map((name) => [name, `icon:${name}`])) as unknown as Record; + +describe('painting the image', () => { + const labelWidth = (text: string) => text.length * 7; + + it("counts the devices, the cables between them and the documentation, as Java's panels do", () => { + const { boxes, parts } = networkParts(VIEW, labelWidth); + const [a, b, c] = ['a', 'b', 'c'].map((id) => boxes.get(id)!); + expect(a).toEqual(deviceBox(VIEW.nodes[0]!, labelWidth('Rechner A'))); + expect(parts).toEqual([a, b, c, cableFrame(a!.center, b!.center), cableFrame(c!.center, b!.center), { x: 60, y: 40, width: 200, height: 120 }, { x: 70, y: 300, width: 64, height: 40 }]); + }); + + it('measures the labels and the footer in Java’s font', () => { + const { context } = recorder(); + const { layout } = measureNetworkImage(context, VIEW, 'netz.fls'); + expect(context.font).toBe(JAVA_LABEL_FONT); + const { parts } = networkParts(VIEW, labelWidth); + expect(layout).toEqual(exportLayout(parts, { width: 8 * 7 + 20, height: JAVA_LABEL_FONT_HEIGHT })); + }); + + it('paints the documentation, then the cables, then the devices, then the footer, on the cut-out', () => { + const { context, calls } = recorder(); + const layout = paintNetworkImage(context, VIEW, ICONS, 'netz.fls'); + const { boxes } = networkParts(VIEW, labelWidth); + const [a, b, c] = ['a', 'b', 'c'].map((id) => boxes.get(id)!); + const named = (name: string) => calls.filter((call) => call[0] === name); + expect(calls[1]).toEqual(['translate', -layout.image.x, -layout.image.y]); + + // The rectangle as the design shows it: inset by 1, corners of 5, opaque and lighter, its border darker. + const pink = VIEW.docs[0]!.color; + expect(named('roundRect')).toEqual([['roundRect', 61, 41, 198, 118, 5]]); + expect(named('fill')).toEqual([['fill', rectangleFill(pink, false)]]); + expect(named('stroke')[0]).toEqual(['stroke', rectangleBorder(pink), 1]); + + // The text inside its frame, in its font, broken to its width less the padding (62: "Netz A123" is 63 wide). + expect(named('rect')).toEqual([['rect', 70, 300, 64, 40]]); + const words = named('fillText').filter((call) => String(call[4]).startsWith('italic')); + expect(words).toEqual([ + // A line the font's ascent and descent high (16.2 + 4.5). + ['fillText', 'Netz ', 71, 301 + 16.2, 'italic 18px sans-serif', 'rgba(0, 0, 0, 1)', 'left'], + ['fillText', 'A123 B', 71, 301 + 16.2 + 20.7, 'italic 18px sans-serif', 'rgba(0, 0, 0, 1)', 'left'], + ]); + + // The cables: Java's curve, a wireless one grey and dashed; the one without its device left out. + expect(named('moveTo')).toEqual([ + ['moveTo', a!.center.x, a!.center.y], + ['moveTo', c!.center.x, c!.center.y], + ]); + const [first, second] = [cableControl(a!.center, b!.center), cableControl(c!.center, b!.center)]; + expect(named('quadraticCurveTo')).toEqual([ + ['quadraticCurveTo', first.x, first.y, b!.center.x, b!.center.y], + ['quadraticCurveTo', second.x, second.y, b!.center.x, b!.center.y], + ]); + expect(named('stroke').slice(1)).toEqual([ + ['stroke', CABLE_COLOR, 2], + ['stroke', WIRELESS_COLOR, 2], + ]); + expect(named('setLineDash')).toEqual([ + ['setLineDash', []], + ['setLineDash', [10]], + ['setLineDash', []], + ]); + + // The devices: Java's icon, the label in the middle below it, black. + expect(named('drawImage')).toEqual([ + ['drawImage', 'icon:server', a!.iconRect.x, a!.iconRect.y, 64, 94], + ['drawImage', 'icon:switch', b!.iconRect.x, b!.iconRect.y, 70, 52], + ['drawImage', 'icon:laptop', c!.iconRect.x, c!.iconRect.y, 84, 80], + ]); + const labels = named('fillText').filter((call) => call[4] === JAVA_LABEL_FONT); + expect(labels).toEqual([ + ['fillText', 'Rechner A', a!.label.x, a!.label.y, JAVA_LABEL_FONT, LABEL_COLOR, 'center'], + ['fillText', 'Switch', b!.label.x, b!.label.y, JAVA_LABEL_FONT, LABEL_COLOR, 'center'], + ['fillText', 'N', c!.label.x, c!.label.y, JAVA_LABEL_FONT, LABEL_COLOR, 'center'], + // The footer: at its left, its baseline the font's ascent down, light grey. + ['fillText', 'netz.fls', layout.footer.x, layout.footer.y + 14, JAVA_LABEL_FONT, FOOTER_COLOR, 'left'], + ]); + + // In that order, and the context left as it was found. + const order = calls.map((call) => call[0]); + expect(order.indexOf('roundRect')).toBeLessThan(order.indexOf('rect')); + expect(order.lastIndexOf('fillText', order.indexOf('moveTo'))).toBeGreaterThan(order.indexOf('rect')); + expect(order.lastIndexOf('quadraticCurveTo')).toBeLessThan(order.indexOf('drawImage')); + expect(order.filter((name) => name === 'save').length).toBe(order.filter((name) => name === 'restore').length); + expect(order.at(-1)).toBe('restore'); + expect(layout.footer.y).toBe(340 + 20); + }); +}); diff --git a/tools/java-oracle/README.md b/tools/java-oracle/README.md index 89ba422..794a075 100644 --- a/tools/java-oracle/README.md +++ b/tools/java-oracle/README.md @@ -1699,3 +1699,35 @@ Gemessen (04.10.2026): Falle der Sonde: Der Menüpunkt „Normal“ steht in beiden Untermenüs; die Sonde nimmt den ersten (Schriftgröße). + +### Dokumentation: „Exportieren“ (F4.8c) + +`ExportRun` öffnet ein Projekt, wie Javas Menü es öffnet +(`Information.reset`, `SzenarioVerwaltung.laden`, `updateViewport`, +`updateCables`), holt per Reflection das Druckpanel +(`GUIContainer.prepareExportPanel`) und liest dessen Ausschnitt +(`getClipX` … `getClipHeight`), die Grenzen seiner drei Ebenen (Netz, +Dokumentation, Fußzeile; `GUIMainArea.minX` …) und die Fußzeile. Dann malt es +mit `createNetworkImage` das Bild und misst Größe, Eckpixel, die Farben der +Fußzeile und die Füllung einer Fläche darin. Vier Projekte +(`nat_with_mapped_port`, `zwei_rechnernetze_komplett`, die Fixture +`documentation.fls`, `modem_beispiel`) und ein leeres +(`packages/editor/test/networkImage.java.json`; Javas Bilder unter +`.build/export`). + +Gemessen (04.10.2026): + +* Ausschnitt: alle Geräte, Kabel (die beiden Mitten mit 2 Rand) und + Dokumentationselemente, links, oben und rechts 10 Rand. Die Fußzeile steht + 20 unter dem untersten Element, bündig mit dem linkesten; sie ist so breit + wie ihr Text und 20 mehr, so hoch wie ihre Schrift; unter ihr 20 Rand. Ist + die Fußzeile breiter als der Inhalt, reicht das Bild 20 über sie hinaus. +* Fußzeile: der Pfad der Datei, ohne Datei „Filius“, hellgrau (192). +* Hintergrund durchsichtig; Dokumentation unter dem Netz, gemalt wie im + Entwurf (Fläche 204 → 215, 201 → 212); nichts ausgewählt. +* Leeres Projekt: ein leeres Bild 20 × 35. Die Fußzeile landet durch einen + Überlauf (`Integer.MAX_VALUE + 10`) außerhalb. Nicht übernommen. + +Falle der Sonde: Das Druckpanel nimmt Javas Geräte-Etiketten aus der +Oberfläche (eine Swing-Komponente hat nur einen Elternteil); erst +`updateViewport` legt sie zurück. diff --git a/tools/java-oracle/run.sh b/tools/java-oracle/run.sh index 839b96c..044266a 100755 --- a/tools/java-oracle/run.sh +++ b/tools/java-oracle/run.sh @@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then fi classpath="$upstream/target/filius.jar:$upstream/target/lib/*" mkdir -p "$here/.build" -javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java" "$here/src/MailWindowRun.java" "$here/src/SaveAfterRunRun.java" "$here/src/TextEditorWindowRun.java" "$here/src/FileExplorerWindowRun.java" "$here/src/EchoWindowRun.java" "$here/src/DnsWindowRun.java" "$here/src/FirewallWindowRun.java" "$here/src/GnutellaWindowRun.java" "$here/src/ViewersRun.java" "$here/src/ExchangeWindowRun.java" "$here/src/DocuRun.java" +javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java" "$here/src/Base64Cases.java" "$here/src/ScenarioNetwork.java" "$here/src/DhcpRun.java" "$here/src/TcpRun.java" "$here/src/DnsRun.java" "$here/src/ArpRun.java" "$here/src/RipRun.java" "$here/src/NatRun.java" "$here/src/WlanRun.java" "$here/src/ExampleRun.java" "$here/src/EchoRun.java" "$here/src/WebRun.java" "$here/src/RouterWebRun.java" "$here/src/KonfigCases.java" "$here/src/EmailRun.java" "$here/src/MailCases.java" "$here/src/EmailClientRun.java" "$here/src/TerminalCases.java" "$here/src/TerminalRun.java" "$here/src/FileCases.java" "$here/src/GnutellaRun.java" "$here/src/NewDeviceRun.java" "$here/src/SheetRun.java" "$here/src/TerminalWindowRun.java" "$here/src/InstallRun.java" "$here/src/BrowserWindowRun.java" "$here/src/MailWindowRun.java" "$here/src/SaveAfterRunRun.java" "$here/src/TextEditorWindowRun.java" "$here/src/FileExplorerWindowRun.java" "$here/src/EchoWindowRun.java" "$here/src/DnsWindowRun.java" "$here/src/FirewallWindowRun.java" "$here/src/GnutellaWindowRun.java" "$here/src/ViewersRun.java" "$here/src/ExchangeWindowRun.java" "$here/src/DocuRun.java" "$here/src/ExportRun.java" # FILIUS writes its settings below $HOME; keep them out of the real one. mkdir -p "$here/.build/home" # Removed first: a crashed run must not leave the previous result standing. @@ -499,6 +499,17 @@ HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE - "$output" "$root/packages/doc/test/fixtures/documentation.fls" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || true test -s "$output" +# FILIUS' „Exportieren“ (ExportRun): the print panel and the image Java makes +# of four projects and an empty one — the part cut out, the footer, the +# colours (packages/editor/test/networkImage.test.ts). Java's images go to +# .build/export, to look at. +output="$root/packages/editor/test/networkImage.java.json" +rm -f "$output" +HOME="$here/.build/home" xvfb-run -a java -Duser.language=de -Duser.country=DE -cp "$classpath:$here/.build" ExportRun \ + "$output" "$here/.build/export" "$upstream/beispiele/nat_with_mapped_port.fls" "$upstream/beispiele/zwei_rechnernetze_komplett.fls" \ + "$root/packages/doc/test/fixtures/documentation.fls" "$upstream/beispiele/modem_beispiel.fls" 2>&1 | grep -v 'logback\|^$\|^\s*at ' || true +test -s "$output" + # FILIUS' own examples as Java FILIUS runs them, opened and started as FILIUS # does (ExampleRun): the acceptance of F2 (packages/fls/test/examplesJava.test.ts) # and the classics of F3 (web, email, gnutella; packages/fls/test/classicsJava.test.ts). diff --git a/tools/java-oracle/src/ExportRun.java b/tools/java-oracle/src/ExportRun.java new file mode 100644 index 0000000..ced3ffb --- /dev/null +++ b/tools/java-oracle/src/ExportRun.java @@ -0,0 +1,214 @@ +// FILIUS' „Exportieren“ in the documentation mode measured +// (GUIContainer.createNetworkImage, GUIPrintPanel): each project opened as +// the menu opens it, then the print panel Java paints — the bounds of every +// device, cable and documentation element in it, the clip bounds of its +// network, documentation and footer panels, the footer's text, place and +// size, the rectangle cut out of the panel — and the image itself: its size, +// whether its background is transparent, the colours of its footer, the fill +// of a rectangle in it. An empty project too (no element at all). The +// reference for the editor's image export (packages/editor/test/networkImage.test.ts). +// +// Usage: java -cp filius.jar:lib/*:classes ExportRun ... + +import filius.gui.GUIContainer; +import filius.gui.netzwerksicht.GUIDocuItem; +import filius.gui.netzwerksicht.GUIKabelItem; +import filius.gui.netzwerksicht.GUIKnotenItem; +import filius.gui.netzwerksicht.GUIMainArea; +import filius.gui.netzwerksicht.GUIPrintPanel; +import filius.rahmenprogramm.Information; +import filius.rahmenprogramm.SzenarioVerwaltung; + +import java.awt.Component; +import java.awt.FontMetrics; +import java.awt.Rectangle; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.FileOutputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import javax.imageio.ImageIO; +import javax.swing.JLabel; +import javax.swing.SwingUtilities; + +public class ExportRun { + static Object get(Object owner, Class type, String name) throws Exception { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + return field.get(owner); + } + + static List bounds(Component component) { + Rectangle rect = component.getBounds(); + return List.of(rect.x, rect.y, rect.width, rect.height); + } + + static Map area(GUIMainArea panel) throws Exception { + Map shown = new LinkedHashMap<>(); + for (String name : new String[] { "minX", "maxX", "minY", "maxY" }) { + shown.put(name, get(panel, GUIMainArea.class, name)); + } + return shown; + } + + static String rgba(int argb) { + return ((argb >> 16) & 255) + "," + ((argb >> 8) & 255) + "," + (argb & 255) + "," + ((argb >>> 24) & 255); + } + + /** The colours inside a rectangle of the image (in image coordinates), most frequent first, at most five. */ + static List colours(BufferedImage image, int x, int y, int width, int height) { + Map counts = new TreeMap<>(); + for (int row = Math.max(0, y); row < Math.min(image.getHeight(), y + height); row++) { + for (int column = Math.max(0, x); column < Math.min(image.getWidth(), x + width); column++) { + counts.merge(rgba(image.getRGB(column, row)), 1, Integer::sum); + } + } + List> sorted = new ArrayList<>(counts.entrySet()); + sorted.sort((lhs, rhs) -> rhs.getValue() - lhs.getValue()); + List shown = new ArrayList<>(); + for (Map.Entry entry : sorted.subList(0, Math.min(5, sorted.size()))) { + shown.add(List.of(entry.getKey(), entry.getValue())); + } + return shown; + } + + static Map measure(String name, File pngFolder) throws Exception { + Map result = new LinkedHashMap<>(); + GUIContainer container = GUIContainer.getGUIContainer(); + List nodes = new ArrayList<>(); + for (GUIKnotenItem item : container.getKnotenItems()) { + Map node = new LinkedHashMap<>(); + node.put("name", item.getKnoten().holeAnzeigeName()); + node.put("bounds", bounds(item.getImageLabel())); + nodes.add(node); + } + result.put("nodes", nodes); + List cables = new ArrayList<>(); + for (GUIKabelItem item : container.getCableItems()) cables.add(bounds(item.getKabelpanel())); + result.put("cables", cables); + List docs = new ArrayList<>(); + for (GUIDocuItem item : container.getDocuItems()) { + Map doc = new LinkedHashMap<>(); + doc.put("type", item.getType()); + doc.put("bounds", bounds(item.asDocuElement())); + doc.put("color", item.getColor() == null ? null : rgba(item.getColor().getRGB())); + docs.add(doc); + } + result.put("docs", docs); + result.put("path", SzenarioVerwaltung.getInstance().holePfad() == null ? null : name); + + Method prepare = GUIContainer.class.getDeclaredMethod("prepareExportPanel"); + prepare.setAccessible(true); + GUIPrintPanel panel = (GUIPrintPanel) prepare.invoke(container); + result.put("panelSize", List.of(panel.getWidth(), panel.getHeight())); + result.put("network", area((GUIMainArea) get(panel, GUIPrintPanel.class, "networkPanel"))); + result.put("documentation", area((GUIMainArea) get(panel, GUIPrintPanel.class, "docuPanel"))); + GUIMainArea footer = (GUIMainArea) get(panel, GUIPrintPanel.class, "footer"); + result.put("footerArea", area(footer)); + JLabel label = (JLabel) get(footer, footer.getClass(), "footer"); + Map footerLabel = new LinkedHashMap<>(); + FontMetrics metrics = label.getFontMetrics(label.getFont()); + footerLabel.put("text", label.getText().equals(SzenarioVerwaltung.getInstance().holePfad()) ? "" : label.getText()); + footerLabel.put("bounds", bounds(label)); + footerLabel.put("stringWidth", metrics.stringWidth(label.getText())); + footerLabel.put("fontHeight", metrics.getHeight()); + footerLabel.put("foreground", rgba(label.getForeground().getRGB())); + result.put("footer", footerLabel); + try { + int clipX = panel.getClipX(), clipY = panel.getClipY(); + result.put("clip", List.of(clipX, clipY, panel.getClipWidth(), panel.getClipHeight())); + container.updateViewport(); + BufferedImage image = container.createNetworkImage(); + result.put("imageSize", List.of(image.getWidth(), image.getHeight())); + result.put("cornerPixel", rgba(image.getRGB(0, 0))); + // The footer in image coordinates: the panel's own minus the clip's origin. + Rectangle at = label.getBounds(); + result.put("footerColours", colours(image, at.x - clipX, at.y - clipY, at.width, at.height)); + for (GUIDocuItem item : container.getDocuItems()) { + if (item.getType() != GUIDocuItem.RECT) continue; + Rectangle rect = item.asDocuElement().getBounds(); + // Inside the panel the elements lie 10 to the right and down (EMPTY_BORDER). + result.put("rectangleCentre", colours(image, rect.x + rect.width / 2 + 10 - clipX, rect.y + 4 + 10 - clipY, 1, 1)); + result.put("rectangleCentreColor", rgba(item.getColor().getRGB())); + break; + } + ImageIO.write(image, "png", new File(pngFolder, name.replace(".fls", "") + ".png")); + } catch (RuntimeException error) { + result.put("error", error.getClass().getSimpleName()); + } + container.updateViewport(); + return result; + } + + public static void main(String[] args) throws Exception { + // FILIUS' window keeps the JVM alive: end it on any failure too. + try { + run(args); + } catch (Throwable error) { + error.printStackTrace(); + System.exit(1); + } + System.exit(0); + } + + static void run(String[] args) throws Exception { + GUIContainer.getGUIContainer(); + File pngFolder = new File(args[1]); + pngFolder.mkdirs(); + Map output = new LinkedHashMap<>(); + Map projects = new LinkedHashMap<>(); + for (int index = 2; index < args.length; index++) { + String file = args[index]; + String name = Path.of(file).getFileName().toString(); + Object[] measured = new Object[1]; + Exception[] failed = new Exception[1]; + SwingUtilities.invokeAndWait(() -> { + try { + // As GUIMainMenu opens a file. + Information.getInformation().reset(); + SzenarioVerwaltung.getInstance().laden(file, GUIContainer.getGUIContainer().getKnotenItems(), + GUIContainer.getGUIContainer().getCableItems(), GUIContainer.getGUIContainer().getDocuItems()); + GUIContainer.getGUIContainer().setProperty(null); + GUIContainer.getGUIContainer().updateViewport(); + GUIContainer.getGUIContainer().updateCables(); + measured[0] = measure(name, pngFolder); + } catch (Exception error) { + failed[0] = error; + } + }); + if (failed[0] != null) throw failed[0]; + projects.put(name, measured[0]); + } + output.put("projects", projects); + + // An empty project: nothing on the surface, no file. + Object[] empty = new Object[1]; + Exception[] failed = new Exception[1]; + SwingUtilities.invokeAndWait(() -> { + try { + GUIContainer container = GUIContainer.getGUIContainer(); + container.getKnotenItems().clear(); + container.getCableItems().clear(); + container.getDocuItems().clear(); + SzenarioVerwaltung.getInstance().reset(); + container.updateViewport(); + empty[0] = measure("empty.fls", pngFolder); + } catch (Exception error) { + failed[0] = error; + } + }); + if (failed[0] != null) throw failed[0]; + output.put("empty", empty[0]); + + try (FileOutputStream out = new FileOutputStream(args[0])) { + out.write((FlsOracle.Json.write(output, "") + "\n").getBytes(StandardCharsets.UTF_8)); + } + } +}