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:
2026-10-04 12:35:35 +00:00
co-authored by Claude Opus 5.5
parent 7e68414bb2
commit b8b999fd66
29 changed files with 7726 additions and 23 deletions
+14 -1
View File
@@ -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

+5 -4
View File
@@ -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')}
+16
View File
@@ -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",
+16
View File
@@ -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",
+181
View File
@@ -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();
}
+61
View File
@@ -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();
}
+262
View File
@@ -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 };
}