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:
@@ -5,6 +5,7 @@ export * from './fields.js';
|
||||
export * from './files.js';
|
||||
export * from './janitor.js';
|
||||
export * from './projectDoc.js';
|
||||
export * from './report.js';
|
||||
export * from './settings.js';
|
||||
export * from './simulation.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(page.getByRole('navigation', { name: 'Komponenten' })).toHaveCount(0);
|
||||
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.
|
||||
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 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: 'Report' })).toBeInViewport({ ratio: 1 });
|
||||
const rights = await page.locator('.editor-toolbar > *').evaluateAll((parts) => parts.map((part) => part.getBoundingClientRect().right));
|
||||
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/protocol": "0.0.0",
|
||||
"@mdg-filius/sim": "0.0.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"yjs": "^13.6.33"
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
removeDocumentation,
|
||||
removeLink,
|
||||
removeLinksOf,
|
||||
reportCards,
|
||||
reportDevices,
|
||||
setDocumentationColor,
|
||||
setDocumentationFont,
|
||||
setDocumentationFrame,
|
||||
@@ -50,6 +52,7 @@ import { DeviceSheet } from './sheet/DeviceSheet.js';
|
||||
import { DesktopWindow, type DesktopWindowName } from './desktop/DesktopWindow.js';
|
||||
import { useLanguage, useTranslate } from './i18n/i18n.js';
|
||||
import { CaptureWindow } from './simulation/CaptureWindow.js';
|
||||
import { captureWords, reportTraffic } from './simulation/captureCells.js';
|
||||
import { ModeBar, type EditorMode } from './simulation/ModeBar.js';
|
||||
import { DropControl } from './simulation/DropControl.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);
|
||||
};
|
||||
|
||||
/** „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) => {
|
||||
setDocumenting(mode === 'documentation');
|
||||
if (mode === 'simulation') simulation.start(language);
|
||||
@@ -363,7 +376,7 @@ export function Editor({ doc, createSimHost, projectName }: EditorProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100%' }}>
|
||||
{simulating ? null : documenting ? (
|
||||
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} />
|
||||
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} onReport={makeReport} />
|
||||
) : (
|
||||
<Palette
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 } {
|
||||
/** 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; readonly report: string } {
|
||||
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('.');
|
||||
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
|
||||
// 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.
|
||||
// „Exportieren“ saves the surface as an image (F4.8c), „Report“ the report as
|
||||
// a PDF (F4.8d); if that fails, Java's „Fehler beim Speichern aufgetreten!“
|
||||
// shows below them.
|
||||
|
||||
import { useRef, useState, type CSSProperties } from 'react';
|
||||
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 addText from '../assets/documentation/add_text_small.png';
|
||||
import exportIcon from '../assets/documentation/download_small.png';
|
||||
import reportIcon from '../assets/documentation/pdf_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<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 }[] = [
|
||||
@@ -44,7 +48,7 @@ const item: CSSProperties = {
|
||||
touchAction: 'none',
|
||||
};
|
||||
|
||||
export function DocumentationSidebar({ onItem, onExport }: DocumentationSidebarProps) {
|
||||
export function DocumentationSidebar({ onItem, onExport, onReport }: 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);
|
||||
@@ -52,10 +56,10 @@ export function DocumentationSidebar({ onItem, onExport }: DocumentationSidebarP
|
||||
const afterDrag = useRef(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const exportImage = () => {
|
||||
const save = (make: () => Promise<void>) => () => {
|
||||
setExporting(true);
|
||||
setFailed(false);
|
||||
onExport().then(
|
||||
make().then(
|
||||
() => setExporting(false),
|
||||
() => {
|
||||
setExporting(false);
|
||||
@@ -124,10 +128,14 @@ export function DocumentationSidebar({ onItem, onExport }: DocumentationSidebarP
|
||||
{t(each.label)}
|
||||
</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} />
|
||||
{t('docu.export')}
|
||||
</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 ? (
|
||||
<p role="alert" style={{ margin: 0, fontSize: 12, color: 'var(--danger)' }}>
|
||||
{t('docu.saveFailed')}
|
||||
|
||||
@@ -109,6 +109,8 @@
|
||||
"docu.fontSize": "Schriftgröße",
|
||||
"docu.fontStyle": "Schriftstil",
|
||||
"docu.rectangle": "Gliederungsfläche",
|
||||
"docu.report": "Report",
|
||||
"docu.reportHint": "Report als PDF-Dokument erstellen",
|
||||
"docu.saveFailed": "Fehler beim Speichern aufgetreten!",
|
||||
"docu.sidebar": "Dokumentation",
|
||||
"docu.sizeLarge": "Groß",
|
||||
@@ -350,6 +352,20 @@
|
||||
"program.textEditor": "Text-Editor",
|
||||
"program.webBrowser": "Webbrowser",
|
||||
"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.gateway": "Nächstes Gateway",
|
||||
"route.interfaceIPAddress": "Über Schnittstelle",
|
||||
|
||||
@@ -109,6 +109,8 @@
|
||||
"docu.fontSize": "Font Size",
|
||||
"docu.fontStyle": "Font Style",
|
||||
"docu.rectangle": "Structure Field",
|
||||
"docu.report": "Report",
|
||||
"docu.reportHint": "Create report as PDF document",
|
||||
"docu.saveFailed": "Error occurred!",
|
||||
"docu.sidebar": "Documentation",
|
||||
"docu.sizeLarge": "Large",
|
||||
@@ -350,6 +352,20 @@
|
||||
"program.textEditor": "Text editor",
|
||||
"program.webBrowser": "Webbrowser",
|
||||
"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.gateway": "Next gateway",
|
||||
"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)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** Watches these topics until the returned function is called; a topic two parts watch is sent for once. */
|
||||
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). */
|
||||
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 {
|
||||
const host = useRef<SimHost | null>(null);
|
||||
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 [speed, setSpeedState] = useState(100);
|
||||
const [activeLinks, setActiveLinks] = useState<ReadonlySet<string>>(NO_LINKS);
|
||||
@@ -104,6 +108,13 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH
|
||||
});
|
||||
return;
|
||||
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)));
|
||||
return;
|
||||
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>> = {}) => {
|
||||
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 type { TraceFrame } from '@mdg-filius/protocol';
|
||||
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. */
|
||||
function frame(number: number, timeMilliseconds: number): TraceFrame {
|
||||
@@ -76,3 +76,26 @@ describe("the data exchange window's cells", () => {
|
||||
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.export': 'docusidebar_msg5',
|
||||
'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.number': 'rp_lauscher_msg1',
|
||||
'capture.time': 'rp_lauscher_msg2',
|
||||
|
||||
@@ -144,16 +144,16 @@ describe('the words of a text in the image', () => {
|
||||
});
|
||||
|
||||
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 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', report: 'nat_with_mapped_port.pdf' });
|
||||
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', report: 'Netz.pdf' });
|
||||
expect(exportNames('.fls')).toEqual({ footer: '.fls', file: '.fls.png', report: '.fls.pdf' });
|
||||
});
|
||||
|
||||
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' });
|
||||
expect(exportNames(undefined)).toEqual({ footer: 'Filius', file: 'Filius.png', report: 'Filius.pdf' });
|
||||
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 ? ü€');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user