Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@
"cytoscape-edgehandles": "^3.6.0",
"cytoscape-grid-guide": "^2.3.2",
"cytoscape-node-editing": "^4.0.0",
"cytoscape-svg": "^0.4.0",
"file-saver": "^2.0.5",
"hotkeys-js": "^3.8.7",
"jquery": "^3.0.0",
"konva": "^7.0.3",
"lucide-react": "^0.487.0",
"md5": "^2.3.0",
"moment": "^2.29.4",
"png-chunk-text": "^1.0.0",
"png-chunks-encode": "^1.0.0",
"png-chunks-extract": "^1.0.0",
Comment on lines 18 to +29

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New dependency cytoscape-svg is GPLv3-licensed (see package-lock.json entry), while this repo is BSD-3-Clause (LICENSE). This can create a license incompatibility for distribution. Please confirm the project can legally take a GPLv3 dependency or switch to an SVG export approach/library with a permissive license compatible with BSD.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pradeeban Actually, excellent catch by copilot there.

I think the main problem is not cytoscape or even svg as entirety but just this specific plugin cytoscape-svg that I used to add the functionality for svg import export (svg support didn't exist prior to this PR).

cystoscape-svg is GPLv3 licensed and will not be ideal for open source, (we should only stick to MIT licensed and open source).

It's an excellent note to take for me as a contributor to verify the licensing for any new packages we add for some new functionality or some fix. Will keep this in mind for future!

I have fixed this with my first follow-up commit.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting (and important) catch, indeed!

"prismjs": "^1.30.0",
"process": "^0.11.10",
"rc-slider": "^9.7.2",
Expand Down
7 changes: 4 additions & 3 deletions src/component/File-drag-drop.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ const app = ({ superState, dispatcher }) => {
e.preventDefault();
fileRef.current.value = null;
const droppedFile = e.dataTransfer.files[0];
const ext = droppedFile && droppedFile.name.split('.').slice(-1)[0];
if (e.dataTransfer.files.length === 1 && (ext === 'graphml' || ext === 'json')) {
const ext = droppedFile && droppedFile.name.split('.').slice(-1)[0]?.toLowerCase();
const allowed = ['graphml', 'json', 'png', 'svg', 'jpg', 'jpeg'];
if (e.dataTransfer.files.length === 1 && allowed.includes(ext)) {
readFile(superStateRef.current, dispatcherRef.current, droppedFile);
Comment on lines +43 to 46

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says the File Open menu scans image files for embedded metadata, but the file picker in src/component/fileBrowser.jsx still restricts selection to .graphml/.json only (both pickerOpts.types.accept and the fallback <input accept>). Please update the File Open menu accept/types to include .png, .svg, .jpg, .jpeg so the UI matches the new readFile support.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in third follow up commit!

}
};
Expand Down Expand Up @@ -70,7 +71,7 @@ const app = ({ superState, dispatcher }) => {
ref={fileRef}
onClick={(e) => { e.target.value = null; }}
style={{ display: 'none' }}
accept=".graphml,.json"
accept=".graphml,.json,.png,.svg,.jpg,.jpeg"
onChange={(e) => readFile(superState, dispatcher, e.target.files[0])}
/>
<span className="arrow">&#10230;</span>
Expand Down
4 changes: 4 additions & 0 deletions src/graph-builder/graph-core/1-core.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import cytoscape from 'cytoscape';
import edgehandles from 'cytoscape-edgehandles';
import gridGuide from 'cytoscape-grid-guide';
import svg from 'cytoscape-svg';
import Konva from 'konva';
import nodeEditing from 'cytoscape-node-editing';
import $ from 'jquery';
Expand Down Expand Up @@ -44,6 +45,9 @@ class CoreGraph {
if (typeof cytoscape('core', 'gridGuide') !== 'function') {
gridGuide(cytoscape);
}
if (typeof cytoscape('core', 'svg') !== 'function') {
cytoscape.use(svg);
}
// if (cy) this.cy = cy;
this.cy = cytoscape({ ...cyOptions(darkMode), container: element });
this.cy.on('position', 'node', () => {
Expand Down
74 changes: 72 additions & 2 deletions src/graph-builder/graph-core/5-load-save.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { saveAs } from 'file-saver';
import { toast } from 'react-toastify';
import extractChunks from 'png-chunks-extract';
import encodeChunks from 'png-chunks-encode';
import textChunk from 'png-chunk-text';
import localStorageManager from '../local-storage-manager';
import graphmlBuilder from '../graphml/builder';
import BendingDistanceWeight from '../calculations/bending-dist-weight';
Expand Down Expand Up @@ -30,8 +33,75 @@ class GraphLoadSave extends GraphUndoRedo {
downloadImg(format) {
this.cy.emit('hide-bend');
this.cy.$('.eh-handle').remove();
if (format === 'PNG') saveAs(this.cy.png({ full: true }), `${this.getName()}-DHGWorkflow.png`);
if (format === 'JPG') saveAs(this.cy.jpg({ full: true }), `${this.getName()}-DHGWorkflow.jpg`);
if (format === 'JPG') {
saveAs(this.cy.jpg({ full: true }), `${this.getName()}-DHGWorkflow.jpg`);
return;
}
if (format === 'PNG') {
saveAs(this.cy.png({ full: true }), `${this.getName()}-DHGWorkflow.png`);
return;
}
if (format === 'PNG-EMBEDDED') {
const b64Uri = this.cy.png({ full: true });
const b64Data = b64Uri.split(',')[1];
const buffer = new Uint8Array(window.atob(b64Data).split('').map((c) => c.charCodeAt(0)));
const chunks = extractChunks(buffer);
chunks.splice(-1, 0, textChunk.encode('graphml', this.getGraphML()));
const newBuffer = new Uint8Array(encodeChunks(chunks));
Comment on lines +45 to +53

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PNG-EMBEDDED export converts a base64 data URL to bytes via window.atob(...).split('').map(...), which creates large intermediate arrays/strings and can blow up memory for larger graphs. Prefer a single-pass conversion (e.g., preallocate a Uint8Array and fill it in a loop, or convert via fetch(dataUrl).then(r => r.arrayBuffer())) to reduce peak memory and improve performance.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed with second follow-up commit!

const blob = new Blob([newBuffer], { type: 'image/png' });
saveAs(blob, `${this.getName()}.graphml.png`);
return;
}
if (format === 'SVG') {
const blob = new Blob([this.cy.svg({ full: true })], { type: 'image/svg+xml;charset=utf-8' });
saveAs(blob, `${this.getName()}-DHGWorkflow.svg`);
return;
}
if (format === 'SVG-EMBEDDED') {
const svgStr = this.cy.svg({ full: true });
const parser = new DOMParser();
const doc = parser.parseFromString(svgStr, 'image/svg+xml');
const metadata = doc.createElementNS('http://www.w3.org/2000/svg', 'metadata');
metadata.setAttribute('data-graphml', this.getGraphML());
doc.documentElement.insertBefore(metadata, doc.documentElement.firstChild);
const newSvg = new XMLSerializer().serializeToString(doc);
const blob = new Blob([newSvg], { type: 'image/svg+xml;charset=utf-8' });
saveAs(blob, `${this.getName()}.graphml.svg`);
return;
}
if (format === 'JPG-EMBEDDED') {
const b64Uri = this.cy.jpg({ full: true });
const b64Data = b64Uri.split(',')[1];
const buffer = new Uint8Array(window.atob(b64Data).split('').map((c) => c.charCodeAt(0)));
const graphMLStr = this.getGraphML();
Comment on lines +59 to +65

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JPG-EMBEDDED export also converts the base64 data URL to bytes via window.atob(...).split('').map(...), which is memory-heavy for large images. Please switch to a more memory-efficient conversion (single-pass loop into a preallocated Uint8Array, or fetch(dataUrl) to arrayBuffer()).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed with second follow-up commit!

const graphMLBytes = new TextEncoder().encode(graphMLStr);

const comSegments = [];
for (let i = 0; i < graphMLBytes.length; i += 65533) {
const chunk = graphMLBytes.slice(i, i + 65533);
const segmentLen = chunk.length + 2;
const seg = new Uint8Array(4 + chunk.length);
seg[0] = 0xFF;
seg[1] = 0xFE;
seg[2] = Math.floor(segmentLen / 256);
seg[3] = segmentLen % 256;
seg.set(chunk, 4);
Comment on lines +68 to +77

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JPEG embedded export writes raw GraphML bytes directly into COM segments without any identifier/signature. Since the import path currently reads all COM segments, any pre-existing COM segments will corrupt the payload (and external tools may add COM segments later). Add a recognizable header (and segment index/count) to each COM segment so import can reliably select and reassemble only your GraphML segments.

Suggested change
const comSegments = [];
for (let i = 0; i < graphMLBytes.length; i += 65533) {
const chunk = graphMLBytes.slice(i, i + 65533);
const segmentLen = chunk.length + 2;
const seg = new Uint8Array(4 + chunk.length);
seg[0] = 0xFF;
seg[1] = 0xFE;
seg[2] = Math.floor(segmentLen / 256);
seg[3] = segmentLen % 256;
seg.set(chunk, 4);
// Prepare a recognizable header for each COM segment carrying GraphML.
const headerBytes = new TextEncoder().encode('GRAPHMLCHUNK');
const perSegmentMetaLength = 4; // 2 bytes: total segment count, 2 bytes: segment index
const headerLength = headerBytes.length + perSegmentMetaLength;
// JPEG COM segment payload limit is 65533 bytes (length field is 2 bytes, max 65535).
const maxChunkSize = 65533 - headerLength;
const segmentCount = Math.ceil(graphMLBytes.length / maxChunkSize);
const comSegments = [];
for (let segIndex = 0, offset = 0; offset < graphMLBytes.length; segIndex += 1, offset += maxChunkSize) {
const chunk = graphMLBytes.slice(offset, offset + maxChunkSize);
const payloadLength = headerLength + chunk.length;
const segmentLen = payloadLength + 2; // includes these 2 bytes for the length field
const seg = new Uint8Array(4 + payloadLength);
// JPEG COM marker
seg[0] = 0xFF;
seg[1] = 0xFE;
// Big-endian segment length (includes these 2 bytes, excludes marker)
seg[2] = Math.floor(segmentLen / 256);
seg[3] = segmentLen % 256;
// Header: magic string
let writePos = 4;
seg.set(headerBytes, writePos);
writePos += headerBytes.length;
// Header: total segment count (2 bytes, big-endian)
seg[writePos] = Math.floor(segmentCount / 256);
seg[writePos + 1] = segmentCount % 256;
writePos += 2;
// Header: current segment index (0-based, 2 bytes, big-endian)
seg[writePos] = Math.floor(segIndex / 256);
seg[writePos + 1] = segIndex % 256;
writePos += 2;
// Payload: chunk of GraphML bytes
seg.set(chunk, writePos);

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably too much edge case thinking, the images should not break in normal sharing

No changes required here in my opinion, still will like to know Mentors suggestion!

comSegments.push(seg);
}

const totalComSize = comSegments.reduce((sum, seg) => sum + seg.length, 0);
const newBuffer = new Uint8Array(buffer.length + totalComSize);
newBuffer.set(buffer.slice(0, 2), 0); // FF D8
let offset = 2;
comSegments.forEach((seg) => {
newBuffer.set(seg, offset);
offset += seg.length;
});
newBuffer.set(buffer.slice(2), offset);

const blob = new Blob([newBuffer], { type: 'image/jpeg' });
saveAs(blob, `${this.getName()}.graphml.jpg`);
}
}

shouldNodeBeSaved(nodeID) {
Expand Down
104 changes: 103 additions & 1 deletion src/toolbarActions/toolbarFunctions.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { saveAs } from 'file-saver';
import { toast } from 'react-toastify';
import extractChunks from 'png-chunks-extract';
import textChunk from 'png-chunk-text';
import parser from '../graph-builder/graphml/parser';
import { actionType as T } from '../reducer';

Expand Down Expand Up @@ -150,7 +152,7 @@ const readFile = async (state, setState, file, fileHandle) => {
}
const fr = new FileReader();
const projectName = file.name;
const ext = file.name.split('.').pop();
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext === 'graphml') {
fr.onload = (x) => {
parser(x.target.result).then(({ authorName }) => {
Expand Down Expand Up @@ -186,6 +188,106 @@ const readFile = async (state, setState, file, fileHandle) => {
}
};
fr.readAsText(file);
} else if (ext === 'png') {
fr.onload = (x) => {
try {
const buffer = new Uint8Array(x.target.result);
const chunks = extractChunks(buffer);
const textChunks = chunks.filter((c) => c.name === 'tEXt').map((c) => textChunk.decode(c));
const graphMLMeta = textChunks.find((c) => c.keyword === 'graphml');
if (graphMLMeta && graphMLMeta.text) {
parser(graphMLMeta.text).then(({ authorName }) => {
setState({
type: T.ADD_GRAPH,
payload: {
projectName,
graphML: graphMLMeta.text,
fileHandle: null,
fileName: file.name,
authorName,
},
});
toast.success('Imported embedded GraphML from Image!');
}).catch(() => toast.error('Embedded GraphML inside PNG is invalid.'));
} else {
toast.error('This PNG does not contain an embedded GraphML Workflow.');
}
} catch (err) {
toast.error('Could not parse the PNG file.');
}
};
if (fileHandle) fr.readAsArrayBuffer(await fileHandle.getFile());
else fr.readAsArrayBuffer(file);
} else if (ext === 'svg') {
fr.onload = (x) => {
try {
const parserDOM = new DOMParser();
const svgDoc = parserDOM.parseFromString(x.target.result, 'image/svg+xml');
const metadata = svgDoc.getElementsByTagName('metadata')[0];
const graphML = metadata ? metadata.getAttribute('data-graphml') : null;
if (graphML) {
parser(graphML).then(({ authorName }) => {
setState({
type: T.ADD_GRAPH,
payload: {
projectName,
graphML,
fileHandle: null,
fileName: file.name,
authorName,
},
});
toast.success('Imported embedded GraphML from SVG!');
}).catch(() => toast.error('Embedded GraphML inside SVG is invalid.'));
} else {
toast.error('This SVG does not contain an embedded GraphML Workflow.');
}
} catch (err) {
toast.error('Could not parse the SVG file.');
}
};
if (fileHandle) fr.readAsText(await fileHandle.getFile());
else fr.readAsText(file);
} else if (ext === 'jpg' || ext === 'jpeg') {
fr.onload = (x) => {
try {
const buffer = new Uint8Array(x.target.result);
let pos = 2; // skip SOI
let graphMLData = '';
while (pos < buffer.length) {
if (buffer[pos] !== 0xFF) break;
const marker = buffer[pos + 1];
if (marker === 0xDA) break; // SOS - Start of Scan
const len = (buffer[pos + 2] * 256) + buffer[pos + 3];
if (marker === 0xFE) { // COM Comment segment
graphMLData += new TextDecoder().decode(buffer.slice(pos + 4, pos + 2 + len));
}
pos += 2 + len;
}

Copilot AI Mar 28, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JPEG embedded GraphML parsing concatenates TextDecoder().decode(...) results per COM segment. Since the exporter splits the UTF-8 byte stream into 65,533-byte chunks, it can split a multi-byte UTF-8 sequence across segments; decoding each segment independently can corrupt characters (U+FFFD) and make the reconstructed GraphML invalid. Consider collecting COM segment bytes first and decoding once, or use TextDecoder.decode(..., { stream: true }) across segments before a final flush.

Suggested change
let graphMLData = '';
while (pos < buffer.length) {
if (buffer[pos] !== 0xFF) break;
const marker = buffer[pos + 1];
if (marker === 0xDA) break; // SOS - Start of Scan
const len = (buffer[pos + 2] * 256) + buffer[pos + 3];
if (marker === 0xFE) { // COM Comment segment
graphMLData += new TextDecoder().decode(buffer.slice(pos + 4, pos + 2 + len));
}
pos += 2 + len;
}
const comSegments = [];
while (pos < buffer.length) {
if (buffer[pos] !== 0xFF) break;
const marker = buffer[pos + 1];
if (marker === 0xDA) break; // SOS - Start of Scan
const len = (buffer[pos + 2] * 256) + buffer[pos + 3];
if (marker === 0xFE) { // COM Comment segment
comSegments.push(buffer.slice(pos + 4, pos + 2 + len));
}
pos += 2 + len;
}
let graphMLData = '';
if (comSegments.length > 0) {
const totalLength = comSegments.reduce((sum, seg) => sum + seg.length, 0);
const allBytes = new Uint8Array(totalLength);
let offset = 0;
for (const seg of comSegments) {
allBytes.set(seg, offset);
offset += seg.length;
}
graphMLData = new TextDecoder().decode(allBytes);
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed with second follow-up commit!

if (graphMLData) {
parser(graphMLData).then(({ authorName }) => {
setState({
type: T.ADD_GRAPH,
payload: {
projectName,
graphML: graphMLData,
fileHandle: null,
fileName: file.name,
authorName,
},
});
toast.success('Imported embedded GraphML from JPEG!');
}).catch(() => toast.error('Embedded GraphML inside JPEG is invalid.'));
} else {
toast.error('This JPEG does not contain an embedded GraphML Workflow.');
}
} catch (err) {
toast.error('Could not parse the JPEG file.');
}
};
if (fileHandle) fr.readAsArrayBuffer(await fileHandle.getFile());
else fr.readAsArrayBuffer(file);
}
}
};
Expand Down
4 changes: 4 additions & 0 deletions src/toolbarActions/toolbarList.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,11 @@ const toolbarList = (state, dispatcher) => [
icon: FaDownload,
action: (s, d) => [
{ fn: () => downloadImg(s, d, 'JPG'), name: 'JPG' },
{ fn: () => downloadImg(s, d, 'JPG-EMBEDDED'), name: 'JPG (with GraphML)' },
{ fn: () => downloadImg(s, d, 'PNG'), name: 'PNG' },
{ fn: () => downloadImg(s, d, 'PNG-EMBEDDED'), name: 'PNG (with GraphML)' },
{ fn: () => downloadImg(s, d, 'SVG'), name: 'SVG' },
{ fn: () => downloadImg(s, d, 'SVG-EMBEDDED'), name: 'SVG (with GraphML)' },
{ fn: () => saveAsJson(s, d), name: 'JSON' },
Comment thread
GREENRAT-K405 marked this conversation as resolved.
],
visibility: true,
Expand Down
Loading