diff --git a/modules/JPEGExporter/README.md b/modules/JPEGExporter/README.md new file mode 100644 index 000000000..e37669894 --- /dev/null +++ b/modules/JPEGExporter/README.md @@ -0,0 +1,7 @@ +## JPEG Exporter + +Adds a Gephi image exporter for `.jpg`/`.jpeg` with configurable: + +- Width (px) +- Height (px) +- DPI metadata diff --git a/modules/JPEGExporter/pom.xml b/modules/JPEGExporter/pom.xml new file mode 100644 index 000000000..82bd33017 --- /dev/null +++ b/modules/JPEGExporter/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + gephi-plugin-parent + org.gephi + 0.10.0 + + + my.self + jpegexporter + 0.0.1 + nbm + + JPEG Exporter + + + + org.gephi + io-exporter-api + + + org.gephi + preview-api + + + org.gephi + project-api + + + org.gephi + utils-longtask + + + org.netbeans.api + org-openide-util + + + org.netbeans.api + org-openide-util-lookup + + + junit + junit + 4.13.2 + test + + + + + + + org.apache.netbeans.utilities + nbm-maven-plugin + + Apache 2.0 + Elio Moreau + elio.moreau@ens.psl.eu + + https://github.com/eliomor01/gephi-plugins.git + + + + + + + + + + + oss-sonatype + oss-sonatype + https://oss.sonatype.org/content/repositories/snapshots/ + + true + + + + diff --git a/modules/JPEGExporter/src/main/java/org/gephi/plugins/jpegexporter/ExporterBuilderJPEG.java b/modules/JPEGExporter/src/main/java/org/gephi/plugins/jpegexporter/ExporterBuilderJPEG.java new file mode 100644 index 000000000..329a3dd26 --- /dev/null +++ b/modules/JPEGExporter/src/main/java/org/gephi/plugins/jpegexporter/ExporterBuilderJPEG.java @@ -0,0 +1,29 @@ +package org.gephi.plugins.jpegexporter; + +import org.gephi.io.exporter.api.FileType; +import org.gephi.io.exporter.spi.VectorExporter; +import org.gephi.io.exporter.spi.VectorFileExporterBuilder; +import org.openide.util.NbBundle; +import org.openide.util.lookup.ServiceProvider; + +@ServiceProvider(service = VectorFileExporterBuilder.class) +public class ExporterBuilderJPEG implements VectorFileExporterBuilder { + + @Override + public VectorExporter buildExporter() { + return new JPEGExporter(); + } + + @Override + public FileType[] getFileTypes() { + return new FileType[]{ + new FileType(".jpg", NbBundle.getMessage(ExporterBuilderJPEG.class, "fileType_JPG_Name")), + new FileType(".jpeg", NbBundle.getMessage(ExporterBuilderJPEG.class, "fileType_JPEG_Name")) + }; + } + + @Override + public String getName() { + return "jpeg"; + } +} diff --git a/modules/JPEGExporter/src/main/java/org/gephi/plugins/jpegexporter/JPEGExporter.java b/modules/JPEGExporter/src/main/java/org/gephi/plugins/jpegexporter/JPEGExporter.java new file mode 100644 index 000000000..b0bdf0eab --- /dev/null +++ b/modules/JPEGExporter/src/main/java/org/gephi/plugins/jpegexporter/JPEGExporter.java @@ -0,0 +1,228 @@ +package org.gephi.plugins.jpegexporter; + +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Iterator; +import javax.imageio.IIOImage; +import javax.imageio.ImageIO; +import javax.imageio.ImageTypeSpecifier; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.metadata.IIOMetadata; +import javax.imageio.metadata.IIOMetadataNode; +import javax.imageio.stream.ImageOutputStream; +import org.gephi.io.exporter.spi.ByteExporter; +import org.gephi.io.exporter.spi.VectorExporter; +import org.gephi.preview.api.G2DTarget; +import org.gephi.preview.api.PreviewController; +import org.gephi.preview.api.PreviewModel; +import org.gephi.preview.api.PreviewProperties; +import org.gephi.preview.api.PreviewProperty; +import org.gephi.preview.api.RenderTarget; +import org.gephi.project.api.Workspace; +import org.gephi.utils.longtask.spi.LongTask; +import org.gephi.utils.progress.Progress; +import org.gephi.utils.progress.ProgressTicket; +import org.openide.util.Lookup; + +public class JPEGExporter implements VectorExporter, ByteExporter, LongTask { + + private static final float MM_PER_INCH = 25.4f; + private static final float CM_PER_INCH = 2.54f; + private static final float DEFAULT_WIDTH_CM = 16.0f; + private static final float DEFAULT_HEIGHT_CM = 9.0f; + private static final int DEFAULT_DPI = 300; + + private ProgressTicket progress; + private boolean cancel; + private Workspace workspace; + private OutputStream stream; + private float widthCm = DEFAULT_WIDTH_CM; + private float heightCm = DEFAULT_HEIGHT_CM; + private int dpi = DEFAULT_DPI; + private int margin = 4; + private G2DTarget target; + + @Override + public boolean execute() { + Progress.start(progress); + + PreviewController controller = Lookup.getDefault().lookup(PreviewController.class); + PreviewModel model = controller.getModel(workspace); + + setExportProperties(model); + controller.refreshPreview(workspace); + + target = (G2DTarget) controller.getRenderTarget(RenderTarget.G2D_TARGET, workspace); + if (target instanceof LongTask) { + ((LongTask) target).setProgressTicket(progress); + } + + try { + target.refresh(); + Progress.switchToIndeterminate(progress); + + int widthPx = cmToPixels(widthCm, dpi); + int heightPx = cmToPixels(heightCm, dpi); + + Image sourceImg = target.getImage(); + BufferedImage img = new BufferedImage(widthPx, heightPx, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = img.createGraphics(); + graphics.setColor(Color.WHITE); + graphics.fillRect(0, 0, widthPx, heightPx); + graphics.drawImage(sourceImg, 0, 0, null); + graphics.dispose(); + + writeJpegWithDpi(img, stream, dpi); + stream.close(); + } catch (Exception ex) { + throw new RuntimeException(ex); + } finally { + discardExportProperties(model); + Progress.finish(progress); + } + + return !cancel; + } + + @Override + public Workspace getWorkspace() { + return workspace; + } + + @Override + public void setWorkspace(Workspace workspace) { + this.workspace = workspace; + } + + @Override + public void setOutputStream(OutputStream stream) { + this.stream = stream; + } + + @Override + public boolean cancel() { + cancel = true; + if (target instanceof LongTask) { + ((LongTask) target).cancel(); + } + return true; + } + + @Override + public void setProgressTicket(ProgressTicket progressTicket) { + this.progress = progressTicket; + } + + public float getWidthCm() { + return widthCm; + } + + public void setWidthCm(float widthCm) { + this.widthCm = widthCm; + } + + public float getHeightCm() { + return heightCm; + } + + public void setHeightCm(float heightCm) { + this.heightCm = heightCm; + } + + public int getDpi() { + return dpi; + } + + public void setDpi(int dpi) { + this.dpi = dpi; + } + + private synchronized void setExportProperties(PreviewModel model) { + PreviewProperties props = model.getProperties(); + props.putValue(PreviewProperty.VISIBILITY_RATIO, 1.0F); + int widthPx = cmToPixels(widthCm, dpi); + int heightPx = cmToPixels(heightCm, dpi); + props.putValue("width", widthPx); + props.putValue("height", heightPx); + props.putValue(PreviewProperty.MARGIN, (float) margin); + } + + private synchronized void discardExportProperties(PreviewModel model) { + PreviewProperties props = model.getProperties(); + props.removeSimpleValue("width"); + props.removeSimpleValue("height"); + props.removeSimpleValue(PreviewProperty.MARGIN); + } + + static void writeJpegWithDpi(BufferedImage image, OutputStream output, int dpiValue) throws IOException { + Iterator writers = ImageIO.getImageWritersByFormatName("jpeg"); + if (!writers.hasNext()) { + throw new IOException("No JPEG ImageWriter available"); + } + + ImageWriter writer = writers.next(); + ImageWriteParam writeParam = writer.getDefaultWriteParam(); + if (writeParam.canWriteCompressed()) { + writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + writeParam.setCompressionQuality(0.95f); + } + + ImageTypeSpecifier imageTypeSpecifier = ImageTypeSpecifier.createFromBufferedImageType(BufferedImage.TYPE_INT_RGB); + IIOMetadata metadata = writer.getDefaultImageMetadata(imageTypeSpecifier, writeParam); + setDpiMetadata(metadata, dpiValue); + + try (ImageOutputStream imageOutputStream = ImageIO.createImageOutputStream(output)) { + writer.setOutput(imageOutputStream); + writer.write(null, new IIOImage(image, null, metadata), writeParam); + imageOutputStream.flush(); + } finally { + writer.dispose(); + } + } + + static int cmToPixels(float cm, int dpi) { + return Math.max(1, Math.round((cm / CM_PER_INCH) * dpi)); + } + + static void setDpiMetadata(IIOMetadata metadata, int dpiValue) { + if (metadata == null) { + return; + } + + if (metadata.isStandardMetadataFormatSupported()) { + IIOMetadataNode root = (IIOMetadataNode) metadata.getAsTree("javax_imageio_1.0"); + IIOMetadataNode dimension = getOrCreateChild(root, "Dimension"); + float pixelsPerMM = MM_PER_INCH / dpiValue; + + IIOMetadataNode horizontal = getOrCreateChild(dimension, "HorizontalPixelSize"); + horizontal.setAttribute("value", Float.toString(pixelsPerMM)); + + IIOMetadataNode vertical = getOrCreateChild(dimension, "VerticalPixelSize"); + vertical.setAttribute("value", Float.toString(pixelsPerMM)); + + try { + metadata.mergeTree("javax_imageio_1.0", root); + } catch (Exception ignored) { + } + } + + // Avoid mutating native JPEG tree (`javax_imageio_jpeg_image_1.0`) as it can + // break marker sequencing in some writers and produce unreadable JPEG files. + } + + private static IIOMetadataNode getOrCreateChild(IIOMetadataNode parent, String name) { + for (int i = 0; i < parent.getLength(); i++) { + if (name.equals(parent.item(i).getNodeName())) { + return (IIOMetadataNode) parent.item(i); + } + } + IIOMetadataNode child = new IIOMetadataNode(name); + parent.appendChild(child); + return child; + } +} diff --git a/modules/JPEGExporter/src/main/java/org/gephi/plugins/jpegexporter/UIExporterJPEG.java b/modules/JPEGExporter/src/main/java/org/gephi/plugins/jpegexporter/UIExporterJPEG.java new file mode 100644 index 000000000..51f7a553c --- /dev/null +++ b/modules/JPEGExporter/src/main/java/org/gephi/plugins/jpegexporter/UIExporterJPEG.java @@ -0,0 +1,61 @@ +package org.gephi.plugins.jpegexporter; + +import javax.swing.JPanel; +import org.gephi.io.exporter.spi.Exporter; +import org.gephi.io.exporter.spi.ExporterUI; +import org.openide.util.NbBundle; +import org.openide.util.NbPreferences; +import org.openide.util.lookup.ServiceProvider; + +@ServiceProvider(service = ExporterUI.class) +public class UIExporterJPEG implements ExporterUI { + + private static final String PREF_WIDTH = "JPEG_width"; + private static final String PREF_HEIGHT = "JPEG_height"; + private static final String PREF_DPI = "JPEG_dpi"; + private static final JPEGExporter DEFAULTS = new JPEGExporter(); + + private UIExporterJPEGPanel panel; + private JPEGExporter exporter; + + @Override + public JPanel getPanel() { + panel = new UIExporterJPEGPanel(); + return panel; + } + + @Override + public void setup(Exporter exporter) { + this.exporter = (JPEGExporter) exporter; + this.exporter + .setWidthCm(NbPreferences.forModule(UIExporterJPEG.class).getFloat(PREF_WIDTH, DEFAULTS.getWidthCm())); + this.exporter + .setHeightCm(NbPreferences.forModule(UIExporterJPEG.class).getFloat(PREF_HEIGHT, DEFAULTS.getHeightCm())); + this.exporter.setDpi(NbPreferences.forModule(UIExporterJPEG.class).getInt(PREF_DPI, DEFAULTS.getDpi())); + if (panel != null) { + panel.setup(this.exporter); + } + } + + @Override + public void unsetup(boolean update) { + if (update && panel != null && exporter != null) { + panel.unsetup(exporter); + NbPreferences.forModule(UIExporterJPEG.class).putFloat(PREF_WIDTH, exporter.getWidthCm()); + NbPreferences.forModule(UIExporterJPEG.class).putFloat(PREF_HEIGHT, exporter.getHeightCm()); + NbPreferences.forModule(UIExporterJPEG.class).putInt(PREF_DPI, exporter.getDpi()); + } + panel = null; + exporter = null; + } + + @Override + public boolean isUIForExporter(Exporter exporter) { + return exporter instanceof JPEGExporter; + } + + @Override + public String getDisplayName() { + return NbBundle.getMessage(UIExporterJPEG.class, "UIExporterJPEG.name"); + } +} diff --git a/modules/JPEGExporter/src/main/java/org/gephi/plugins/jpegexporter/UIExporterJPEGPanel.java b/modules/JPEGExporter/src/main/java/org/gephi/plugins/jpegexporter/UIExporterJPEGPanel.java new file mode 100644 index 000000000..475b6d463 --- /dev/null +++ b/modules/JPEGExporter/src/main/java/org/gephi/plugins/jpegexporter/UIExporterJPEGPanel.java @@ -0,0 +1,75 @@ +package org.gephi.plugins.jpegexporter; + +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import javax.swing.JFormattedTextField; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSpinner; +import javax.swing.SpinnerNumberModel; +import org.openide.util.NbBundle; + +public class UIExporterJPEGPanel extends JPanel { + + private final JSpinner widthSpinner = new JSpinner(new SpinnerNumberModel(16.0d, 0.1d, 1000.0d, 0.1d)); + private final JSpinner heightSpinner = new JSpinner(new SpinnerNumberModel(9.0d, 0.1d, 1000.0d, 0.1d)); + private final JSpinner dpiSpinner = new JSpinner(new SpinnerNumberModel(300, 1, 2400, 1)); + + public UIExporterJPEGPanel() { + initComponents(); + JFormattedTextField widthField = ((JSpinner.NumberEditor) widthSpinner.getEditor()).getTextField(); + widthField.setColumns(8); + JFormattedTextField heightField = ((JSpinner.NumberEditor) heightSpinner.getEditor()).getTextField(); + heightField.setColumns(8); + } + + public void setup(JPEGExporter exporter) { + widthSpinner.setValue((double) exporter.getWidthCm()); + heightSpinner.setValue((double) exporter.getHeightCm()); + dpiSpinner.setValue(exporter.getDpi()); + } + + public void unsetup(JPEGExporter exporter) { + exporter.setWidthCm(((Double) widthSpinner.getValue()).floatValue()); + exporter.setHeightCm(((Double) heightSpinner.getValue()).floatValue()); + exporter.setDpi((Integer) dpiSpinner.getValue()); + } + + private void initComponents() { + setLayout(new GridBagLayout()); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 6, 4, 6); + gbc.anchor = GridBagConstraints.WEST; + + gbc.gridx = 0; + gbc.gridy = 0; + add(new JLabel(NbBundle.getMessage(UIExporterJPEGPanel.class, "UIExporterJPEGPanel.widthLabel.text")), gbc); + + gbc.gridx = 1; + add(widthSpinner, gbc); + + gbc.gridx = 2; + add(new JLabel("cm"), gbc); + + gbc.gridx = 0; + gbc.gridy = 1; + add(new JLabel(NbBundle.getMessage(UIExporterJPEGPanel.class, "UIExporterJPEGPanel.heightLabel.text")), gbc); + + gbc.gridx = 1; + add(heightSpinner, gbc); + + gbc.gridx = 2; + add(new JLabel("cm"), gbc); + + gbc.gridx = 0; + gbc.gridy = 2; + add(new JLabel(NbBundle.getMessage(UIExporterJPEGPanel.class, "UIExporterJPEGPanel.dpiLabel.text")), gbc); + + gbc.gridx = 1; + add(dpiSpinner, gbc); + + gbc.gridx = 2; + add(new JLabel("dpi"), gbc); + } +} diff --git a/modules/JPEGExporter/src/main/nbm/manifest.mf b/modules/JPEGExporter/src/main/nbm/manifest.mf new file mode 100644 index 000000000..e3312361e --- /dev/null +++ b/modules/JPEGExporter/src/main/nbm/manifest.mf @@ -0,0 +1,5 @@ +Manifest-Version: 1.0 +OpenIDE-Module-Name: JPEG Exporter +OpenIDE-Module-Short-Description: Plugin to export in jpeg format +OpenIDE-Module-Long-Description: Plugin to export in jpeg format +OpenIDE-Module-Display-Category: Export diff --git a/modules/JPEGExporter/src/main/resources/org/gephi/plugins/jpegexporter/Bundle.properties b/modules/JPEGExporter/src/main/resources/org/gephi/plugins/jpegexporter/Bundle.properties new file mode 100644 index 000000000..22dcea4b5 --- /dev/null +++ b/modules/JPEGExporter/src/main/resources/org/gephi/plugins/jpegexporter/Bundle.properties @@ -0,0 +1,10 @@ +OpenIDE-Module-Long-Description=Plugin to export preview graphs to JPEG with configurable dimensions and DPI +OpenIDE-Module-Short-Description=JPEG exporter with dimensions and DPI controls + +fileType_JPG_Name=JPEG Files (*.jpg) +fileType_JPEG_Name=JPEG Files (*.jpeg) +UIExporterJPEG.name=JPEG + +UIExporterJPEGPanel.widthLabel.text=Width (cm): +UIExporterJPEGPanel.heightLabel.text=Height (cm): +UIExporterJPEGPanel.dpiLabel.text=DPI: diff --git a/modules/JPEGExporter/src/test/java/org/gephi/plugins/jpegexporter/JPEGExporterTest.java b/modules/JPEGExporter/src/test/java/org/gephi/plugins/jpegexporter/JPEGExporterTest.java new file mode 100644 index 000000000..c410055f6 --- /dev/null +++ b/modules/JPEGExporter/src/test/java/org/gephi/plugins/jpegexporter/JPEGExporterTest.java @@ -0,0 +1,82 @@ +package org.gephi.plugins.jpegexporter; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.Iterator; +import javax.imageio.ImageIO; +import javax.imageio.ImageTypeSpecifier; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; +import javax.imageio.metadata.IIOMetadata; +import javax.imageio.metadata.IIOMetadataNode; +import org.junit.Assert; +import org.junit.Test; +import org.w3c.dom.Node; + +public class JPEGExporterTest { + + @Test + public void testCmToPixels() { + Assert.assertEquals(300, JPEGExporter.cmToPixels(2.54f, 300)); + Assert.assertEquals(2480, JPEGExporter.cmToPixels(21.0f, 300)); + Assert.assertEquals(1, JPEGExporter.cmToPixels(0.001f, 72)); + } + + @Test + public void testWriteJpegWithDpiProducesReadableJpeg() throws Exception { + BufferedImage source = new BufferedImage(320, 240, BufferedImage.TYPE_INT_RGB); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + JPEGExporter.writeJpegWithDpi(source, outputStream, 300); + + byte[] bytes = outputStream.toByteArray(); + Assert.assertTrue(bytes.length > 0); + BufferedImage parsed = ImageIO.read(new ByteArrayInputStream(bytes)); + Assert.assertNotNull("Written JPEG should be readable", parsed); + Assert.assertEquals(320, parsed.getWidth()); + Assert.assertEquals(240, parsed.getHeight()); + } + + @Test + public void testSetDpiMetadataWritesStandardDimension() throws Exception { + IIOMetadata metadata = createJpegMetadata(); + Assert.assertTrue(metadata.isStandardMetadataFormatSupported()); + + JPEGExporter.setDpiMetadata(metadata, 300); + + IIOMetadataNode root = (IIOMetadataNode) metadata.getAsTree("javax_imageio_1.0"); + IIOMetadataNode dimension = findChild(root, "Dimension"); + Assert.assertNotNull(dimension); + IIOMetadataNode horizontal = findChild(dimension, "HorizontalPixelSize"); + IIOMetadataNode vertical = findChild(dimension, "VerticalPixelSize"); + Assert.assertNotNull(horizontal); + Assert.assertNotNull(vertical); + Assert.assertTrue(Float.parseFloat(horizontal.getAttribute("value")) > 0f); + Assert.assertTrue(Float.parseFloat(vertical.getAttribute("value")) > 0f); + } + + private IIOMetadata createJpegMetadata() throws Exception { + Iterator writers = ImageIO.getImageWritersByFormatName("jpeg"); + Assert.assertTrue("No JPEG writer found", writers.hasNext()); + ImageWriter writer = writers.next(); + try { + ImageWriteParam writeParam = writer.getDefaultWriteParam(); + return writer.getDefaultImageMetadata( + ImageTypeSpecifier.createFromBufferedImageType(BufferedImage.TYPE_INT_RGB), + writeParam + ); + } finally { + writer.dispose(); + } + } + + private IIOMetadataNode findChild(IIOMetadataNode parent, String name) { + for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) { + if (name.equals(node.getNodeName())) { + return (IIOMetadataNode) node; + } + } + return null; + } +} diff --git a/pom.xml b/pom.xml index 1ca1569b0..d6d9f5b1a 100644 --- a/pom.xml +++ b/pom.xml @@ -12,74 +12,7 @@ - modules/GeoLayout - modules/KBraceFilter - modules/LinkfluencePlugin - modules/Multimode-Networks - modules/JsonExporter - modules/SigmaExporter - modules/NodeColorManager - modules/GraphvizLayout - modules/GraphStreaming - modules/DesktopStreaming - modules/StreamingAPI - modules/StreamingImpl - modules/JettyWrapper - modules/StreamingServer - modules/CircularLayout - - modules/loxawebsiteexport - modules/Export-To-Earth - modules/MapOfCountries - modules/ScriptingPlugin - modules/TwitterStreamingImporterV2 - modules/IsometricLayout - modules/NetworkSplitter3D - modules/ExcelCsvImporter - modules/OracleDriver - modules/Lineage - modules/MdsLayout - modules/filterfromfile - modules/GiveColorToNodes - modules/GiveColorToEdges - modules/GravityPlugin - modules/MdsMetric - modules/VectorCalculator - modules/PrestigePlugin - modules/HttpGraph - modules/MinimumSpanningTree - modules/SimilarityComputer - - modules/PolygonShapedNodes - modules/ScalePlugin - modules/CirclePack - - modules/newmangirvan - - modules/polinodeExporter - modules/LeidenAlgorithm - modules/BridgingPlugin - modules/BoundingDiametersSuite - modules/BoundingDiameters - modules/BoundingDiametersUI - modules/dbscan - modules/ClusteringCoefficient - modules/KleinbergGenerator - modules/columnCalculator - modules/GroupPartition - modules/HairballBuster - modules/ForceAtlas3D - modules/InspectorTool - modules/ErGenerator - modules/KatzCentrality - modules/PositionRanking - modules/Neo4jPlugin - modules/LinkPrediction - modules/WebPublishPlugin - modules/OrderedLayout - modules/OpenSeadragonPlugin - modules/WordCloudPlugin - modules/BlueskyGephi + modules/JPEGExporter