Skip to content

Commit 9b0b1af

Browse files
yueguobinclaude
andcommitted
fix(screenshot): enable PNG export on all platforms and fix blob URL icons (GNS3#1534)
Enable PNG screenshot export on all platforms (Linux/macOS/Windows) and fix missing icons when exporting custom symbols cached as blob URLs. Changes: - Remove Windows-only restriction for PNG export (isPngAvailable = true) - Remove DeviceDetectorService dependency (no longer needed) - Fix blob URL icons not appearing in PNG exports: * Detect blob URLs vs server symbol URLs * Fetch blob content directly from browser cache * Use importNode to properly inline SVG with viewBox - Improve PNG export robustness: * Use cloneNode() to avoid modifying original SVG * Add try-catch error handling with user feedback * Process embedded images with better error recovery - Add ToasterService for error notifications Resolves GNS3#1534 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6d56d0f commit 9b0b1af

File tree

2 files changed

+53
-19
lines changed

2 files changed

+53
-19
lines changed

src/app/components/project-map/project-map-menu/project-map-menu.component.ts

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { ProjectService } from '@services/project.service';
3232
import { SymbolService } from '@services/symbol.service';
3333
import { ThemeService } from '@services/theme.service';
3434
import { ToolsService } from '@services/tools.service';
35+
import { ToasterService } from '@services/toaster.service';
3536
import { Screenshot, ScreenshotDialogComponent } from '../screenshot-dialog/screenshot-dialog.component';
3637
import { ProjectMapLockConfirmationDialogComponent } from './project-map-lock-confirmation-dialog/project-map-lock-confirmation-dialog.component';
3738
import { DrawingsDataSource } from '../../../cartography/datasources/drawings-datasource';
@@ -67,6 +68,7 @@ export class ProjectMapMenuComponent implements OnInit, OnDestroy {
6768
private drawingsDataSource = inject(DrawingsDataSource);
6869
private aiChatStore = inject(AiChatStore);
6970
private cdr = inject(ChangeDetectorRef);
71+
private toaster = inject(ToasterService);
7072
readonly project = input<Project>(undefined);
7173
readonly controller = input<Controller>(undefined);
7274
@Output() aiChatOpened = new EventEmitter<void>();
@@ -139,26 +141,60 @@ export class ProjectMapMenuComponent implements OnInit, OnDestroy {
139141

140142
private async saveImage(screenshotProperties: Screenshot) {
141143
if (screenshotProperties.filetype === 'png') {
142-
let splittedSvg = document.getElementsByTagName('svg')[0].outerHTML.split('image');
143-
let i = 1;
144+
try {
145+
// Get the SVG element and clone it to avoid modifying the original
146+
const originalSvg = document.getElementsByTagName('svg')[0];
147+
const svgClone = originalSvg.cloneNode(true) as SVGElement;
144148

145-
while (i < splittedSvg.length) {
146-
let splittedImage = splittedSvg[i].split('"');
147-
let splittedUrl = splittedImage[1].split('/');
149+
// Process any embedded images
150+
const images = Array.from(svgClone.getElementsByTagName('image'));
148151

149-
let elem = await this.symbolService.raw(this.controller(), splittedUrl[7]).toPromise();
150-
let splittedElement = elem.split('-->');
151-
splittedSvg[i] = splittedElement[1].substring(2);
152-
i += 2;
153-
}
154-
let svgString = splittedSvg.join();
152+
for (let i = 0; i < images.length; i++) {
153+
const image = images[i];
154+
const href = image.getAttribute('href') || image.getAttribute('xlink:href');
155+
if (!href) continue;
156+
157+
try {
158+
let svgContent: string;
155159

156-
// Use DOMParser instead of innerHTML to avoid XSS warnings
157-
const parser = new DOMParser();
158-
const doc = parser.parseFromString(svgString, 'image/svg+xml');
159-
const element = doc.documentElement;
160+
// Check if it's a blob URL (cached custom symbols)
161+
if (href.startsWith('blob:')) {
162+
// Fetch the blob content directly
163+
const response = await fetch(href);
164+
const blob = await response.blob();
165+
svgContent = await blob.text();
166+
} else {
167+
// Server-hosted symbol URL
168+
const urlParts = href.split('/symbols/');
169+
if (urlParts.length > 1) {
170+
const symbolId = urlParts[urlParts.length - 1].replace('/raw', '');
171+
const rawSvg = await this.symbolService.raw(this.controller(), symbolId).toPromise();
172+
if (rawSvg) {
173+
svgContent = rawSvg.includes('-->') ? rawSvg.split('-->')[1].trim() : rawSvg.trim();
174+
}
175+
}
176+
}
160177

161-
svg.saveSvgAsPng(element, `${screenshotProperties.name}.png`);
178+
if (svgContent) {
179+
// Parse the SVG content and import it
180+
const parser = new DOMParser();
181+
const svgDoc = parser.parseFromString(svgContent, 'image/svg+xml');
182+
const importedSvg = svgDoc.documentElement;
183+
const importedNode = svgClone.ownerDocument.importNode(importedSvg, true);
184+
image.parentNode.replaceChild(importedNode, image);
185+
}
186+
} catch (err) {
187+
console.warn(`Failed to process embedded image: ${href}`, err);
188+
}
189+
}
190+
191+
// Save as PNG
192+
svg.saveSvgAsPng(svgClone, `${screenshotProperties.name}.png`);
193+
} catch (err) {
194+
console.error('Failed to save PNG:', err);
195+
this.toaster.error('Failed to save screenshot as PNG');
196+
throw err;
197+
}
162198
} else {
163199
var svg_el = select('svg').attr('version', 1.1).attr('xmlns', 'http://www.w3.org/2000/svg').node();
164200
downloadSvg(select('svg').node(), `${screenshotProperties.name}`);

src/app/components/project-map/screenshot-dialog/screenshot-dialog.component.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { MatButtonModule } from '@angular/material/button';
1212
import { MatInputModule } from '@angular/material/input';
1313
import { MatCardModule } from '@angular/material/card';
1414
import { MatRadioModule } from '@angular/material/radio';
15-
import { DeviceDetectorService } from 'ngx-device-detector';
1615
import { ToasterService } from '@services/toaster.service';
1716

1817
@Component({
@@ -35,7 +34,6 @@ export class ScreenshotDialogComponent implements OnInit {
3534
public dialogRef = inject(MatDialogRef<ScreenshotDialogComponent>);
3635
private toasterService = inject(ToasterService);
3736
private formBuilder = inject(UntypedFormBuilder);
38-
private deviceService = inject(DeviceDetectorService);
3937
private cd = inject(ChangeDetectorRef);
4038

4139
nameForm: UntypedFormGroup;
@@ -46,7 +44,7 @@ export class ScreenshotDialogComponent implements OnInit {
4644
this.nameForm = this.formBuilder.group({
4745
screenshotName: new UntypedFormControl(`screenshot-${Date.now()}`, [Validators.required]),
4846
});
49-
this.isPngAvailable = this.deviceService.getDeviceInfo().os === 'Windows';
47+
this.isPngAvailable = true;
5048
}
5149

5250
ngOnInit() {

0 commit comments

Comments
 (0)