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:
@@ -25,7 +25,7 @@ import type { Point, Rect } from './viewport.js';
|
||||
export const JAVA_LABEL_FONT = '14px Arial, Helvetica, "Liberation Sans", "DejaVu Sans", sans-serif';
|
||||
/** Its FontMetrics.getHeight, as most of the files' bounds have it. */
|
||||
export const JAVA_LABEL_FONT_HEIGHT = 17;
|
||||
const JAVA_LABEL_ASCENT = 14;
|
||||
export const JAVA_LABEL_ASCENT = 14;
|
||||
const BORDER = 8;
|
||||
|
||||
/** Java's default icons (IconMode.DEFAULT) and their sizes. */
|
||||
@@ -88,16 +88,28 @@ export function deviceBox(node: { readonly kind: NodeKind; readonly cloud: boole
|
||||
};
|
||||
}
|
||||
|
||||
/** JCablePanel's curve between two devices' middles, as an SVG path. */
|
||||
export function cablePath(first: Point, second: Point): string {
|
||||
/** JCablePanel's bounds: the rectangle the two ends span, with 2 to spare (updateBounds). */
|
||||
export function cableFrame(first: Point, second: Point): Rect {
|
||||
const left = Math.min(first.x, second.x) - 2;
|
||||
const top = Math.min(first.y, second.y) - 2;
|
||||
return { x: left, y: top, width: Math.abs(first.x - second.x) + 4, height: Math.abs(first.y - second.y) + 4 };
|
||||
}
|
||||
|
||||
/** JCablePanel's quadratic curve between two devices' middles: its control point. */
|
||||
export function cableControl(first: Point, second: Point): Point {
|
||||
const [x1, y1, x2, y2] = [first.x, first.y, second.x, second.y];
|
||||
// The panel spans the two ends with 2 to spare (updateBounds); the control point is relative to it.
|
||||
const left = Math.min(x1, x2) - 2;
|
||||
const top = Math.min(y1, y2) - 2;
|
||||
// The control point is relative to the panel's bounds.
|
||||
const { x: left, y: top } = cableFrame(first, second);
|
||||
let controlX = Math.trunc((x1 - left + x2 - left) / 4);
|
||||
if ((x1 > x2 && y1 > y2) || (x1 < x2 && y1 < y2)) controlX = 3 * controlX;
|
||||
const controlY = Math.trunc((y1 - top + y2 - top) / 4);
|
||||
return `M ${x1} ${y1} Q ${left + controlX} ${top + controlY} ${x2} ${y2}`;
|
||||
return { x: left + controlX, y: top + controlY };
|
||||
}
|
||||
|
||||
/** JCablePanel's curve, as an SVG path. */
|
||||
export function cablePath(first: Point, second: Point): string {
|
||||
const control = cableControl(first, second);
|
||||
return `M ${first.x} ${first.y} Q ${control.x} ${control.y} ${second.x} ${second.y}`;
|
||||
}
|
||||
|
||||
/** The rectangle around all of them. */
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// FILIUS' „Exportieren“ in the documentation mode (GUIContainer.createNetworkImage,
|
||||
// GUIPrintPanel; measured with tools/java-oracle's ExportRun): the network
|
||||
// and its documentation as a PNG, painted as the design shows them — the
|
||||
// documentation below, an opaque rectangle a fifth of the way to white and
|
||||
// a text without its border; cables over it, devices over the cables;
|
||||
// nothing chosen — on a transparent background. The image is cut to what
|
||||
// is there: 10 to spare on the left, above and on the right; 20 below the
|
||||
// lowest element a light grey footer with the project's name, flush with
|
||||
// the leftmost element, and 20 to spare below it. Java's own measure: a
|
||||
// footer as wide as its words and 20 more, as high as its font.
|
||||
//
|
||||
// Not adopted from Java: an empty project gives Java an image of 20 × 35
|
||||
// with nothing in it (its footer lands beyond reach, an integer overflow);
|
||||
// here it is the footer alone. Java cuts at its surface's edges (nothing
|
||||
// lies beyond them there); the editor has no edges, so nothing is cut. The
|
||||
// footer is the project's name: a browser knows no file's path.
|
||||
|
||||
import type { DocumentationView, TopologyView } from '@mdg-filius/doc';
|
||||
import { rectangleBorder, rectangleFill, textColor, fontFamily } from './documentationPaint.js';
|
||||
import { bounds, cableControl, cableFrame, deviceBox, JAVA_LABEL_ASCENT, JAVA_LABEL_FONT, JAVA_LABEL_FONT_HEIGHT, type DeviceBox, type IconName } from './layout.js';
|
||||
import type { Rect, Size } from './viewport.js';
|
||||
|
||||
/** GUIPrintPanel.EMPTY_BORDER: what is left around the elements. */
|
||||
export const EXPORT_MARGIN = 10;
|
||||
/** How far below the lowest element the footer begins, and what is left below it. */
|
||||
export const FOOTER_GAP = 20;
|
||||
/** GUIFooterPanel: Color.lightGray. */
|
||||
export const FOOTER_COLOR = 'rgb(192, 192, 192)';
|
||||
/** JCablePanel's colours: a cable, a wireless link (dashed). */
|
||||
export const CABLE_COLOR = 'rgb(64, 64, 64)';
|
||||
export const WIRELESS_COLOR = 'rgb(192, 192, 192)';
|
||||
/** A device's label, as Java's look and feel writes it. */
|
||||
export const LABEL_COLOR = 'rgb(0, 0, 0)';
|
||||
|
||||
export interface ExportLayout {
|
||||
/** The part of the surface the image shows. */
|
||||
readonly image: Rect;
|
||||
/** The footer's label. */
|
||||
readonly footer: Rect;
|
||||
}
|
||||
|
||||
/** Where the image and its footer lie, around the elements' rectangles (GUIPrintPanel.getClipX … getClipHeight). */
|
||||
export function exportLayout(parts: readonly Rect[], footer: Size): ExportLayout {
|
||||
const content = parts.length === 0 ? null : bounds(parts);
|
||||
const left = content?.x ?? 0;
|
||||
const footerTop = content === null ? 0 : content.y + content.height + FOOTER_GAP;
|
||||
const top = content?.y ?? footerTop;
|
||||
// The footer's label already holds 20 more than its words; Java leaves 10 beyond its own clip of it.
|
||||
const right = Math.max(content === null ? -Infinity : content.x + content.width, left + EXPORT_MARGIN + footer.width) + EXPORT_MARGIN;
|
||||
const bottom = footerTop + footer.height + FOOTER_GAP;
|
||||
return {
|
||||
image: { x: left - EXPORT_MARGIN, y: top - EXPORT_MARGIN, width: right - (left - EXPORT_MARGIN), height: bottom - (top - EXPORT_MARGIN) },
|
||||
footer: { x: left, y: footerTop, width: footer.width, height: footer.height },
|
||||
};
|
||||
}
|
||||
|
||||
/** The lines a text is written in within a width: its own lines, each broken after a space, a word wider than the width broken where it reaches it. */
|
||||
export function wrapLines(text: string, width: number, measure: (text: string) => number): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const paragraph of text.split('\n')) {
|
||||
let line = '';
|
||||
// Each word with the spaces after it: they stay with it, at a line's end too.
|
||||
for (const word of paragraph.match(/\S*\s*/g)!.filter((each) => each !== '')) {
|
||||
if (measure((line + word).trimEnd()) <= width) {
|
||||
line += word;
|
||||
continue;
|
||||
}
|
||||
if (line !== '') lines.push(line);
|
||||
line = '';
|
||||
for (const character of word) {
|
||||
if (line !== '' && measure((line + character).trimEnd()) > width) {
|
||||
lines.push(line);
|
||||
line = '';
|
||||
}
|
||||
line += character;
|
||||
}
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** What of a 2D context the export paints with. */
|
||||
export type Painter = Pick<
|
||||
CanvasRenderingContext2D,
|
||||
| 'save'
|
||||
| 'restore'
|
||||
| 'translate'
|
||||
| 'beginPath'
|
||||
| 'moveTo'
|
||||
| 'quadraticCurveTo'
|
||||
| 'roundRect'
|
||||
| 'rect'
|
||||
| 'clip'
|
||||
| 'fill'
|
||||
| 'stroke'
|
||||
| 'fillText'
|
||||
| 'measureText'
|
||||
| 'drawImage'
|
||||
| 'setLineDash'
|
||||
| 'font'
|
||||
| 'fillStyle'
|
||||
| 'strokeStyle'
|
||||
| 'lineWidth'
|
||||
| 'textAlign'
|
||||
| 'textBaseline'
|
||||
>;
|
||||
|
||||
/** The devices' buttons and the rectangles of everything in the image (devices, cables, documentation), as Java's panels count them. */
|
||||
export function networkParts(view: TopologyView, labelWidth: (text: string) => number): { readonly boxes: ReadonlyMap<string, DeviceBox>; readonly parts: readonly Rect[] } {
|
||||
const boxes = new Map(view.nodes.map((node) => [node.id, deviceBox(node, labelWidth(node.label))]));
|
||||
const parts: Rect[] = [...boxes.values()];
|
||||
for (const link of view.links) {
|
||||
const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)];
|
||||
if (source !== undefined && target !== undefined) parts.push(cableFrame(source.center, target.center));
|
||||
}
|
||||
for (const item of view.docs) parts.push({ x: item.x, y: item.y, width: item.width, height: item.height });
|
||||
return { boxes, parts };
|
||||
}
|
||||
|
||||
function paintDocumentation(context: Painter, item: DocumentationView) {
|
||||
if (item.kind === 'rectangle') {
|
||||
// As the surface draws it without the focus: inset by 1, its border 1 wide, corners of 5.
|
||||
context.beginPath();
|
||||
context.roundRect(item.x + 1, item.y + 1, Math.max(0, item.width - 2), Math.max(0, item.height - 2), 5);
|
||||
context.fillStyle = rectangleFill(item.color, false);
|
||||
context.fill();
|
||||
context.lineWidth = 1;
|
||||
context.strokeStyle = rectangleBorder(item.color);
|
||||
context.stroke();
|
||||
return;
|
||||
}
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.rect(item.x, item.y, item.width, item.height);
|
||||
context.clip();
|
||||
context.font = `${item.italic ? 'italic ' : ''}${item.bold ? 'bold ' : ''}${item.size}px ${fontFamily(item.font)}`;
|
||||
context.fillStyle = textColor(item.color);
|
||||
context.textAlign = 'left';
|
||||
context.textBaseline = 'alphabetic';
|
||||
// The surface's line height ("normal"): the font's own ascent and descent.
|
||||
const metrics = context.measureText(item.text);
|
||||
const ascent = metrics.fontBoundingBoxAscent;
|
||||
const height = ascent + metrics.fontBoundingBoxDescent;
|
||||
// Inside a padding of 1, as the surface writes it.
|
||||
wrapLines(item.text, item.width - 2, (text) => context.measureText(text).width).forEach((line, index) => {
|
||||
context.fillText(line, item.x + 1, item.y + 1 + ascent + index * height);
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** The devices' buttons and the image's layout, with the labels measured in Java's font on the context. */
|
||||
export function measureNetworkImage(context: Painter, view: TopologyView, footerText: string): { readonly boxes: ReadonlyMap<string, DeviceBox>; readonly layout: ExportLayout } {
|
||||
context.font = JAVA_LABEL_FONT;
|
||||
const labelWidth = (text: string) => context.measureText(text).width;
|
||||
const { boxes, parts } = networkParts(view, labelWidth);
|
||||
return { boxes, layout: exportLayout(parts, { width: Math.round(labelWidth(footerText)) + 20, height: JAVA_LABEL_FONT_HEIGHT }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints the image onto a context as large as its layout's image: the
|
||||
* documentation, the cables, the devices, the footer. The icons are Java's,
|
||||
* loaded already.
|
||||
*/
|
||||
export function paintNetworkImage(context: Painter, view: TopologyView, icons: Readonly<Record<IconName, CanvasImageSource>>, footerText: string): ExportLayout {
|
||||
const { boxes, layout } = measureNetworkImage(context, view, footerText);
|
||||
context.save();
|
||||
context.translate(-layout.image.x, -layout.image.y);
|
||||
|
||||
for (const item of view.docs) paintDocumentation(context, item);
|
||||
|
||||
context.lineWidth = 2;
|
||||
for (const link of view.links) {
|
||||
const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)];
|
||||
if (source === undefined || target === undefined) continue;
|
||||
const control = cableControl(source.center, target.center);
|
||||
context.beginPath();
|
||||
context.moveTo(source.center.x, source.center.y);
|
||||
context.quadraticCurveTo(control.x, control.y, target.center.x, target.center.y);
|
||||
context.strokeStyle = link.wireless ? WIRELESS_COLOR : CABLE_COLOR;
|
||||
context.setLineDash(link.wireless ? [10] : []);
|
||||
context.stroke();
|
||||
}
|
||||
context.setLineDash([]);
|
||||
|
||||
context.font = JAVA_LABEL_FONT;
|
||||
context.textBaseline = 'alphabetic';
|
||||
for (const node of view.nodes) {
|
||||
const box = boxes.get(node.id)!;
|
||||
context.drawImage(icons[box.icon], box.iconRect.x, box.iconRect.y, box.iconRect.width, box.iconRect.height);
|
||||
context.fillStyle = LABEL_COLOR;
|
||||
context.textAlign = 'center';
|
||||
context.fillText(node.label, box.label.x, box.label.y);
|
||||
}
|
||||
|
||||
// A JLabel's words: at its left, its font's height high.
|
||||
context.fillStyle = FOOTER_COLOR;
|
||||
context.textAlign = 'left';
|
||||
context.fillText(footerText, layout.footer.x, layout.footer.y + JAVA_LABEL_ASCENT);
|
||||
context.restore();
|
||||
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 } {
|
||||
const name = projectName?.trim() ?? '';
|
||||
if (name === '') return { footer: 'Filius', file: 'Filius.png' };
|
||||
const dot = name.lastIndexOf('.');
|
||||
return { footer: name, file: `${dot > 0 ? name.slice(0, dot) : name}.png` };
|
||||
}
|
||||
Reference in New Issue
Block a user