diff --git a/package-lock.json b/package-lock.json index 543af29..10ff670 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,9 @@ "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", "prismjs": "^1.30.0", "process": "^0.11.10", "rc-slider": "^9.7.2", @@ -6935,6 +6938,15 @@ "typescript": ">=3" } }, + "node_modules/crc-32": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-0.3.0.tgz", + "integrity": "sha512-kucVIjOmMc1f0tv53BJ/5WIX+MGLcKuoBhnGqQrgKJNqLByb/sVMWfW/Aw6hw0jgcqjJ2pi9E5y32zOIpaUlsA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -15055,6 +15067,31 @@ "node": ">=4" } }, + "node_modules/png-chunk-text": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunk-text/-/png-chunk-text-1.0.0.tgz", + "integrity": "sha512-DEROKU3SkkLGWNMzru3xPVgxyd48UGuMSZvioErCure6yhOc/pRH2ZV+SEn7nmaf7WNf3NdIpH+UTrRdKyq9Lw==", + "license": "MIT" + }, + "node_modules/png-chunks-encode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-encode/-/png-chunks-encode-1.0.0.tgz", + "integrity": "sha512-J1jcHgbQRsIIgx5wxW9UmCymV3wwn4qCCJl6KYgEU/yHCh/L2Mwq/nMOkRPtmV79TLxRZj5w3tH69pvygFkDqA==", + "license": "MIT", + "dependencies": { + "crc-32": "^0.3.0", + "sliced": "^1.0.1" + } + }, + "node_modules/png-chunks-extract": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-extract/-/png-chunks-extract-1.0.0.tgz", + "integrity": "sha512-ZiVwF5EJ0DNZyzAqld8BP1qyJBaGOFaq9zl579qfbkcmOwWLLO4I9L8i2O4j3HkI6/35i0nKG2n+dZplxiT89Q==", + "license": "MIT", + "dependencies": { + "crc-32": "^0.3.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -18674,6 +18711,12 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/sliced": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz", + "integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==", + "license": "MIT" + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", diff --git a/package.json b/package.json index 55462af..3627c88 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,9 @@ "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", "prismjs": "^1.30.0", "process": "^0.11.10", "rc-slider": "^9.7.2", diff --git a/src/component/File-drag-drop.jsx b/src/component/File-drag-drop.jsx index bde6938..a629e84 100644 --- a/src/component/File-drag-drop.jsx +++ b/src/component/File-drag-drop.jsx @@ -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', 'jpg', 'jpeg']; + if (e.dataTransfer.files.length === 1 && allowed.includes(ext)) { readFile(superStateRef.current, dispatcherRef.current, droppedFile); } }; @@ -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,.jpg,.jpeg" onChange={(e) => readFile(superState, dispatcher, e.target.files[0])} /> diff --git a/src/component/fileBrowser.jsx b/src/component/fileBrowser.jsx index af8da77..1ab72c1 100644 --- a/src/component/fileBrowser.jsx +++ b/src/component/fileBrowser.jsx @@ -53,7 +53,7 @@ const LocalFileBrowser = ({ superState, dispatcher }) => { }, [superState.fileState]); const handleSelectFile = (data) => { - const fileExtensions = ['jpeg', 'jpg', 'png', 'exe']; + const fileExtensions = ['exe']; const fileExt = data.fileObj.name.split('.').pop().toLowerCase(); if (fileExtensions.includes(fileExt)) { // eslint-disable-next-line no-alert @@ -61,7 +61,8 @@ const LocalFileBrowser = ({ superState, dispatcher }) => { return; } - if (fileExt === 'graphml' || fileExt === 'json') { + const allowedExts = ['graphml', 'json', 'png', 'jpg', 'jpeg']; + if (allowedExts.includes(fileExt)) { let foundi = -1; superState.graphs.forEach((g, i) => { if ((g.fileName === data.fileObj.name)) { @@ -140,6 +141,8 @@ const LocalFileBrowser = ({ superState, dispatcher }) => { accept: { 'text/graphml': ['.graphml'], 'application/json': ['.json'], + 'image/png': ['.png'], + 'image/jpeg': ['.jpg', '.jpeg'], }, }, ], @@ -207,7 +210,7 @@ const LocalFileBrowser = ({ superState, dispatcher }) => { ref={fileRef} onClick={(e) => { e.target.value = null; }} style={{ display: 'none' }} - accept=".graphml,.json" + accept=".graphml,.json,.png,.jpg,.jpeg" onChange={(e) => readFile(superState, dispatcher, e.target.files[0])} /> )} diff --git a/src/graph-builder/graph-core/5-load-save.js b/src/graph-builder/graph-core/5-load-save.js index da74be7..078d9fb 100644 --- a/src/graph-builder/graph-core/5-load-save.js +++ b/src/graph-builder/graph-core/5-load-save.js @@ -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'; @@ -30,8 +33,64 @@ 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 binaryString = window.atob(b64Uri.split(',')[1]); + const buffer = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i += 1) { + buffer[i] = binaryString.charCodeAt(i); + } + const chunks = extractChunks(buffer); + chunks.splice(-1, 0, textChunk.encode('graphml', this.getGraphML())); + const newBuffer = new Uint8Array(encodeChunks(chunks)); + const blob = new Blob([newBuffer], { type: 'image/png' }); + saveAs(blob, `${this.getName()}.graphml.png`); + return; + } + if (format === 'JPG-EMBEDDED') { + const b64Uri = this.cy.jpg({ full: true }); + const binaryString = window.atob(b64Uri.split(',')[1]); + const buffer = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i += 1) { + buffer[i] = binaryString.charCodeAt(i); + } + const graphMLStr = this.getGraphML(); + 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); + 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) { diff --git a/src/toolbarActions/toolbarFunctions.js b/src/toolbarActions/toolbarFunctions.js index 9dda574..134f70a 100644 --- a/src/toolbarActions/toolbarFunctions.js +++ b/src/toolbarActions/toolbarFunctions.js @@ -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'; @@ -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 }) => { @@ -186,6 +188,88 @@ 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 === 'jpg' || ext === 'jpeg') { + fr.onload = (x) => { + try { + const buffer = new Uint8Array(x.target.result); + let pos = 2; // skip SOI + 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 (let i = 0; i < comSegments.length; i += 1) { + allBytes.set(comSegments[i], offset); + offset += comSegments[i].length; + } + graphMLData = new TextDecoder().decode(allBytes); + } + + 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); } } }; diff --git a/src/toolbarActions/toolbarList.js b/src/toolbarActions/toolbarList.js index 77ae50c..f9ff6c7 100644 --- a/src/toolbarActions/toolbarList.js +++ b/src/toolbarActions/toolbarList.js @@ -303,7 +303,9 @@ 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: () => saveAsJson(s, d), name: 'JSON' }, ], visibility: true,