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
+79 -2
View File
@@ -4,9 +4,13 @@
// pointer), moved, resized to Java's smallest size, coloured from Java's
// list; the sidebar's text tapped into the view, typed into, made large and
// italic; Java's paint in the documentation mode and in the design; Delete;
// a simulation started ends the mode; and a phone.
// a simulation started ends the mode; „Exportieren“ (F4.8c): the image
// saved under the project's name, cut and painted as Java's, and Java's
// message when saving fails; and a phone.
import { readFileSync } from 'node:fs';
import { expect, test, type Page } from '@playwright/test';
import { JAVA_LABEL_FONT } from '../src/canvas/layout.js';
import { drag, drawing, openEditor, serveEditor } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -50,7 +54,7 @@ test('makes, moves, resizes, colours and types documentation as Java does, and p
await expect(canvas).toHaveAttribute('data-mode', 'documentation');
await expect(page.getByRole('navigation', { name: 'Komponenten' })).toHaveCount(0);
const sidebar = page.getByRole('navigation', { name: 'Dokumentation' });
await expect(sidebar.getByRole('button')).toHaveText(['Text', 'Gliederungsfläche']);
await expect(sidebar.getByRole('button')).toHaveText(['Text', 'Gliederungsfläche', 'Exportieren']);
// A rectangle dragged onto the surface: 100 × 50, its middle where it was dropped, grey, with the focus.
const surface = (await canvas.boundingBox())!;
@@ -159,11 +163,84 @@ test('makes, moves, resizes, colours and types documentation as Java does, and p
await expect(page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' })).toHaveAttribute('aria-pressed', 'true');
});
test("exports the surface as Java's image, under the project's name", async ({ page }) => {
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
const button = page.getByRole('navigation', { name: 'Dokumentation' }).getByRole('button', { name: 'Exportieren' });
await expect(button).toHaveAttribute('title', 'Als Bild exportieren');
const saved = page.waitForEvent('download');
await button.click();
const download = await saved;
expect(download.suggestedFilename()).toBe('nat_with_mapped_port.png');
// Ready for the next one.
await expect(button).toBeEnabled();
const base64 = readFileSync((await download.path())!).toString('base64');
// What it should show, from the surface: every device's button and every documentation element, 10 around, the footer 20 below.
const boxes = await page.locator('g.device').evaluateAll((devices) => devices.map((device) => device.getAttribute('data-box')!.split(' ').map(Number)));
const docs = (await drawing(page)).docs;
const parts = [...boxes.map(([x, y, width, height]) => ({ x: x!, y: y!, width: width!, height: height! })), ...docs];
const left = Math.min(...parts.map((part) => part.x));
const top = Math.min(...parts.map((part) => part.y));
const right = Math.max(...parts.map((part) => part.x + part.width));
const bottom = Math.max(...parts.map((part) => part.y + part.height));
const footerWidth = await page.evaluate(
({ font, text }) => {
const context = document.createElement('canvas').getContext('2d')!;
context.font = font;
return Math.round(context.measureText(text).width) + 20;
},
{ font: JAVA_LABEL_FONT, text: 'nat_with_mapped_port.fls' },
);
const rectangle = docs.find((item) => item.kind === 'rectangle')!;
const image = await page.evaluate(
async ({ base64, rectangle, footer }) => {
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' }));
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d')!;
context.drawImage(bitmap, 0, 0);
const pixel = (x: number, y: number) => [...context.getImageData(x, y, 1, 1).data];
const footerPixels = context.getImageData(footer.x, footer.y, footer.width, footer.height).data;
let grey = 0;
for (let index = 0; index < footerPixels.length; index += 4) {
if (footerPixels[index] === 192 && footerPixels[index + 1] === 192 && footerPixels[index + 2] === 192 && footerPixels[index + 3] === 255) grey += 1;
}
return { width: bitmap.width, height: bitmap.height, corner: pixel(0, 0), rectangle: pixel(rectangle.x, rectangle.y), grey };
},
{
base64,
rectangle: { x: rectangle.x + 8 - (left - 10), y: rectangle.y + 8 - (top - 10) },
footer: { x: 10, y: bottom + 20 - (top - 10), width: footerWidth, height: 17 },
},
);
expect([image.width, image.height]).toEqual([Math.max(right, left + 10 + footerWidth) + 10 - (left - 10), bottom + 20 + 17 + 20 - (top - 10)]);
// Transparent around; a grey rectangle (204) as the design paints it, a fifth of the way to white; the footer light grey.
expect(image.corner).toEqual([0, 0, 0, 0]);
expect(image.rectangle).toEqual([215, 215, 215, 255]);
expect(image.grey).toBeGreaterThan(20);
});
test("shows Java's message when the image cannot be saved", async ({ page }) => {
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
await page.evaluate(() => {
HTMLCanvasElement.prototype.toBlob = function (callback: BlobCallback) {
callback(null);
};
});
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
const button = page.getByRole('button', { name: 'Exportieren' });
await button.click();
await expect(page.getByRole('alert')).toHaveText('Fehler beim Speichern aufgetreten!');
await expect(button).toBeEnabled();
});
test('keeps the documentation mode within reach on a phone', async ({ page }) => {
await page.setViewportSize({ width: 360, height: 740 });
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
await page.getByRole('button', { name: 'Wechselt in den Dokumentationsmodus' }).click();
await expect(page.getByRole('button', { name: 'Gliederungsfläche' })).toBeInViewport({ ratio: 1 });
await expect(page.getByRole('button', { name: 'Exportieren' })).toBeInViewport({ ratio: 1 });
const rights = await page.locator('.editor-toolbar > *').evaluateAll((parts) => parts.map((part) => part.getBoundingClientRect().right));
expect(Math.max(...rights)).toBeLessThanOrEqual(360);
});