Skip to content

Commit fb4646b

Browse files
committed
Add feature for embedded images
1 parent 23ee51b commit fb4646b

7 files changed

Lines changed: 244 additions & 6 deletions

File tree

package-lock.json

Lines changed: 53 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,17 @@
1717
"cytoscape-edgehandles": "^3.6.0",
1818
"cytoscape-grid-guide": "^2.3.2",
1919
"cytoscape-node-editing": "^4.0.0",
20+
"cytoscape-svg": "^0.4.0",
2021
"file-saver": "^2.0.5",
2122
"hotkeys-js": "^3.8.7",
2223
"jquery": "^3.0.0",
2324
"konva": "^7.0.3",
2425
"lucide-react": "^0.487.0",
2526
"md5": "^2.3.0",
2627
"moment": "^2.29.4",
28+
"png-chunk-text": "^1.0.0",
29+
"png-chunks-encode": "^1.0.0",
30+
"png-chunks-extract": "^1.0.0",
2731
"prismjs": "^1.30.0",
2832
"process": "^0.11.10",
2933
"rc-slider": "^9.7.2",

src/component/File-drag-drop.jsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ const app = ({ superState, dispatcher }) => {
4040
e.preventDefault();
4141
fileRef.current.value = null;
4242
const droppedFile = e.dataTransfer.files[0];
43-
const ext = droppedFile && droppedFile.name.split('.').slice(-1)[0];
44-
if (e.dataTransfer.files.length === 1 && (ext === 'graphml' || ext === 'json')) {
43+
const ext = droppedFile && droppedFile.name.split('.').slice(-1)[0]?.toLowerCase();
44+
const allowed = ['graphml', 'json', 'png', 'svg', 'jpg', 'jpeg'];
45+
if (e.dataTransfer.files.length === 1 && allowed.includes(ext)) {
4546
readFile(superStateRef.current, dispatcherRef.current, droppedFile);
4647
}
4748
};
@@ -70,7 +71,7 @@ const app = ({ superState, dispatcher }) => {
7071
ref={fileRef}
7172
onClick={(e) => { e.target.value = null; }}
7273
style={{ display: 'none' }}
73-
accept=".graphml,.json"
74+
accept=".graphml,.json,.png,.svg,.jpg,.jpeg"
7475
onChange={(e) => readFile(superState, dispatcher, e.target.files[0])}
7576
/>
7677
<span className="arrow">&#10230;</span>

src/graph-builder/graph-core/1-core.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import cytoscape from 'cytoscape';
22
import edgehandles from 'cytoscape-edgehandles';
33
import gridGuide from 'cytoscape-grid-guide';
4+
import svg from 'cytoscape-svg';
45
import Konva from 'konva';
56
import nodeEditing from 'cytoscape-node-editing';
67
import $ from 'jquery';
@@ -44,6 +45,9 @@ class CoreGraph {
4445
if (typeof cytoscape('core', 'gridGuide') !== 'function') {
4546
gridGuide(cytoscape);
4647
}
48+
if (typeof cytoscape('core', 'svg') !== 'function') {
49+
cytoscape.use(svg);
50+
}
4751
// if (cy) this.cy = cy;
4852
this.cy = cytoscape({ ...cyOptions(darkMode), container: element });
4953
this.cy.on('position', 'node', () => {

src/graph-builder/graph-core/5-load-save.js

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { saveAs } from 'file-saver';
22
import { toast } from 'react-toastify';
3+
import extractChunks from 'png-chunks-extract';
4+
import encodeChunks from 'png-chunks-encode';
5+
import textChunk from 'png-chunk-text';
36
import localStorageManager from '../local-storage-manager';
47
import graphmlBuilder from '../graphml/builder';
58
import BendingDistanceWeight from '../calculations/bending-dist-weight';
@@ -30,8 +33,75 @@ class GraphLoadSave extends GraphUndoRedo {
3033
downloadImg(format) {
3134
this.cy.emit('hide-bend');
3235
this.cy.$('.eh-handle').remove();
33-
if (format === 'PNG') saveAs(this.cy.png({ full: true }), `${this.getName()}-DHGWorkflow.png`);
34-
if (format === 'JPG') saveAs(this.cy.jpg({ full: true }), `${this.getName()}-DHGWorkflow.jpg`);
36+
if (format === 'JPG') {
37+
saveAs(this.cy.jpg({ full: true }), `${this.getName()}-DHGWorkflow.jpg`);
38+
return;
39+
}
40+
if (format === 'PNG') {
41+
saveAs(this.cy.png({ full: true }), `${this.getName()}-DHGWorkflow.png`);
42+
return;
43+
}
44+
if (format === 'PNG-EMBEDDED') {
45+
const b64Uri = this.cy.png({ full: true });
46+
const b64Data = b64Uri.split(',')[1];
47+
const buffer = new Uint8Array(window.atob(b64Data).split('').map((c) => c.charCodeAt(0)));
48+
const chunks = extractChunks(buffer);
49+
chunks.splice(-1, 0, textChunk.encode('graphml', this.getGraphML()));
50+
const newBuffer = new Uint8Array(encodeChunks(chunks));
51+
const blob = new Blob([newBuffer], { type: 'image/png' });
52+
saveAs(blob, `${this.getName()}.graphml.png`);
53+
return;
54+
}
55+
if (format === 'SVG') {
56+
const blob = new Blob([this.cy.svg({ full: true })], { type: 'image/svg+xml;charset=utf-8' });
57+
saveAs(blob, `${this.getName()}-DHGWorkflow.svg`);
58+
return;
59+
}
60+
if (format === 'SVG-EMBEDDED') {
61+
const svgStr = this.cy.svg({ full: true });
62+
const parser = new DOMParser();
63+
const doc = parser.parseFromString(svgStr, 'image/svg+xml');
64+
const metadata = doc.createElementNS('http://www.w3.org/2000/svg', 'metadata');
65+
metadata.setAttribute('data-graphml', this.getGraphML());
66+
doc.documentElement.insertBefore(metadata, doc.documentElement.firstChild);
67+
const newSvg = new XMLSerializer().serializeToString(doc);
68+
const blob = new Blob([newSvg], { type: 'image/svg+xml;charset=utf-8' });
69+
saveAs(blob, `${this.getName()}.graphml.svg`);
70+
return;
71+
}
72+
if (format === 'JPG-EMBEDDED') {
73+
const b64Uri = this.cy.jpg({ full: true });
74+
const b64Data = b64Uri.split(',')[1];
75+
const buffer = new Uint8Array(window.atob(b64Data).split('').map((c) => c.charCodeAt(0)));
76+
const graphMLStr = this.getGraphML();
77+
const graphMLBytes = new TextEncoder().encode(graphMLStr);
78+
79+
const comSegments = [];
80+
for (let i = 0; i < graphMLBytes.length; i += 65533) {
81+
const chunk = graphMLBytes.slice(i, i + 65533);
82+
const segmentLen = chunk.length + 2;
83+
const seg = new Uint8Array(4 + chunk.length);
84+
seg[0] = 0xFF;
85+
seg[1] = 0xFE;
86+
seg[2] = Math.floor(segmentLen / 256);
87+
seg[3] = segmentLen % 256;
88+
seg.set(chunk, 4);
89+
comSegments.push(seg);
90+
}
91+
92+
const totalComSize = comSegments.reduce((sum, seg) => sum + seg.length, 0);
93+
const newBuffer = new Uint8Array(buffer.length + totalComSize);
94+
newBuffer.set(buffer.slice(0, 2), 0); // FF D8
95+
let offset = 2;
96+
comSegments.forEach((seg) => {
97+
newBuffer.set(seg, offset);
98+
offset += seg.length;
99+
});
100+
newBuffer.set(buffer.slice(2), offset);
101+
102+
const blob = new Blob([newBuffer], { type: 'image/jpeg' });
103+
saveAs(blob, `${this.getName()}.graphml.jpg`);
104+
}
35105
}
36106

37107
shouldNodeBeSaved(nodeID) {

src/toolbarActions/toolbarFunctions.js

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { saveAs } from 'file-saver';
22
import { toast } from 'react-toastify';
3+
import extractChunks from 'png-chunks-extract';
4+
import textChunk from 'png-chunk-text';
35
import parser from '../graph-builder/graphml/parser';
46
import { actionType as T } from '../reducer';
57

@@ -150,7 +152,7 @@ const readFile = async (state, setState, file, fileHandle) => {
150152
}
151153
const fr = new FileReader();
152154
const projectName = file.name;
153-
const ext = file.name.split('.').pop();
155+
const ext = file.name.split('.').pop()?.toLowerCase();
154156
if (ext === 'graphml') {
155157
fr.onload = (x) => {
156158
parser(x.target.result).then(({ authorName }) => {
@@ -186,6 +188,106 @@ const readFile = async (state, setState, file, fileHandle) => {
186188
}
187189
};
188190
fr.readAsText(file);
191+
} else if (ext === 'png') {
192+
fr.onload = (x) => {
193+
try {
194+
const buffer = new Uint8Array(x.target.result);
195+
const chunks = extractChunks(buffer);
196+
const textChunks = chunks.filter((c) => c.name === 'tEXt').map((c) => textChunk.decode(c));
197+
const graphMLMeta = textChunks.find((c) => c.keyword === 'graphml');
198+
if (graphMLMeta && graphMLMeta.text) {
199+
parser(graphMLMeta.text).then(({ authorName }) => {
200+
setState({
201+
type: T.ADD_GRAPH,
202+
payload: {
203+
projectName,
204+
graphML: graphMLMeta.text,
205+
fileHandle: null,
206+
fileName: file.name,
207+
authorName,
208+
},
209+
});
210+
toast.success('Imported embedded GraphML from Image!');
211+
}).catch(() => toast.error('Embedded GraphML inside PNG is invalid.'));
212+
} else {
213+
toast.error('This PNG does not contain an embedded GraphML Workflow.');
214+
}
215+
} catch (err) {
216+
toast.error('Could not parse the PNG file.');
217+
}
218+
};
219+
if (fileHandle) fr.readAsArrayBuffer(await fileHandle.getFile());
220+
else fr.readAsArrayBuffer(file);
221+
} else if (ext === 'svg') {
222+
fr.onload = (x) => {
223+
try {
224+
const parserDOM = new DOMParser();
225+
const svgDoc = parserDOM.parseFromString(x.target.result, 'image/svg+xml');
226+
const metadata = svgDoc.getElementsByTagName('metadata')[0];
227+
const graphML = metadata ? metadata.getAttribute('data-graphml') : null;
228+
if (graphML) {
229+
parser(graphML).then(({ authorName }) => {
230+
setState({
231+
type: T.ADD_GRAPH,
232+
payload: {
233+
projectName,
234+
graphML,
235+
fileHandle: null,
236+
fileName: file.name,
237+
authorName,
238+
},
239+
});
240+
toast.success('Imported embedded GraphML from SVG!');
241+
}).catch(() => toast.error('Embedded GraphML inside SVG is invalid.'));
242+
} else {
243+
toast.error('This SVG does not contain an embedded GraphML Workflow.');
244+
}
245+
} catch (err) {
246+
toast.error('Could not parse the SVG file.');
247+
}
248+
};
249+
if (fileHandle) fr.readAsText(await fileHandle.getFile());
250+
else fr.readAsText(file);
251+
} else if (ext === 'jpg' || ext === 'jpeg') {
252+
fr.onload = (x) => {
253+
try {
254+
const buffer = new Uint8Array(x.target.result);
255+
let pos = 2; // skip SOI
256+
let graphMLData = '';
257+
while (pos < buffer.length) {
258+
if (buffer[pos] !== 0xFF) break;
259+
const marker = buffer[pos + 1];
260+
if (marker === 0xDA) break; // SOS - Start of Scan
261+
const len = (buffer[pos + 2] * 256) + buffer[pos + 3];
262+
if (marker === 0xFE) { // COM Comment segment
263+
graphMLData += new TextDecoder().decode(buffer.slice(pos + 4, pos + 2 + len));
264+
}
265+
pos += 2 + len;
266+
}
267+
268+
if (graphMLData) {
269+
parser(graphMLData).then(({ authorName }) => {
270+
setState({
271+
type: T.ADD_GRAPH,
272+
payload: {
273+
projectName,
274+
graphML: graphMLData,
275+
fileHandle: null,
276+
fileName: file.name,
277+
authorName,
278+
},
279+
});
280+
toast.success('Imported embedded GraphML from JPEG!');
281+
}).catch(() => toast.error('Embedded GraphML inside JPEG is invalid.'));
282+
} else {
283+
toast.error('This JPEG does not contain an embedded GraphML Workflow.');
284+
}
285+
} catch (err) {
286+
toast.error('Could not parse the JPEG file.');
287+
}
288+
};
289+
if (fileHandle) fr.readAsArrayBuffer(await fileHandle.getFile());
290+
else fr.readAsArrayBuffer(file);
189291
}
190292
}
191293
};

src/toolbarActions/toolbarList.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,11 @@ const toolbarList = (state, dispatcher) => [
303303
icon: FaDownload,
304304
action: (s, d) => [
305305
{ fn: () => downloadImg(s, d, 'JPG'), name: 'JPG' },
306+
{ fn: () => downloadImg(s, d, 'JPG-EMBEDDED'), name: 'JPG (with GraphML)' },
306307
{ fn: () => downloadImg(s, d, 'PNG'), name: 'PNG' },
308+
{ fn: () => downloadImg(s, d, 'PNG-EMBEDDED'), name: 'PNG (with GraphML)' },
309+
{ fn: () => downloadImg(s, d, 'SVG'), name: 'SVG' },
310+
{ fn: () => downloadImg(s, d, 'SVG-EMBEDDED'), name: 'SVG (with GraphML)' },
307311
{ fn: () => saveAsJson(s, d), name: 'JSON' },
308312
],
309313
visibility: true,

0 commit comments

Comments
 (0)