// 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; 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; 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>, 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` }; }