Files
julian.kiedaischandClaude Opus 5.5 b8b999fd66 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>
2026-10-04 12:35:35 +00:00

318 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// FILIUS' „Exportieren“ (GUIContainer.createNetworkImage), against Java's own
// print panel (tools/java-oracle's ExportRun on four projects and an empty
// one, networkImage.java.json): the part of the surface the image shows and
// where its footer lies, from the bounds of Java's own elements and footer
// (their sizes depend on the font of the machine Java runs on); Java's
// cable bounds; the colours Java paints (a transparent background, the
// footer light grey, a rectangle as the design shows it); and the painting
// itself on a recording context.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { DocumentationView, TopologyView } from '@mdg-filius/doc';
import { rectangleBorder, rectangleFill } from '../src/canvas/documentationPaint.js';
import { cableControl, cableFrame, deviceBox, JAVA_LABEL_FONT, JAVA_LABEL_FONT_HEIGHT, type IconName } from '../src/canvas/layout.js';
import {
CABLE_COLOR,
exportLayout,
exportNames,
FOOTER_COLOR,
LABEL_COLOR,
measureNetworkImage,
networkParts,
paintNetworkImage,
WIRELESS_COLOR,
wrapLines,
type Painter,
} from '../src/canvas/networkImage.js';
type Box = [number, number, number, number];
interface JavaProject {
readonly nodes: readonly { readonly name: string; readonly bounds: Box }[];
readonly cables: readonly Box[];
readonly docs: readonly { readonly type: number; readonly bounds: Box; readonly color: string }[];
readonly footer: { readonly text: string; readonly bounds: Box; readonly stringWidth: number; readonly fontHeight: number; readonly foreground: string };
readonly clip: Box;
readonly imageSize: [number, number];
readonly cornerPixel: string;
readonly footerColours: readonly [string, number][];
readonly rectangleCentre?: readonly [string, number][];
readonly rectangleCentreColor?: string;
}
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'networkImage.java.json'), 'utf8')) as {
readonly projects: Readonly<Record<string, JavaProject>>;
readonly empty: JavaProject;
};
const rect = ([x, y, width, height]: Box) => ({ x, y, width, height });
const rgb = (text: string) => {
const [red, green, blue, alpha] = text.split(',').map(Number);
return `rgb(${red}, ${green}, ${blue})${alpha === 255 ? '' : ` alpha ${alpha}`}`;
};
describe("the exported image's layout, as Java's print panel", () => {
it.each(Object.keys(java.projects))('cuts %s as Java does: 10 around, the footer 20 below the lowest element, 20 below the footer', (name) => {
const project = java.projects[name]!;
// Java's footer: its words and 20 more wide, its font high.
expect(project.footer.bounds[2]).toBe(project.footer.stringWidth + 20);
expect(project.footer.bounds[3]).toBe(project.footer.fontHeight);
expect(project.footer.text).toBe('<path>');
const parts = [...project.nodes.map((node) => rect(node.bounds)), ...project.cables.map(rect), ...project.docs.map((doc) => rect(doc.bounds))];
const layout = exportLayout(parts, { width: project.footer.bounds[2], height: project.footer.bounds[3] });
// Java's panel holds the elements 10 to the right and down (EMPTY_BORDER); its clip is in the panel's terms.
const [x, y, width, height] = project.clip;
expect(layout.image).toEqual({ x: x - 10, y: y - 10, width, height });
expect(layout.footer).toEqual({ x: project.footer.bounds[0] - 10, y: project.footer.bounds[1] - 10, width: project.footer.bounds[2], height: project.footer.bounds[3] });
expect(project.imageSize).toEqual([width, height]);
});
it('is as wide as the footer and 20 more where the footer is wider than the elements', () => {
const layout = exportLayout([{ x: 50, y: 40, width: 100, height: 60 }], { width: 300, height: 17 });
expect(layout.footer).toEqual({ x: 50, y: 120, width: 300, height: 17 });
expect(layout.image).toEqual({ x: 40, y: 30, width: 330, height: 127 });
});
it("shows an empty project's footer alone (Java's image of 20 × 35 shows nothing: its footer overflows)", () => {
expect(java.empty.imageSize).toEqual([20, 35]);
expect(java.empty.footerColours).toEqual([]);
expect(java.empty.footer.text).toBe('Filius');
const layout = exportLayout([], { width: 57, height: 17 });
expect(layout.footer).toEqual({ x: 0, y: 0, width: 57, height: 17 });
expect(layout.image).toEqual({ x: -10, y: -10, width: 87, height: 47 });
});
it("does not cut at the surface's edges, which the editor does not have", () => {
expect(exportLayout([{ x: -40, y: -30, width: 20, height: 10 }], { width: 10, height: 17 }).image).toEqual({ x: -50, y: -40, width: 40, height: 77 });
});
it("takes Java's cable bounds: the two middles with 2 to spare", () => {
const project = java.projects['nat_with_mapped_port.fls']!;
const middles = project.nodes.map(({ bounds: [x, y, width, height] }) => ({ x: Math.trunc(x + 0.5 * width), y: Math.trunc(y + 0.5 * height) }));
for (const cable of project.cables) {
const matches = middles.flatMap((first, index) => middles.slice(index + 1).filter((second) => JSON.stringify(cableFrame(first, second)) === JSON.stringify(rect(cable))));
expect(matches.length, JSON.stringify(cable)).toBeGreaterThan(0);
}
expect(cableFrame({ x: 50, y: 5 }, { x: 10, y: 20 })).toEqual({ x: 8, y: 3, width: 44, height: 19 });
});
it("draws cables in JCablePanel's colours: dark grey (64), a wireless link light grey (192)", () => {
expect([CABLE_COLOR, WIRELESS_COLOR]).toEqual(['rgb(64, 64, 64)', 'rgb(192, 192, 192)']);
});
it("paints as Java's image does: transparent around, the footer light grey, a rectangle as the design shows it", () => {
for (const project of [...Object.values(java.projects), java.empty]) expect(project.cornerPixel).toBe('0,0,0,0');
for (const project of Object.values(java.projects)) {
expect(rgb(project.footer.foreground)).toBe(FOOTER_COLOR);
expect(project.footerColours.map(([colour]) => colour)).toContain(project.footer.foreground);
if (project.rectangleCentre === undefined) continue;
const [red, green, blue] = project.rectangleCentreColor!.split(',').map((part) => Number(part) / 255);
expect(rgb(project.rectangleCentre[0]![0])).toBe(rectangleFill({ red: red!, green: green!, blue: blue!, alpha: 1 }, false));
}
});
});
describe('the words of a text in the image', () => {
const measure = (text: string) => text.length * 10;
it('keeps lines that fit, and its own lines, empty ones too', () => {
expect(wrapLines('Netz A', 100, measure)).toEqual(['Netz A']);
expect(wrapLines('eins\n\nzwei', 100, measure)).toEqual(['eins', '', 'zwei']);
expect(wrapLines('', 100, measure)).toEqual(['']);
});
it('breaks after a space, the space staying at the end of its line', () => {
expect(wrapLines('Netz A und B', 80, measure)).toEqual(['Netz A ', 'und B']);
// A trailing space does not count: "abcd efgh " fits in 90.
expect(wrapLines('abcd efgh ijk', 90, measure)).toEqual(['abcd efgh ', 'ijk']);
expect(wrapLines(' eingerückt', 200, measure)).toEqual([' eingerückt']);
});
it('breaks after a tab as after a space', () => {
expect(wrapLines('abc\tdefghij', 70, measure)).toEqual(['abc\t', 'defghij']);
});
it('holds a character a line, where one is wider than the line', () => {
expect(wrapLines('ab', 5, measure)).toEqual(['a', 'b']);
});
it('breaks a word wider than the line where it reaches the edge', () => {
expect(wrapLines('Vermittlungsrechner', 50, measure)).toEqual(['Vermi', 'ttlun', 'gsrec', 'hner']);
expect(wrapLines('ab Vermittlung', 50, measure)).toEqual(['ab ', 'Vermi', 'ttlun', 'g']);
});
});
describe("the image's names", () => {
it("writes the project's name in the footer and names the image 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', report: 'Filius.pdf' });
expect(exportNames(' ')).toEqual({ footer: 'Filius', file: 'Filius.png', report: 'Filius.pdf' });
});
});
/** A 2D context that writes down what is painted, its text 7 wide a character, a font's ascent 0.9 of its size, its descent 0.25. */
function recorder() {
const calls: unknown[][] = [];
const state = { font: '10px sans-serif', fillStyle: '' as unknown, strokeStyle: '' as unknown, lineWidth: 1, textAlign: 'start', textBaseline: 'alphabetic' };
const size = () => Number(/(\d+)px/.exec(state.font)![1]);
const record =
(name: string) =>
(...args: unknown[]) => {
calls.push([name, ...args]);
};
const context = {
save: record('save'),
restore: record('restore'),
translate: record('translate'),
beginPath: record('beginPath'),
moveTo: record('moveTo'),
quadraticCurveTo: record('quadraticCurveTo'),
roundRect: record('roundRect'),
rect: record('rect'),
clip: record('clip'),
fill: () => calls.push(['fill', state.fillStyle]),
stroke: () => calls.push(['stroke', state.strokeStyle, state.lineWidth]),
fillText: (text: string, x: number, y: number) => calls.push(['fillText', text, x, y, state.font, state.fillStyle, state.textAlign]),
measureText: (text: string) => ({ width: text.length * 7, fontBoundingBoxAscent: size() * 0.9, fontBoundingBoxDescent: size() * 0.25 }),
drawImage: (image: unknown, x: number, y: number, width: number, height: number) => calls.push(['drawImage', image, x, y, width, height]),
setLineDash: record('setLineDash'),
};
for (const key of Object.keys(state) as (keyof typeof state)[]) {
Object.defineProperty(context, key, {
get: () => state[key],
set: (value: never) => {
state[key] = value;
},
});
}
return { context: context as unknown as Painter, calls };
}
const grey = { red: 204 / 255, green: 204 / 255, blue: 204 / 255, alpha: 1 };
const doc = (fields: Partial<DocumentationView> & Pick<DocumentationView, 'id' | 'kind'>): DocumentationView => ({
x: 0,
y: 0,
width: 100,
height: 50,
text: '',
color: grey,
font: 'Dialog',
size: 12,
bold: false,
italic: false,
order: 0,
...fields,
});
const VIEW: TopologyView = {
nodes: [
{ id: 'a', kind: 'pc', name: 'A', label: 'Rechner A', x: 100, y: 80, cloud: false, ports: [] },
{ id: 'b', kind: 'networkSwitch', name: 'S', label: 'Switch', x: 300, y: 200, cloud: false, ports: [] },
{ id: 'c', kind: 'notebook', name: 'N', label: 'N', x: 500, y: 60, cloud: false, ports: [] },
],
links: [
{ id: 'l1', sourceNode: 'a', sourcePort: 'p', targetNode: 'b', targetPort: 'q', wireless: false },
{ id: 'l2', sourceNode: 'c', sourcePort: 'p', targetNode: 'b', targetPort: 'r', wireless: true },
// Its device gone (before the janitor): left out, as the surface leaves it.
{ id: 'l3', sourceNode: 'a', sourcePort: 'p', targetNode: 'gone', targetPort: 'r', wireless: false },
],
docs: [
doc({ id: 'r', kind: 'rectangle', x: 60, y: 40, width: 200, height: 120, color: { red: 1, green: 201 / 255, blue: 201 / 255, alpha: 1 } }),
doc({ id: 't', kind: 'text', x: 70, y: 300, width: 64, height: 40, text: 'Netz A123 B', size: 18, italic: true, color: { red: 0, green: 0, blue: 0, alpha: 1 } }),
],
};
const ICONS = Object.fromEntries(['server', 'laptop', 'switch', 'cloud', 'router', 'gateway', 'modem'].map((name) => [name, `icon:${name}`])) as unknown as Record<IconName, CanvasImageSource>;
describe('painting the image', () => {
const labelWidth = (text: string) => text.length * 7;
it("counts the devices, the cables between them and the documentation, as Java's panels do", () => {
const { boxes, parts } = networkParts(VIEW, labelWidth);
const [a, b, c] = ['a', 'b', 'c'].map((id) => boxes.get(id)!);
expect(a).toEqual(deviceBox(VIEW.nodes[0]!, labelWidth('Rechner A')));
expect(parts).toEqual([a, b, c, cableFrame(a!.center, b!.center), cableFrame(c!.center, b!.center), { x: 60, y: 40, width: 200, height: 120 }, { x: 70, y: 300, width: 64, height: 40 }]);
});
it('measures the labels and the footer in Java’s font', () => {
const { context } = recorder();
const { layout } = measureNetworkImage(context, VIEW, 'netz.fls');
expect(context.font).toBe(JAVA_LABEL_FONT);
const { parts } = networkParts(VIEW, labelWidth);
expect(layout).toEqual(exportLayout(parts, { width: 8 * 7 + 20, height: JAVA_LABEL_FONT_HEIGHT }));
});
it('paints the documentation, then the cables, then the devices, then the footer, on the cut-out', () => {
const { context, calls } = recorder();
const layout = paintNetworkImage(context, VIEW, ICONS, 'netz.fls');
const { boxes } = networkParts(VIEW, labelWidth);
const [a, b, c] = ['a', 'b', 'c'].map((id) => boxes.get(id)!);
const named = (name: string) => calls.filter((call) => call[0] === name);
expect(calls[1]).toEqual(['translate', -layout.image.x, -layout.image.y]);
// The rectangle as the design shows it: inset by 1, corners of 5, opaque and lighter, its border darker.
const pink = VIEW.docs[0]!.color;
expect(named('roundRect')).toEqual([['roundRect', 61, 41, 198, 118, 5]]);
expect(named('fill')).toEqual([['fill', rectangleFill(pink, false)]]);
expect(named('stroke')[0]).toEqual(['stroke', rectangleBorder(pink), 1]);
// The text inside its frame, in its font, broken to its width less the padding (62: "Netz A123" is 63 wide).
expect(named('rect')).toEqual([['rect', 70, 300, 64, 40]]);
const words = named('fillText').filter((call) => String(call[4]).startsWith('italic'));
expect(words).toEqual([
// A line the font's ascent and descent high (16.2 + 4.5).
['fillText', 'Netz ', 71, 301 + 16.2, 'italic 18px sans-serif', 'rgba(0, 0, 0, 1)', 'left'],
['fillText', 'A123 B', 71, 301 + 16.2 + 20.7, 'italic 18px sans-serif', 'rgba(0, 0, 0, 1)', 'left'],
]);
// The cables: Java's curve, a wireless one grey and dashed; the one without its device left out.
expect(named('moveTo')).toEqual([
['moveTo', a!.center.x, a!.center.y],
['moveTo', c!.center.x, c!.center.y],
]);
const [first, second] = [cableControl(a!.center, b!.center), cableControl(c!.center, b!.center)];
expect(named('quadraticCurveTo')).toEqual([
['quadraticCurveTo', first.x, first.y, b!.center.x, b!.center.y],
['quadraticCurveTo', second.x, second.y, b!.center.x, b!.center.y],
]);
expect(named('stroke').slice(1)).toEqual([
['stroke', CABLE_COLOR, 2],
['stroke', WIRELESS_COLOR, 2],
]);
expect(named('setLineDash')).toEqual([
['setLineDash', []],
['setLineDash', [10]],
['setLineDash', []],
]);
// The devices: Java's icon, the label in the middle below it, black.
expect(named('drawImage')).toEqual([
['drawImage', 'icon:server', a!.iconRect.x, a!.iconRect.y, 64, 94],
['drawImage', 'icon:switch', b!.iconRect.x, b!.iconRect.y, 70, 52],
['drawImage', 'icon:laptop', c!.iconRect.x, c!.iconRect.y, 84, 80],
]);
const labels = named('fillText').filter((call) => call[4] === JAVA_LABEL_FONT);
expect(labels).toEqual([
['fillText', 'Rechner A', a!.label.x, a!.label.y, JAVA_LABEL_FONT, LABEL_COLOR, 'center'],
['fillText', 'Switch', b!.label.x, b!.label.y, JAVA_LABEL_FONT, LABEL_COLOR, 'center'],
['fillText', 'N', c!.label.x, c!.label.y, JAVA_LABEL_FONT, LABEL_COLOR, 'center'],
// The footer: at its left, its baseline the font's ascent down, light grey.
['fillText', 'netz.fls', layout.footer.x, layout.footer.y + 14, JAVA_LABEL_FONT, FOOTER_COLOR, 'left'],
]);
// In that order, and the context left as it was found.
const order = calls.map((call) => call[0]);
expect(order.indexOf('roundRect')).toBeLessThan(order.indexOf('rect'));
expect(order.lastIndexOf('fillText', order.indexOf('moveTo'))).toBeGreaterThan(order.indexOf('rect'));
expect(order.lastIndexOf('quadraticCurveTo')).toBeLessThan(order.indexOf('drawImage'));
expect(order.filter((name) => name === 'save').length).toBe(order.filter((name) => name === 'restore').length);
expect(order.at(-1)).toBe('restore');
expect(layout.footer.y).toBe(340 + 20);
});
});