From 4f2ae73b848848a7b08bc7a79e3cc0cfec7d0c26 Mon Sep 17 00:00:00 2001 From: Florian Rauscha Date: Wed, 10 Dec 2025 10:29:12 +0100 Subject: [PATCH] Add image support for worksheets (PNG, JPEG, GIF, SVG) This adds the ability to embed images in worksheet cells with support for: - PNG, JPEG, GIF raster formats with automatic detection via byte headers - SVG vector format (Office 2016+) with automatic detection - One-cell anchoring (fixed size at cell position) - Two-cell anchoring (image spans and resizes with cell range) - Custom anchoring with pixel offsets via PictureAnchor New classes: - ImageType: Enum for supported formats with byte header detection - Picture: Represents an embedded image with XML writing - PictureAnchor: EMU-based positioning (one-cell/two-cell anchors) - Pictures: Collection manager for worksheet images API additions to Worksheet: - addImage(row, col, imageData, widthPx, heightPx) - one-cell anchor - addImage(fromRow, fromCol, toRow, toCol, imageData) - two-cell anchor - addImage(anchor, imageData) - custom anchor - addImage(anchor, imageData, name, lockAspectRatio) - full control Images coexist properly with comments (separate drawing relationships). Includes comprehensive unit tests with Apache POI validation. --- .../java/org/dhatim/fastexcel/ImageType.java | 95 +++++ .../java/org/dhatim/fastexcel/Picture.java | 158 ++++++++ .../org/dhatim/fastexcel/PictureAnchor.java | 196 ++++++++++ .../java/org/dhatim/fastexcel/Pictures.java | 151 ++++++++ .../org/dhatim/fastexcel/Relationships.java | 20 + .../java/org/dhatim/fastexcel/Workbook.java | 40 +- .../java/org/dhatim/fastexcel/Worksheet.java | 111 +++++- .../org/dhatim/fastexcel/PictureTest.java | 344 ++++++++++++++++++ 8 files changed, 1109 insertions(+), 6 deletions(-) create mode 100644 fastexcel-writer/src/main/java/org/dhatim/fastexcel/ImageType.java create mode 100644 fastexcel-writer/src/main/java/org/dhatim/fastexcel/Picture.java create mode 100644 fastexcel-writer/src/main/java/org/dhatim/fastexcel/PictureAnchor.java create mode 100644 fastexcel-writer/src/main/java/org/dhatim/fastexcel/Pictures.java create mode 100644 fastexcel-writer/src/test/java/org/dhatim/fastexcel/PictureTest.java diff --git a/fastexcel-writer/src/main/java/org/dhatim/fastexcel/ImageType.java b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/ImageType.java new file mode 100644 index 00000000..81127998 --- /dev/null +++ b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/ImageType.java @@ -0,0 +1,95 @@ +/* + * Copyright 2016 Dhatim. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.dhatim.fastexcel; + +/** + * Supported image types for embedding in worksheets. + */ +public enum ImageType { + PNG("png", "image/png", false), + JPEG("jpeg", "image/jpeg", false), + GIF("gif", "image/gif", false), + SVG("svg", "image/svg+xml", true); + + private final String extension; + private final String contentType; + private final boolean vector; + + ImageType(String extension, String contentType, boolean vector) { + this.extension = extension; + this.contentType = contentType; + this.vector = vector; + } + + /** + * Check if this is a vector image format (e.g., SVG). + * + * @return true if vector format, false if raster + */ + public boolean isVector() { + return vector; + } + + /** + * Get the file extension for this image type. + * + * @return File extension without the dot (e.g., "png", "jpeg") + */ + public String getExtension() { + return extension; + } + + /** + * Get the MIME content type for this image type. + * + * @return MIME content type (e.g., "image/png") + */ + public String getContentType() { + return contentType; + } + + /** + * Detect image type from byte array header. + * + * @param data Image bytes + * @return Detected ImageType + * @throws IllegalArgumentException if the image format is not supported or data is invalid + */ + public static ImageType fromBytes(byte[] data) { + if (data == null || data.length < 8) { + throw new IllegalArgumentException("Invalid image data: data is null or too short"); + } + // PNG signature: 89 50 4E 47 0D 0A 1A 0A + if (data[0] == (byte) 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 + && data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A) { + return PNG; + } + // JPEG signature: FF D8 FF + if (data[0] == (byte) 0xFF && data[1] == (byte) 0xD8 && data[2] == (byte) 0xFF) { + return JPEG; + } + // GIF signature: 47 49 46 38 (GIF8) + if (data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x38) { + return GIF; + } + // SVG detection: look for "); + anchor.writeFrom(w); + anchor.writeExt(w); + writePicElement(w); + w.append(""); + w.append(""); + } + + private void writeTwoCellAnchor(Writer w) throws IOException { + w.append(""); + anchor.writeFrom(w); + anchor.writeTo(w); + writePicElement(w); + w.append(""); + w.append(""); + } + + private void writePicElement(Writer w) throws IOException { + w.append(""); + + // Non-visual properties + w.append(""); + w.append(""); + w.append(""); + if (lockAspectRatio) { + w.append(""); + } + w.append(""); + w.append(""); + + // Blip fill (image reference) + w.append(""); + if (imageType == ImageType.SVG) { + // SVG uses extension element with svgBlip (Office 2016+) + w.append(""); + w.append(""); + w.append(""); + w.append(""); + w.append(""); + w.append(""); + w.append(""); + } else { + // Raster images (PNG, JPEG, GIF) + w.append(""); + } + w.append(""); + w.append(""); + + // Shape properties + w.append(""); + w.append(""); + w.append(""); + if (anchor.isTwoCellAnchor()) { + w.append(""); + } else { + w.append(""); + } + w.append(""); + w.append(""); + w.append(""); + + w.append(""); + } +} diff --git a/fastexcel-writer/src/main/java/org/dhatim/fastexcel/PictureAnchor.java b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/PictureAnchor.java new file mode 100644 index 00000000..0d8c656a --- /dev/null +++ b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/PictureAnchor.java @@ -0,0 +1,196 @@ +/* + * Copyright 2016 Dhatim. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.dhatim.fastexcel; + +import java.io.IOException; + +/** + * Defines the positioning of an image within a worksheet. + * Supports both one-cell and two-cell anchoring. + *

+ * One-cell anchor: Image is positioned at a cell with explicit size (width/height). + * Two-cell anchor: Image spans from one cell to another, resizing with cells. + */ +public class PictureAnchor { + + /** + * EMUs (English Metric Units) per pixel at 96 DPI. + */ + public static final int EMU_PER_PIXEL = 9525; + + /** + * EMUs per point. + */ + public static final int EMU_PER_POINT = 12700; + + /** + * EMUs per inch. + */ + public static final int EMU_PER_INCH = 914400; + + /** + * EMUs per centimeter. + */ + public static final int EMU_PER_CM = 360000; + + private final int fromCol; + private final int fromColOff; // offset in EMUs + private final int fromRow; + private final int fromRowOff; // offset in EMUs + + // For two-cell anchor + private final Integer toCol; + private final Integer toColOff; + private final Integer toRow; + private final Integer toRowOff; + + // For one-cell anchor (explicit size in EMUs) + private final Long widthEmu; + private final Long heightEmu; + + /** + * Create a one-cell anchor with explicit size in pixels. + * + * @param row Zero-based row number + * @param col Zero-based column number + * @param widthPx Image width in pixels + * @param heightPx Image height in pixels + * @return A new PictureAnchor configured for one-cell anchoring + */ + public static PictureAnchor oneCellAnchor(int row, int col, int widthPx, int heightPx) { + return new PictureAnchor(col, 0, row, 0, null, null, null, null, + (long) widthPx * EMU_PER_PIXEL, (long) heightPx * EMU_PER_PIXEL); + } + + /** + * Create a one-cell anchor with offset and explicit size in pixels. + * + * @param row Zero-based row number + * @param col Zero-based column number + * @param colOffPx Column offset in pixels from the left edge of the cell + * @param rowOffPx Row offset in pixels from the top edge of the cell + * @param widthPx Image width in pixels + * @param heightPx Image height in pixels + * @return A new PictureAnchor configured for one-cell anchoring with offset + */ + public static PictureAnchor oneCellAnchor(int row, int col, int colOffPx, int rowOffPx, + int widthPx, int heightPx) { + return new PictureAnchor(col, colOffPx * EMU_PER_PIXEL, row, rowOffPx * EMU_PER_PIXEL, + null, null, null, null, + (long) widthPx * EMU_PER_PIXEL, (long) heightPx * EMU_PER_PIXEL); + } + + /** + * Create a two-cell anchor spanning from one cell to another. + * + * @param fromRow Starting row (zero-based) + * @param fromCol Starting column (zero-based) + * @param toRow Ending row (zero-based, exclusive - image ends at top of this row) + * @param toCol Ending column (zero-based, exclusive - image ends at left of this column) + * @return A new PictureAnchor configured for two-cell anchoring + */ + public static PictureAnchor twoCellAnchor(int fromRow, int fromCol, int toRow, int toCol) { + return new PictureAnchor(fromCol, 0, fromRow, 0, toCol, 0, toRow, 0, null, null); + } + + /** + * Create a two-cell anchor with offsets. + * + * @param fromRow Starting row (zero-based) + * @param fromCol Starting column (zero-based) + * @param fromColOffPx Starting column offset in pixels + * @param fromRowOffPx Starting row offset in pixels + * @param toRow Ending row (zero-based) + * @param toCol Ending column (zero-based) + * @param toColOffPx Ending column offset in pixels + * @param toRowOffPx Ending row offset in pixels + * @return A new PictureAnchor configured for two-cell anchoring with offsets + */ + public static PictureAnchor twoCellAnchor(int fromRow, int fromCol, int fromColOffPx, int fromRowOffPx, + int toRow, int toCol, int toColOffPx, int toRowOffPx) { + return new PictureAnchor(fromCol, fromColOffPx * EMU_PER_PIXEL, fromRow, fromRowOffPx * EMU_PER_PIXEL, + toCol, toColOffPx * EMU_PER_PIXEL, toRow, toRowOffPx * EMU_PER_PIXEL, null, null); + } + + private PictureAnchor(int fromCol, int fromColOff, int fromRow, int fromRowOff, + Integer toCol, Integer toColOff, Integer toRow, Integer toRowOff, + Long widthEmu, Long heightEmu) { + this.fromCol = fromCol; + this.fromColOff = fromColOff; + this.fromRow = fromRow; + this.fromRowOff = fromRowOff; + this.toCol = toCol; + this.toColOff = toColOff; + this.toRow = toRow; + this.toRowOff = toRowOff; + this.widthEmu = widthEmu; + this.heightEmu = heightEmu; + } + + /** + * Check if this anchor is a two-cell anchor. + * + * @return true if two-cell anchor, false if one-cell anchor + */ + public boolean isTwoCellAnchor() { + return toCol != null; + } + + /** + * Write the "from" position element. + */ + void writeFrom(Writer w) throws IOException { + w.append(""); + w.append("").append(fromCol).append(""); + w.append("").append(fromColOff).append(""); + w.append("").append(fromRow).append(""); + w.append("").append(fromRowOff).append(""); + w.append(""); + } + + /** + * Write the "to" position element (for two-cell anchors). + */ + void writeTo(Writer w) throws IOException { + w.append(""); + w.append("").append(toCol).append(""); + w.append("").append(toColOff).append(""); + w.append("").append(toRow).append(""); + w.append("").append(toRowOff).append(""); + w.append(""); + } + + /** + * Write the extent element (for one-cell anchors). + */ + void writeExt(Writer w) throws IOException { + w.append(""); + } + + /** + * Get width in EMUs (for one-cell anchors). + */ + public Long getWidthEmu() { + return widthEmu; + } + + /** + * Get height in EMUs (for one-cell anchors). + */ + public Long getHeightEmu() { + return heightEmu; + } +} diff --git a/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Pictures.java b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Pictures.java new file mode 100644 index 00000000..849f2a28 --- /dev/null +++ b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Pictures.java @@ -0,0 +1,151 @@ +/* + * Copyright 2016 Dhatim. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.dhatim.fastexcel; + +import java.io.IOException; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Manages all pictures in a worksheet. + */ +class Pictures { + + private final List pictures = new ArrayList<>(); + private final AtomicInteger idCounter = new AtomicInteger(1); + private final Set usedImageTypes = new HashSet<>(); + + /** + * Add a picture with one-cell anchor. + * + * @param row Zero-based row number + * @param col Zero-based column number + * @param imageData Image bytes + * @param widthPx Width in pixels + * @param heightPx Height in pixels + * @return The created Picture + */ + Picture addPicture(int row, int col, byte[] imageData, int widthPx, int heightPx) { + return addPicture(PictureAnchor.oneCellAnchor(row, col, widthPx, heightPx), + imageData, null, true); + } + + /** + * Add a picture with two-cell anchor. + * + * @param fromRow Starting row (zero-based) + * @param fromCol Starting column (zero-based) + * @param toRow Ending row (zero-based) + * @param toCol Ending column (zero-based) + * @param imageData Image bytes + * @return The created Picture + */ + Picture addPicture(int fromRow, int fromCol, int toRow, int toCol, byte[] imageData) { + return addPicture(PictureAnchor.twoCellAnchor(fromRow, fromCol, toRow, toCol), + imageData, null, true); + } + + /** + * Add a picture with custom anchor. + * + * @param anchor The positioning anchor + * @param imageData Image bytes + * @param name Picture name (can be null) + * @param lockAspectRatio Whether to lock aspect ratio + * @return The created Picture + */ + Picture addPicture(PictureAnchor anchor, byte[] imageData, String name, boolean lockAspectRatio) { + ImageType imageType = ImageType.fromBytes(imageData); + usedImageTypes.add(imageType); + + int id = idCounter.getAndIncrement(); + Picture picture = new Picture(id, name, anchor, imageData, imageType, lockAspectRatio); + pictures.add(picture); + return picture; + } + + boolean isEmpty() { + return pictures.isEmpty(); + } + + int size() { + return pictures.size(); + } + + Set getUsedImageTypes() { + return Collections.unmodifiableSet(usedImageTypes); + } + + List getPictures() { + return Collections.unmodifiableList(pictures); + } + + /** + * Write the drawing XML file. + */ + void writeDrawing(Writer w) throws IOException { + w.append(""); + w.append(""); + + for (Picture picture : pictures) { + picture.write(w); + } + + w.append(""); + } + + /** + * Write the drawing relationships file. + * + * @param w The writer + * @param sheetIndex The sheet index (1-based) + */ + void writeDrawingRels(Writer w, int sheetIndex) throws IOException { + w.append(""); + w.append(""); + + int imageIndex = 1; + for (Picture picture : pictures) { + String rId = "rId" + imageIndex; + picture.setRelationshipId(rId); + String imageName = "image" + sheetIndex + "_" + imageIndex + "." + picture.getImageType().getExtension(); + + w.append(""); + imageIndex++; + } + + w.append(""); + } + + /** + * Write image files to the media folder. + * + * @param workbook The parent workbook + * @param sheetIndex The sheet index (1-based) + */ + void writeMediaFiles(Workbook workbook, int sheetIndex) throws IOException { + int imageIndex = 1; + for (Picture picture : pictures) { + String imageName = "image" + sheetIndex + "_" + imageIndex + "." + picture.getImageType().getExtension(); + workbook.writeBinaryFile("xl/media/" + imageName, picture.getImageData()); + imageIndex++; + } + } +} diff --git a/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Relationships.java b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Relationships.java index fa57464c..4b71ad55 100644 --- a/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Relationships.java +++ b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Relationships.java @@ -42,6 +42,26 @@ void setCommentsRels(int index) { relationship.add(new Relationship("v", TYPE_OF_VMLDRAWING, "../drawings/vmlDrawing" + index + ".vml", null)); } + /** + * Set relationships for comments when pictures also exist (no separate drawing.xml for comments). + */ + void setCommentsOnlyRels(int index) { + relationship.add(new Relationship("c", TYPE_OF_COMMENTS, "../comments" + index + ".xml", null)); + relationship.add(new Relationship("v", TYPE_OF_VMLDRAWING, "../drawings/vmlDrawing" + index + ".vml", null)); + } + + /** + * Set relationship for picture drawing. + * + * @param index The sheet index + * @return The relationship ID + */ + String setImageDrawingRels(int index) { + String id = "rId" + (maxIndex.getAndIncrement()); + relationship.add(new Relationship(id, TYPE_OF_DRAWING, "../drawings/drawing" + index + ".xml", null)); + return id; + } + boolean isEmpty() { return relationship.isEmpty(); } diff --git a/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Workbook.java b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Workbook.java index a8f5c1a5..b3c7cb34 100644 --- a/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Workbook.java +++ b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Workbook.java @@ -144,13 +144,23 @@ public void finish() throws IOException { if (hasComments()) { w.append(""); } + // Add image content types + Set usedImageTypes = collectUsedImageTypes(); + for (ImageType imageType : usedImageTypes) { + w.append(""); + } w.append(""); for (Worksheet ws : worksheets) { int index = getIndex(ws); w.append(""); + // Drawing content type (for pictures and/or comments) + if (!ws.pictures.isEmpty() || !ws.comments.isEmpty()) { + w.append(""); + } + // Comments content type if (!ws.comments.isEmpty()) { w.append(""); - w.append(""); } if (!ws.tables.isEmpty()) { for (Map.Entry entry : ws.tables.entrySet()) { @@ -283,6 +293,19 @@ private boolean hasComments() { return false; } + /** + * Collect all used image types from all worksheets. + * + * @return Set of used ImageType values + */ + private Set collectUsedImageTypes() { + Set types = new HashSet<>(); + for (Worksheet ws : worksheets) { + types.addAll(ws.pictures.getUsedImageTypes()); + } + return types; + } + /** * Writes the {@code xl/workbook.xml} file to the zip. * @@ -404,6 +427,21 @@ void endFile() throws IOException { os.closeEntry(); } + /** + * Write a binary file to the output stream. + * + * @param name File name (path within the zip). + * @param data Binary data to write. + * @throws IOException If an I/O error occurs. + */ + void writeBinaryFile(String name, byte[] data) throws IOException { + synchronized (os) { + os.putNextEntry(new ZipEntry(name)); + os.write(data); + os.closeEntry(); + } + } + /** * Cache the given string. * diff --git a/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Worksheet.java b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Worksheet.java index d16c64e0..8f68d939 100644 --- a/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Worksheet.java +++ b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Worksheet.java @@ -121,6 +121,8 @@ public class Worksheet implements Closeable { final Comments comments = new Comments(); + final Pictures pictures = new Pictures(); + final Map tables = new LinkedHashMap<>(); private final DynamicBitMatrix tablesMatrix = new DynamicBitMatrix(MAX_COLS, MAX_ROWS); @@ -267,6 +269,11 @@ public class Worksheet implements Closeable { private Map hyperlinkRanges = new LinkedHashMap<>(); + /** + * Relationship ID for the drawing element (for pictures). + */ + private String drawingRelId; + /** * The set of protection options that are applied on the sheet. */ @@ -972,9 +979,22 @@ public void finish() throws IOException { writer.append(""); - if(!comments.isEmpty()) { - writer.append(""); - writer.append(""); + // Drawing references + if (!pictures.isEmpty() || !comments.isEmpty()) { + if (!pictures.isEmpty()) { + // Pictures need drawing.xml reference + if (drawingRelId == null) { + drawingRelId = relationships.setImageDrawingRels(index); + } + writer.append(""); + } else if (!comments.isEmpty()) { + // Comments only need drawing reference with fixed ID + writer.append(""); + } + if (!comments.isEmpty()) { + // Comments need legacyDrawing (VML) + writer.append(""); + } } if (!tables.isEmpty()){ writer.append(""); @@ -987,12 +1007,29 @@ public void finish() throws IOException { writer.append(""); workbook.endFile(); + /* write picture files */ + if (!pictures.isEmpty()) { + // First, write the drawing relationships file (this assigns rIds to pictures) + workbook.writeFile("xl/drawings/_rels/drawing" + index + ".xml.rels", + w -> pictures.writeDrawingRels(w, index)); + // Then write the drawing file + workbook.writeFile("xl/drawings/drawing" + index + ".xml", pictures::writeDrawing); + // Finally write the actual image binary files + pictures.writeMediaFiles(workbook, index); + } + /* write comment files */ if (!comments.isEmpty()) { workbook.writeFile("xl/comments" + index + ".xml", comments::writeComments); workbook.writeFile("xl/drawings/vmlDrawing" + index + ".vml", comments::writeVmlDrawing); - workbook.writeFile("xl/drawings/drawing" + index + ".xml", comments::writeDrawing); - relationships.setCommentsRels(index); + if (pictures.isEmpty()) { + // Only write empty drawing.xml for comments if no pictures + workbook.writeFile("xl/drawings/drawing" + index + ".xml", comments::writeDrawing); + relationships.setCommentsRels(index); + } else { + // Comments coexist with pictures - only set VML relationship + relationships.setCommentsOnlyRels(index); + } } //write table files for (Map.Entry entry : tables.entrySet()) { @@ -1183,6 +1220,70 @@ public void comment(int r, int c, String comment) { comments.set(r, c, comment); } + /** + * Add an image at the specified cell position with explicit size. + *

+ * Images are stored in memory till call to {@link #close()} (or {@link #finish()}) - + * calling {@link #flush()} does not write them to output stream. + * + * @param row Zero-based row number + * @param col Zero-based column number + * @param imageData Image bytes (PNG, JPEG, or GIF) + * @param widthPx Image width in pixels + * @param heightPx Image height in pixels + * @return The created Picture object for further customization + */ + public Picture addImage(int row, int col, byte[] imageData, int widthPx, int heightPx) { + return pictures.addPicture(row, col, imageData, widthPx, heightPx); + } + + /** + * Add an image spanning from one cell to another. + *

+ * Images are stored in memory till call to {@link #close()} (or {@link #finish()}) - + * calling {@link #flush()} does not write them to output stream. + * + * @param fromRow Starting row (zero-based) + * @param fromCol Starting column (zero-based) + * @param toRow Ending row (zero-based) + * @param toCol Ending column (zero-based) + * @param imageData Image bytes (PNG, JPEG, or GIF) + * @return The created Picture object for further customization + */ + public Picture addImage(int fromRow, int fromCol, int toRow, int toCol, byte[] imageData) { + return pictures.addPicture(fromRow, fromCol, toRow, toCol, imageData); + } + + /** + * Add an image with custom anchor configuration. + *

+ * Images are stored in memory till call to {@link #close()} (or {@link #finish()}) - + * calling {@link #flush()} does not write them to output stream. + * + * @param anchor The positioning anchor + * @param imageData Image bytes (PNG, JPEG, or GIF) + * @return The created Picture object + */ + public Picture addImage(PictureAnchor anchor, byte[] imageData) { + return pictures.addPicture(anchor, imageData, null, true); + } + + /** + * Add an image with custom anchor and name. + *

+ * Images are stored in memory till call to {@link #close()} (or {@link #finish()}) - + * calling {@link #flush()} does not write them to output stream. + * + * @param anchor The positioning anchor + * @param imageData Image bytes (PNG, JPEG, or GIF) + * @param name Name for the picture + * @param lockAspectRatio Whether to lock the aspect ratio + * @return The created Picture object + */ + public Picture addImage(PictureAnchor anchor, byte[] imageData, String name, boolean lockAspectRatio) { + return pictures.addPicture(anchor, imageData, name, lockAspectRatio); + } + /** * Hide grid lines. */ diff --git a/fastexcel-writer/src/test/java/org/dhatim/fastexcel/PictureTest.java b/fastexcel-writer/src/test/java/org/dhatim/fastexcel/PictureTest.java new file mode 100644 index 00000000..73cd1b06 --- /dev/null +++ b/fastexcel-writer/src/test/java/org/dhatim/fastexcel/PictureTest.java @@ -0,0 +1,344 @@ +/* + * Copyright 2016 Dhatim. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.dhatim.fastexcel; + +import org.apache.poi.xssf.usermodel.XSSFDrawing; +import org.apache.poi.xssf.usermodel.XSSFPicture; +import org.apache.poi.xssf.usermodel.XSSFSheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PictureTest { + + /** + * Create a minimal valid 1x1 red PNG for testing. + * This is a complete PNG file that can be read by image parsers. + */ + private static byte[] createTestPng() { + return new byte[] { + // PNG signature + (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + // IHDR chunk (13 bytes data) + 0x00, 0x00, 0x00, 0x0D, // length + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x01, // width = 1 + 0x00, 0x00, 0x00, 0x01, // height = 1 + 0x08, // bit depth = 8 + 0x02, // color type = RGB + 0x00, // compression method + 0x00, // filter method + 0x00, // interlace method + (byte) 0x90, 0x77, 0x53, (byte) 0xDE, // CRC + // IDAT chunk (compressed image data) + 0x00, 0x00, 0x00, 0x0C, // length + 0x49, 0x44, 0x41, 0x54, // "IDAT" + 0x08, (byte) 0xD7, // zlib header + 0x63, (byte) 0xF8, (byte) 0xCF, (byte) 0xC0, 0x00, 0x00, // compressed data + 0x00, 0x03, 0x00, 0x01, // more compressed data + 0x00, 0x05, // checksum part + (byte) 0xFE, (byte) 0xD4, (byte) 0xEF, (byte) 0xA5, // CRC + // IEND chunk + 0x00, 0x00, 0x00, 0x00, // length = 0 + 0x49, 0x45, 0x4E, 0x44, // "IEND" + (byte) 0xAE, 0x42, 0x60, (byte) 0x82 // CRC + }; + } + + /** + * Create a minimal valid JPEG for testing. + */ + private static byte[] createTestJpeg() { + return new byte[] { + // JPEG signature (SOI marker) + (byte) 0xFF, (byte) 0xD8, + // APP0 marker (JFIF) + (byte) 0xFF, (byte) 0xE0, + 0x00, 0x10, // length + 0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0" + 0x01, 0x01, // version + 0x00, // units + 0x00, 0x01, // X density + 0x00, 0x01, // Y density + 0x00, 0x00, // thumbnail size + // SOF0 marker (start of frame) + (byte) 0xFF, (byte) 0xC0, + 0x00, 0x0B, // length + 0x08, // precision + 0x00, 0x01, // height = 1 + 0x00, 0x01, // width = 1 + 0x01, // components + 0x01, 0x11, 0x00, // component info + // DHT marker (Huffman table) + (byte) 0xFF, (byte) 0xC4, + 0x00, 0x14, // length + 0x00, // table info + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, + // SOS marker (start of scan) + (byte) 0xFF, (byte) 0xDA, + 0x00, 0x08, // length + 0x01, // components + 0x01, 0x00, // component selector + 0x00, 0x3F, 0x00, // spectral selection + // Image data + 0x7F, + // EOI marker (end of image) + (byte) 0xFF, (byte) 0xD9 + }; + } + + @Test + void testImageTypeDetectionPng() { + byte[] png = createTestPng(); + assertThat(ImageType.fromBytes(png)).isEqualTo(ImageType.PNG); + } + + @Test + void testImageTypeDetectionJpeg() { + byte[] jpeg = createTestJpeg(); + assertThat(ImageType.fromBytes(jpeg)).isEqualTo(ImageType.JPEG); + } + + @Test + void testImageTypeDetectionGif() { + byte[] gif = new byte[] {0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x00, 0x00}; + assertThat(ImageType.fromBytes(gif)).isEqualTo(ImageType.GIF); + } + + @Test + void testImageTypeDetectionSvg() { + String svg = ""; + byte[] svgBytes = svg.getBytes(java.nio.charset.StandardCharsets.UTF_8); + assertThat(ImageType.fromBytes(svgBytes)).isEqualTo(ImageType.SVG); + } + + @Test + void testImageTypeDetectionSvgWithoutXmlDeclaration() { + String svg = ""; + byte[] svgBytes = svg.getBytes(java.nio.charset.StandardCharsets.UTF_8); + assertThat(ImageType.fromBytes(svgBytes)).isEqualTo(ImageType.SVG); + } + + @Test + void testImageTypeDetectionUnsupported() { + byte[] unknown = new byte[] {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}; + assertThatThrownBy(() -> ImageType.fromBytes(unknown)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported image format"); + } + + @Test + void testImageTypeDetectionNull() { + assertThatThrownBy(() -> ImageType.fromBytes(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testImageTypeDetectionTooShort() { + byte[] tooShort = new byte[] {0x00, 0x01, 0x02}; + assertThatThrownBy(() -> ImageType.fromBytes(tooShort)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testAddImageOneCellAnchor() throws Exception { + byte[] imageData = createTestPng(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try (Workbook wb = new Workbook(baos, "Test", "1.0")) { + Worksheet ws = wb.newWorksheet("Sheet1"); + ws.value(0, 0, "Logo:"); + ws.addImage(0, 1, imageData, 100, 50); + } + + // Verify with Apache POI + try (XSSFWorkbook poiWb = new XSSFWorkbook(new ByteArrayInputStream(baos.toByteArray()))) { + XSSFSheet sheet = poiWb.getSheetAt(0); + XSSFDrawing drawing = sheet.getDrawingPatriarch(); + assertThat(drawing).isNotNull(); + assertThat(drawing.getShapes()).hasSize(1); + assertThat(drawing.getShapes().get(0)).isInstanceOf(XSSFPicture.class); + } + } + + @Test + void testAddImageTwoCellAnchor() throws Exception { + byte[] imageData = createTestPng(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try (Workbook wb = new Workbook(baos, "Test", "1.0")) { + Worksheet ws = wb.newWorksheet("Sheet1"); + ws.addImage(0, 0, 5, 3, imageData); + } + + try (XSSFWorkbook poiWb = new XSSFWorkbook(new ByteArrayInputStream(baos.toByteArray()))) { + XSSFSheet sheet = poiWb.getSheetAt(0); + XSSFDrawing drawing = sheet.getDrawingPatriarch(); + assertThat(drawing).isNotNull(); + assertThat(drawing.getShapes()).hasSize(1); + } + } + + @Test + void testMultipleImages() throws Exception { + byte[] imageData = createTestPng(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try (Workbook wb = new Workbook(baos, "Test", "1.0")) { + Worksheet ws = wb.newWorksheet("Sheet1"); + ws.addImage(0, 0, imageData, 50, 50); + ws.addImage(1, 0, imageData, 50, 50); + ws.addImage(2, 0, imageData, 50, 50); + } + + try (XSSFWorkbook poiWb = new XSSFWorkbook(new ByteArrayInputStream(baos.toByteArray()))) { + XSSFSheet sheet = poiWb.getSheetAt(0); + XSSFDrawing drawing = sheet.getDrawingPatriarch(); + assertThat(drawing.getShapes()).hasSize(3); + } + } + + @Test + void testImageWithComments() throws Exception { + byte[] imageData = createTestPng(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try (Workbook wb = new Workbook(baos, "Test", "1.0")) { + Worksheet ws = wb.newWorksheet("Sheet1"); + ws.value(0, 0, "Cell with comment"); + ws.comment(0, 0, "This is a comment"); + ws.addImage(1, 0, imageData, 100, 100); + } + + // Verify both comments and images work together + try (XSSFWorkbook poiWb = new XSSFWorkbook(new ByteArrayInputStream(baos.toByteArray()))) { + XSSFSheet sheet = poiWb.getSheetAt(0); + // Check comment exists + assertThat(sheet.getCellComment(new org.apache.poi.ss.util.CellAddress(0, 0))).isNotNull(); + // Check drawing exists + XSSFDrawing drawing = sheet.getDrawingPatriarch(); + assertThat(drawing).isNotNull(); + } + } + + @Test + void testMultipleWorksheets() throws Exception { + byte[] imageData = createTestPng(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try (Workbook wb = new Workbook(baos, "Test", "1.0")) { + Worksheet ws1 = wb.newWorksheet("Sheet1"); + ws1.addImage(0, 0, imageData, 100, 100); + + Worksheet ws2 = wb.newWorksheet("Sheet2"); + ws2.addImage(0, 0, imageData, 150, 150); + } + + try (XSSFWorkbook poiWb = new XSSFWorkbook(new ByteArrayInputStream(baos.toByteArray()))) { + assertThat(poiWb.getSheetAt(0).getDrawingPatriarch().getShapes()).hasSize(1); + assertThat(poiWb.getSheetAt(1).getDrawingPatriarch().getShapes()).hasSize(1); + } + } + + @Test + void testImageWithCustomAnchor() throws Exception { + byte[] imageData = createTestPng(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try (Workbook wb = new Workbook(baos, "Test", "1.0")) { + Worksheet ws = wb.newWorksheet("Sheet1"); + PictureAnchor anchor = PictureAnchor.oneCellAnchor(2, 1, 10, 10, 200, 150); + ws.addImage(anchor, imageData, "MyImage", true); + } + + try (XSSFWorkbook poiWb = new XSSFWorkbook(new ByteArrayInputStream(baos.toByteArray()))) { + XSSFSheet sheet = poiWb.getSheetAt(0); + XSSFDrawing drawing = sheet.getDrawingPatriarch(); + assertThat(drawing.getShapes()).hasSize(1); + } + } + + @Test + void testPictureAnchorOneCellBasic() { + PictureAnchor anchor = PictureAnchor.oneCellAnchor(5, 3, 100, 200); + assertThat(anchor.isTwoCellAnchor()).isFalse(); + assertThat(anchor.getWidthEmu()).isEqualTo(100L * PictureAnchor.EMU_PER_PIXEL); + assertThat(anchor.getHeightEmu()).isEqualTo(200L * PictureAnchor.EMU_PER_PIXEL); + } + + @Test + void testPictureAnchorTwoCellBasic() { + PictureAnchor anchor = PictureAnchor.twoCellAnchor(0, 0, 5, 3); + assertThat(anchor.isTwoCellAnchor()).isTrue(); + assertThat(anchor.getWidthEmu()).isNull(); + assertThat(anchor.getHeightEmu()).isNull(); + } + + @Test + void testImageWithOtherContent() throws Exception { + byte[] imageData = createTestPng(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try (Workbook wb = new Workbook(baos, "Test", "1.0")) { + Worksheet ws = wb.newWorksheet("Sheet1"); + // Add regular content + ws.value(0, 0, "Header"); + ws.value(1, 0, 123.45); + ws.style(0, 0).bold().set(); + // Add merged cells + ws.range(3, 0, 3, 2).merge(); + ws.value(3, 0, "Merged"); + // Add image + ws.addImage(5, 0, imageData, 100, 100); + } + + try (XSSFWorkbook poiWb = new XSSFWorkbook(new ByteArrayInputStream(baos.toByteArray()))) { + XSSFSheet sheet = poiWb.getSheetAt(0); + // Verify content + assertThat(sheet.getRow(0).getCell(0).getStringCellValue()).isEqualTo("Header"); + assertThat(sheet.getRow(1).getCell(0).getNumericCellValue()).isEqualTo(123.45); + // Verify merged region + assertThat(sheet.getNumMergedRegions()).isEqualTo(1); + // Verify image + XSSFDrawing drawing = sheet.getDrawingPatriarch(); + assertThat(drawing.getShapes()).hasSize(1); + } + } + + @Test + void testJpegImage() throws Exception { + byte[] imageData = createTestJpeg(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try (Workbook wb = new Workbook(baos, "Test", "1.0")) { + Worksheet ws = wb.newWorksheet("Sheet1"); + ws.addImage(0, 0, imageData, 100, 100); + } + + try (XSSFWorkbook poiWb = new XSSFWorkbook(new ByteArrayInputStream(baos.toByteArray()))) { + XSSFSheet sheet = poiWb.getSheetAt(0); + XSSFDrawing drawing = sheet.getDrawingPatriarch(); + assertThat(drawing.getShapes()).hasSize(1); + } + } +}