feat: F4.8c the documentation mode's image export, as Java's

ExportRun measures Java's print panel (GUIContainer.prepareExportPanel) and
image on four projects and an empty one: 10 to spare around, a light grey
footer 20 below the lowest element, 20 below it, a transparent background,
the documentation painted as in the design. The sidebar gains Java's
"Exportieren": the surface painted with Canvas 2D (an SVG with a
foreignObject taints the canvas in WebKit) and saved under the project's
name; Java's message when saving fails. Not adopted: Java's empty image of
an empty project (its footer overflows). The editor takes a projectName;
the capture export shares the download helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 11:54:09 +00:00
co-authored by Claude Opus 5.5
parent de03f6bb96
commit 7e68414bb2
20 changed files with 1803 additions and 25 deletions
+4
View File
@@ -256,6 +256,8 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
'docu.styleItalic': 'guievents_msg20',
'docu.styleBold': 'guievents_msg21',
'docu.color': 'guievents_msg13',
'docu.export': 'docusidebar_msg5',
'docu.exportHint': 'docusidebar_msg6',
'capture.title': 'lauscherdialog_msg1',
'capture.number': 'rp_lauscher_msg1',
'capture.time': 'rp_lauscher_msg2',
@@ -361,6 +363,8 @@ const FROM_JAVA: Readonly<Record<string, string>> = {
/** Java's words, mended: the prompt says "Vermittlungsrechnerns" (guicontainer_msg1). */
const FROM_JAVA_MENDED: Readonly<Record<string, readonly [string, (java: string) => string]>> = {
// Java's English misspells it.
'docu.saveFailed': ['guimainmemu_msg11', (java) => java.replace('occured', 'occurred')],
'router.prompt': ['guicontainer_msg1', (java) => java.replace(' \\n ', ' ').replace('Vermittlungsrechnerns', 'Vermittlungsrechners')],
// A label above its list, without the colon of Java's label beside it.
'sheet.network': ['jhostkonfiguration_msg15', (java) => java.replace(/:$/, '')],
+803
View File
@@ -0,0 +1,803 @@
{
"projects": {
"nat_with_mapped_port.fls": {
"nodes": [
{
"name": "Switch",
"bounds": [
300,
131,
91,
72
]
},
{
"name": "Switch",
"bounds": [
676,
161,
91,
72
]
},
{
"name": "Vermittlungsrechner",
"bounds": [
551,
90,
167,
78
]
},
{
"name": "Vermittlungsrechner",
"bounds": [
559,
263,
167,
78
]
},
{
"name": "Vermittlungsrechner",
"bounds": [
562,
426,
167,
78
]
},
{
"name": "DNS-Server",
"bounds": [
837,
119,
101,
114
]
},
{
"name": "192.168.0.100",
"bounds": [
152,
54,
116,
100
]
},
{
"name": "192.168.0.101",
"bounds": [
157,
202,
116,
100
]
},
{
"name": "Webserver www.jupiter.de",
"bounds": [
804,
307,
213,
114
]
},
{
"name": "Webserver - 192.168.0.10",
"bounds": [
99,
363,
198,
114
]
},
{
"name": "Switch",
"bounds": [
313,
444,
91,
72
]
},
{
"name": "192.168.0.100",
"bounds": [
260,
538,
116,
100
]
},
{
"name": "Heimrouter Vincent",
"bounds": [
384,
438,
158,
78
]
},
{
"name": "Heimrouter Paul",
"bounds": [
375,
122,
136,
78
]
},
{
"name": "Switch",
"bounds": [
694,
342,
91,
72
]
}
],
"cables": [
[
208,
102,
139,
67
],
[
213,
165,
134,
89
],
[
632,
127,
91,
72
],
[
719,
174,
170,
25
],
[
640,
195,
83,
109
],
[
196,
418,
164,
64
],
[
316,
478,
44,
112
],
[
343,
159,
102,
10
],
[
441,
127,
195,
36
],
[
356,
475,
109,
7
],
[
461,
463,
186,
16
],
[
640,
300,
101,
80
],
[
643,
376,
98,
91
],
[
737,
362,
175,
18
]
],
"docs": [
{
"type": 1,
"bounds": [
94,
34,
428,
292
],
"color": "204,204,204,255"
},
{
"type": 2,
"bounds": [
316,
42,
142,
26
],
"color": "0,0,0,255"
},
{
"type": 1,
"bounds": [
95,
349,
428,
296
],
"color": "204,204,204,255"
},
{
"type": 2,
"bounds": [
322,
358,
169,
27
],
"color": "0,0,0,255"
},
{
"type": 2,
"bounds": [
388,
523,
129,
73
],
"color": "0,0,0,255"
},
{
"type": 2,
"bounds": [
113,
483,
167,
108
],
"color": "0,0,0,255"
},
{
"type": 1,
"bounds": [
792,
52,
224,
489
],
"color": "204,204,204,255"
},
{
"type": 2,
"bounds": [
828,
430,
148,
50
],
"color": "0,0,0,255"
},
{
"type": 2,
"bounds": [
802,
57,
187,
50
],
"color": "0,0,0,255"
}
],
"path": "nat_with_mapped_port.fls",
"panelSize": [
2020,
1620
],
"network": {
"minX": 99.0,
"maxX": 1017.0,
"minY": 54.0,
"maxY": 638.0
},
"documentation": {
"minX": 94.0,
"maxX": 1016.0,
"minY": 34.0,
"maxY": 645.0
},
"footerArea": {
"minX": 104.0,
"maxX": 814.0,
"minY": 675.0,
"maxY": 690.0
},
"footer": {
"text": "<path>",
"bounds": [
104,
675,
710,
15
],
"stringWidth": 690,
"fontHeight": 15,
"foreground": "192,192,192,255"
},
"clip": [
94,
34,
943,
676
],
"imageSize": [
943,
676
],
"cornerPixel": "0,0,0,0",
"footerColours": [
[
"0,0,0,0",
8267
],
[
"192,192,192,255",
2383
]
],
"rectangleCentre": [
[
"215,215,215,255",
1
]
],
"rectangleCentreColor": "204,204,204,255"
},
"zwei_rechnernetze_komplett.fls": {
"nodes": [
{
"name": "Switch 1",
"bounds": [
164,
178,
91,
72
]
},
{
"name": "Switch 2",
"bounds": [
612,
104,
91,
72
]
},
{
"name": "DNS-Server 2.10",
"bounds": [
694,
19,
134,
114
]
},
{
"name": "Notebook 1.10",
"bounds": [
53,
100,
123,
100
]
},
{
"name": "Notebook 1.11",
"bounds": [
70,
235,
123,
100
]
},
{
"name": "Vermittlungsrechner",
"bounds": [
373,
106,
167,
78
]
}
],
"cables": [
[
112,
148,
99,
68
],
[
129,
212,
82,
75
],
[
655,
74,
108,
68
],
[
207,
143,
251,
73
],
[
454,
138,
205,
9
]
],
"docs": [],
"path": "zwei_rechnernetze_komplett.fls",
"panelSize": [
2020,
1620
],
"network": {
"minX": 53.0,
"maxX": 828.0,
"minY": 19.0,
"maxY": 335.0
},
"documentation": {
"minX": 2.147483647E9,
"maxX": 0.0,
"minY": 2.147483647E9,
"maxY": 0.0
},
"footerArea": {
"minX": 63.0,
"maxX": 817.0,
"minY": 365.0,
"maxY": 380.0
},
"footer": {
"text": "<path>",
"bounds": [
63,
365,
754,
15
],
"stringWidth": 734,
"fontHeight": 15,
"foreground": "192,192,192,255"
},
"clip": [
53,
19,
795,
381
],
"imageSize": [
795,
381
],
"cornerPixel": "0,0,0,0",
"footerColours": [
[
"0,0,0,0",
8805
],
[
"192,192,192,255",
2505
]
]
},
"documentation.fls": {
"nodes": [],
"cables": [],
"docs": [
{
"type": 2,
"bounds": [
40,
40,
160,
60
],
"color": "79,130,189,255"
},
{
"type": 2,
"bounds": [
40,
140,
120,
50
],
"color": "0,0,0,255"
},
{
"type": 1,
"bounds": [
20,
20,
300,
200
],
"color": "255,201,201,255"
},
{
"type": 1,
"bounds": [
400,
20,
100,
50
],
"color": "204,204,204,255"
}
],
"path": "documentation.fls",
"panelSize": [
2020,
1620
],
"network": {
"minX": 2.147483647E9,
"maxX": 0.0,
"minY": 2.147483647E9,
"maxY": 0.0
},
"documentation": {
"minX": 20.0,
"maxX": 500.0,
"minY": 20.0,
"maxY": 220.0
},
"footerArea": {
"minX": 30.0,
"maxX": 399.0,
"minY": 250.0,
"maxY": 265.0
},
"footer": {
"text": "<path>",
"bounds": [
30,
250,
369,
15
],
"stringWidth": 349,
"fontHeight": 15,
"foreground": "192,192,192,255"
},
"clip": [
20,
20,
500,
265
],
"imageSize": [
500,
265
],
"cornerPixel": "0,0,0,0",
"footerColours": [
[
"0,0,0,0",
4393
],
[
"192,192,192,255",
1142
]
],
"rectangleCentre": [
[
"255,212,212,255",
1
]
],
"rectangleCentreColor": "255,201,201,255"
},
"modem_beispiel.fls": {
"nodes": [
{
"name": "Rechner 0.11",
"bounds": [
586,
46,
112,
114
]
},
{
"name": "Notebook 0.10",
"bounds": [
42,
182,
123,
100
]
},
{
"name": "Modem (Zugreifend)",
"bounds": [
237,
212,
165,
76
]
},
{
"name": "Modem (Zugangspunkt)",
"bounds": [
396,
79,
190,
76
]
}
],
"cables": [
[
101,
230,
220,
22
],
[
489,
101,
155,
18
]
],
"docs": [],
"path": "modem_beispiel.fls",
"panelSize": [
2020,
1620
],
"network": {
"minX": 42.0,
"maxX": 698.0,
"minY": 46.0,
"maxY": 288.0
},
"documentation": {
"minX": 2.147483647E9,
"maxX": 0.0,
"minY": 2.147483647E9,
"maxY": 0.0
},
"footerArea": {
"minX": 52.0,
"maxX": 715.0,
"minY": 318.0,
"maxY": 333.0
},
"footer": {
"text": "<path>",
"bounds": [
52,
318,
663,
15
],
"stringWidth": 643,
"fontHeight": 15,
"foreground": "192,192,192,255"
},
"clip": [
42,
46,
693,
307
],
"imageSize": [
693,
307
],
"cornerPixel": "0,0,0,0",
"footerColours": [
[
"0,0,0,0",
7708
],
[
"192,192,192,255",
2237
]
]
}
},
"empty": {
"nodes": [],
"cables": [],
"docs": [],
"path": null,
"panelSize": [
2020,
1620
],
"network": {
"minX": 2.147483647E9,
"maxX": 0.0,
"minY": 2.147483647E9,
"maxY": 0.0
},
"documentation": {
"minX": 2.147483647E9,
"maxX": 0.0,
"minY": 2.147483647E9,
"maxY": 0.0
},
"footerArea": {
"minX": 0.0,
"maxX": 0.0,
"minY": 30.0,
"maxY": 45.0
},
"footer": {
"text": "Filius",
"bounds": [
-2147483639,
30,
57,
15
],
"stringWidth": 37,
"fontHeight": 15,
"foreground": "192,192,192,255"
},
"clip": [
0,
30,
20,
35
],
"imageSize": [
20,
35
],
"cornerPixel": "0,0,0,0",
"footerColours": []
}
}
+317
View File
@@ -0,0 +1,317 @@
// FILIUS' „Exportieren“ (GUIContainer.createNetworkImage), against Java's own
// print panel (tools/java-oracle's ExportRun on four projects and an empty
// one, networkImage.java.json): the part of the surface the image shows and
// where its footer lies, from the bounds of Java's own elements and footer
// (their sizes depend on the font of the machine Java runs on); Java's
// cable bounds; the colours Java paints (a transparent background, the
// footer light grey, a rectangle as the design shows it); and the painting
// itself on a recording context.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { DocumentationView, TopologyView } from '@mdg-filius/doc';
import { rectangleBorder, rectangleFill } from '../src/canvas/documentationPaint.js';
import { cableControl, cableFrame, deviceBox, JAVA_LABEL_FONT, JAVA_LABEL_FONT_HEIGHT, type IconName } from '../src/canvas/layout.js';
import {
CABLE_COLOR,
exportLayout,
exportNames,
FOOTER_COLOR,
LABEL_COLOR,
measureNetworkImage,
networkParts,
paintNetworkImage,
WIRELESS_COLOR,
wrapLines,
type Painter,
} from '../src/canvas/networkImage.js';
type Box = [number, number, number, number];
interface JavaProject {
readonly nodes: readonly { readonly name: string; readonly bounds: Box }[];
readonly cables: readonly Box[];
readonly docs: readonly { readonly type: number; readonly bounds: Box; readonly color: string }[];
readonly footer: { readonly text: string; readonly bounds: Box; readonly stringWidth: number; readonly fontHeight: number; readonly foreground: string };
readonly clip: Box;
readonly imageSize: [number, number];
readonly cornerPixel: string;
readonly footerColours: readonly [string, number][];
readonly rectangleCentre?: readonly [string, number][];
readonly rectangleCentreColor?: string;
}
const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'networkImage.java.json'), 'utf8')) as {
readonly projects: Readonly<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 after it, as Java names it after its file", () => {
expect(exportNames('nat_with_mapped_port.fls')).toEqual({ footer: 'nat_with_mapped_port.fls', file: 'nat_with_mapped_port.png' });
expect(exportNames('netz.v2.fls')).toEqual({ footer: 'netz.v2.fls', file: 'netz.v2.png' });
expect(exportNames('Netz')).toEqual({ footer: 'Netz', file: 'Netz.png' });
expect(exportNames('.fls')).toEqual({ footer: '.fls', file: '.fls.png' });
});
it('writes „Filius“ without a name, as Java does without a file', () => {
expect(exportNames(undefined)).toEqual({ footer: 'Filius', file: 'Filius.png' });
expect(exportNames(' ')).toEqual({ footer: 'Filius', file: 'Filius.png' });
});
});
/** A 2D context that writes down what is painted, its text 7 wide a character, a font's ascent 0.9 of its size, its descent 0.25. */
function recorder() {
const calls: unknown[][] = [];
const state = { font: '10px sans-serif', fillStyle: '' as unknown, strokeStyle: '' as unknown, lineWidth: 1, textAlign: 'start', textBaseline: 'alphabetic' };
const size = () => Number(/(\d+)px/.exec(state.font)![1]);
const record =
(name: string) =>
(...args: unknown[]) => {
calls.push([name, ...args]);
};
const context = {
save: record('save'),
restore: record('restore'),
translate: record('translate'),
beginPath: record('beginPath'),
moveTo: record('moveTo'),
quadraticCurveTo: record('quadraticCurveTo'),
roundRect: record('roundRect'),
rect: record('rect'),
clip: record('clip'),
fill: () => calls.push(['fill', state.fillStyle]),
stroke: () => calls.push(['stroke', state.strokeStyle, state.lineWidth]),
fillText: (text: string, x: number, y: number) => calls.push(['fillText', text, x, y, state.font, state.fillStyle, state.textAlign]),
measureText: (text: string) => ({ width: text.length * 7, fontBoundingBoxAscent: size() * 0.9, fontBoundingBoxDescent: size() * 0.25 }),
drawImage: (image: unknown, x: number, y: number, width: number, height: number) => calls.push(['drawImage', image, x, y, width, height]),
setLineDash: record('setLineDash'),
};
for (const key of Object.keys(state) as (keyof typeof state)[]) {
Object.defineProperty(context, key, {
get: () => state[key],
set: (value: never) => {
state[key] = value;
},
});
}
return { context: context as unknown as Painter, calls };
}
const grey = { red: 204 / 255, green: 204 / 255, blue: 204 / 255, alpha: 1 };
const doc = (fields: Partial<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);
});
});