Improve image resource management by ensuring BufferedImage objects are flushed after processing; add tests for cover extraction and image scaling (#1688)

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
This commit is contained in:
Balázs Szücs
2025-11-30 16:17:44 +01:00
committed by GitHub
parent 914d05b464
commit 965aa9dfab
9 changed files with 214 additions and 69 deletions

View File

@@ -44,6 +44,7 @@ public class BackgroundUploadService {
deleteExistingBackgroundFiles(userId);
fileService.saveBackgroundImage(originalImage, filename, userId);
originalImage.flush(); // Release resources after saving
String fileUrl = FileService.getBackgroundUrl(filename, userId);
return new UploadResponse(fileUrl);
@@ -64,6 +65,7 @@ public class BackgroundUploadService {
deleteExistingBackgroundFiles(userId);
fileService.saveBackgroundImage(originalImage, filename, userId);
originalImage.flush(); // Release resources after saving
String fileUrl = FileService.getBackgroundUrl(filename, userId);
return new UploadResponse(fileUrl);

View File

@@ -74,11 +74,16 @@ public class CbxProcessor extends AbstractFileProcessor implements BookFileProce
try {
Optional<BufferedImage> imageOptional = extractImagesFromArchive(file);
if (imageOptional.isPresent()) {
boolean saved = fileService.saveCoverImages(imageOptional.get(), bookEntity.getId());
if (saved) {
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
bookMetadataRepository.save(bookEntity.getMetadata());
return true;
BufferedImage image = imageOptional.get();
try {
boolean saved = fileService.saveCoverImages(image, bookEntity.getId());
if (saved) {
bookEntity.getMetadata().setCoverUpdatedOn(Instant.now());
bookMetadataRepository.save(bookEntity.getMetadata());
return true;
}
} finally {
image.flush(); // Release resources after processing
}
}
} catch (Exception e) {

View File

@@ -151,6 +151,12 @@ public class EpubProcessor extends AbstractFileProcessor implements BookFileProc
private boolean saveCoverImage(Resource coverImage, long bookId) throws IOException {
BufferedImage originalImage = ImageIO.read(new ByteArrayInputStream(coverImage.getData()));
return fileService.saveCoverImages(originalImage, bookId);
try {
return fileService.saveCoverImages(originalImage, bookId);
} finally {
if (originalImage != null) {
originalImage.flush(); // Release resources after processing
}
}
}
}

View File

@@ -47,14 +47,20 @@ public class PdfMetadataExtractor implements FileMetadataExtractor {
@Override
public byte[] extractCover(File file) {
BufferedImage coverImage = null;
try (PDDocument pdf = Loader.loadPDF(file)) {
BufferedImage coverImage = new PDFRenderer(pdf).renderImageWithDPI(0, 300, ImageType.RGB);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(coverImage, "jpg", baos);
return baos.toByteArray();
coverImage = new PDFRenderer(pdf).renderImageWithDPI(0, 300, ImageType.RGB);
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
ImageIO.write(coverImage, "jpg", baos);
return baos.toByteArray();
}
} catch (Exception e) {
log.warn("Failed to extract cover from PDF: {}", file.getAbsolutePath(), e);
return null;
} finally {
if (coverImage != null) {
coverImage.flush(); // Release native resources
}
}
}

View File

@@ -130,9 +130,11 @@ public class AppMigrationService {
try (var stream = Files.walk(thumbsDir)) {
stream.filter(Files::isRegularFile)
.forEach(path -> {
BufferedImage originalImage = null;
BufferedImage resized = null;
try {
// Load original image
BufferedImage originalImage = ImageIO.read(path.toFile());
originalImage = ImageIO.read(path.toFile());
if (originalImage == null) {
log.warn("Skipping non-image file: {}", path);
return;
@@ -150,7 +152,7 @@ public class AppMigrationService {
ImageIO.write(originalImage, "jpg", coverFile.toFile());
// Resize and save thumbnail.jpg
BufferedImage resized = FileService.resizeImage(originalImage, 250, 350);
resized = FileService.resizeImage(originalImage, 250, 350);
Path thumbnailFile = bookDir.resolve("thumbnail.jpg");
ImageIO.write(resized, "jpg", thumbnailFile.toFile());
@@ -158,6 +160,13 @@ public class AppMigrationService {
} catch (IOException e) {
log.error("Error processing file {}", path, e);
throw new UncheckedIOException(e);
} finally {
if (originalImage != null) {
originalImage.flush();
}
if (resized != null) {
resized.flush();
}
}
});
}

View File

@@ -94,9 +94,16 @@ public class PdfReaderService {
try (PDDocument document = Loader.loadPDF(new File(pdfPath.toFile().toURI()))) {
PDFRenderer renderer = new PDFRenderer(document);
for (int i = 0; i < document.getNumberOfPages(); i++) {
BufferedImage image = renderer.renderImageWithDPI(i, 200, ImageType.RGB);
Path outputFile = targetDir.resolve(String.format("%04d.jpg", i + 1));
ImageIO.write(image, "JPEG", outputFile.toFile());
BufferedImage image = null;
try {
image = renderer.renderImageWithDPI(i, 200, ImageType.RGB);
Path outputFile = targetDir.resolve(String.format("%04d.jpg", i + 1));
ImageIO.write(image, "JPEG", outputFile.toFile());
} finally {
if (image != null) {
image.flush(); // Release native resources
}
}
}
} catch (IOException e) {
log.error("Failed to render PDF pages from {}", pdfPath, e);

View File

@@ -143,15 +143,24 @@ public class FileService {
}
public static void saveImage(byte[] imageData, String filePath) throws IOException {
BufferedImage originalImage = ImageIO.read(new ByteArrayInputStream(imageData));
File outputFile = new File(filePath);
File parentDir = outputFile.getParentFile();
if (!parentDir.exists() && !parentDir.mkdirs()) {
throw new IOException("Failed to create directory: " + parentDir);
BufferedImage originalImage = null;
try {
originalImage = ImageIO.read(new ByteArrayInputStream(imageData));
if (originalImage == null) {
throw new IOException("Invalid image data: unable to decode image");
}
File outputFile = new File(filePath);
File parentDir = outputFile.getParentFile();
if (!parentDir.exists() && !parentDir.mkdirs()) {
throw new IOException("Failed to create directory: " + parentDir);
}
ImageIO.write(originalImage, IMAGE_FORMAT, outputFile);
log.info("Image saved successfully to: {}", filePath);
} finally {
if (originalImage != null) {
originalImage.flush(); // Release native resources
}
}
ImageIO.write(originalImage, IMAGE_FORMAT, outputFile);
originalImage.flush(); // Release native resources
log.info("Image saved successfully to: {}", filePath);
}
public BufferedImage downloadImageFromUrl(String imageUrl) throws IOException {
@@ -205,6 +214,7 @@ public class FileService {
if (!success) {
throw ApiError.FILE_READ_ERROR.createException("Failed to save cover images");
}
originalImage.flush(); // Release resources after processing
log.info("Cover images created and saved for book ID: {}", bookId);
} catch (Exception e) {
log.error("An error occurred while creating the thumbnail: {}", e.getMessage(), e);
@@ -219,6 +229,7 @@ public class FileService {
if (!success) {
throw ApiError.FILE_READ_ERROR.createException("Failed to save cover images");
}
originalImage.flush(); // Release resources after processing
log.info("Cover images created and saved from URL for book ID: {}", bookId);
} catch (Exception e) {
log.error("An error occurred while creating thumbnail from URL: {}", e.getMessage(), e);
@@ -227,44 +238,58 @@ public class FileService {
}
public boolean saveCoverImages(BufferedImage coverImage, long bookId) throws IOException {
String folderPath = getImagesFolder(bookId);
File folder = new File(folderPath);
if (!folder.exists() && !folder.mkdirs()) {
throw new IOException("Failed to create directory: " + folder.getAbsolutePath());
BufferedImage rgbImage = null;
BufferedImage resized = null;
BufferedImage thumb = null;
try {
String folderPath = getImagesFolder(bookId);
File folder = new File(folderPath);
if (!folder.exists() && !folder.mkdirs()) {
throw new IOException("Failed to create directory: " + folder.getAbsolutePath());
}
rgbImage = new BufferedImage(
coverImage.getWidth(),
coverImage.getHeight(),
BufferedImage.TYPE_INT_RGB
);
Graphics2D g = rgbImage.createGraphics();
g.drawImage(coverImage, 0, 0, Color.WHITE, null);
g.dispose();
// Note: coverImage is not flushed here - caller is responsible for its lifecycle
// Resize original image if too large to prevent OOM
double scale = Math.min(
(double) MAX_ORIGINAL_WIDTH / rgbImage.getWidth(),
(double) MAX_ORIGINAL_HEIGHT / rgbImage.getHeight()
);
if (scale < 1.0) {
resized = resizeImage(rgbImage, (int) (rgbImage.getWidth() * scale), (int) (rgbImage.getHeight() * scale));
rgbImage.flush(); // Release resources of the original large image
rgbImage = resized;
}
File originalFile = new File(folder, COVER_FILENAME);
boolean originalSaved = ImageIO.write(rgbImage, IMAGE_FORMAT, originalFile);
thumb = resizeImage(rgbImage, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
File thumbnailFile = new File(folder, THUMBNAIL_FILENAME);
boolean thumbnailSaved = ImageIO.write(thumb, IMAGE_FORMAT, thumbnailFile);
return originalSaved && thumbnailSaved;
} finally {
// Cleanup resources created within this method
// Note: resized may equal rgbImage after reassignment, avoid double-flush
if (rgbImage != null) {
rgbImage.flush();
}
if (resized != null && resized != rgbImage) {
resized.flush();
}
if (thumb != null) {
thumb.flush();
}
}
BufferedImage rgbImage = new BufferedImage(
coverImage.getWidth(),
coverImage.getHeight(),
BufferedImage.TYPE_INT_RGB
);
Graphics2D g = rgbImage.createGraphics();
g.drawImage(coverImage, 0, 0, Color.WHITE, null);
g.dispose();
coverImage.flush(); // Original input no longer needed after conversion
// Resize original image if too large to prevent OOM
double scale = Math.min(
(double) MAX_ORIGINAL_WIDTH / rgbImage.getWidth(),
(double) MAX_ORIGINAL_HEIGHT / rgbImage.getHeight()
);
if (scale < 1.0) {
BufferedImage resized = resizeImage(rgbImage, (int) (rgbImage.getWidth() * scale), (int) (rgbImage.getHeight() * scale));
rgbImage.flush(); // Release resources of the original large image
rgbImage = resized;
}
File originalFile = new File(folder, COVER_FILENAME);
boolean originalSaved = ImageIO.write(rgbImage, IMAGE_FORMAT, originalFile);
BufferedImage thumb = resizeImage(rgbImage, THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT);
File thumbnailFile = new File(folder, THUMBNAIL_FILENAME);
boolean thumbnailSaved = ImageIO.write(thumb, IMAGE_FORMAT, thumbnailFile);
// Cleanup resources
rgbImage.flush();
thumb.flush();
return originalSaved && thumbnailSaved;
}
public static void setBookCoverPath(BookMetadataEntity bookMetadataEntity) {
@@ -313,9 +338,8 @@ public class FileService {
}
log.info("Background image saved successfully for user {}: {}", userId, filename);
}
public void deleteBackgroundFile(String filename, Long userId) {
// Note: input image is not flushed here - caller is responsible for its lifecycle
} public void deleteBackgroundFile(String filename, Long userId) {
try {
String backgroundsFolder = getBackgroundsFolder(userId);
File file = new File(backgroundsFolder, filename);

View File

@@ -12,9 +12,13 @@ import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.awt.image.BufferedImage;
import javax.imageio.ImageIO;
import java.io.ByteArrayInputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.*;
class PdfMetadataExtractorTest {
@@ -51,12 +55,10 @@ class PdfMetadataExtractorTest {
@Test
void extractMetadata_shouldUseFilenameWithoutExtension_whenMetadataMissing() throws IOException {
// Arrange: Create a PDF with NO metadata title
// Name the file "Dune.pdf"
File pdfFile = tempDir.resolve("Dune.pdf").toFile();
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
// explicitly leaving metadata empty
doc.save(pdfFile);
}
@@ -83,4 +85,41 @@ class PdfMetadataExtractorTest {
// Assert
assertEquals("Harry Potter and the Sorcerer's Stone", result.getTitle());
}
@Test
void extractCover_validPdf_returnsJpegBytes() throws IOException {
// Arrange: Create a simple one-page PDF
File pdfFile = tempDir.resolve("cover-test.pdf").toFile();
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
doc.save(pdfFile);
}
// Act
byte[] coverBytes = extractor.extractCover(pdfFile);
// Assert: Should return non-null, decodable JPEG bytes
assertNotNull(coverBytes);
assertTrue(coverBytes.length > 0);
BufferedImage coverImage = ImageIO.read(new ByteArrayInputStream(coverBytes));
assertNotNull(coverImage);
assertTrue(coverImage.getWidth() > 0);
assertTrue(coverImage.getHeight() > 0);
}
@Test
void extractCover_invalidPdf_returnsNull() throws IOException {
// Arrange: Create a file that looks like a PDF but isn't
File invalidPdf = tempDir.resolve("invalid-pdf.pdf").toFile();
java.nio.file.Files.writeString(invalidPdf.toPath(), "this is not a real pdf");
// Act
byte[] coverBytes = extractor.extractCover(invalidPdf);
// Assert: Should return null for invalid PDF
assertNull(coverBytes);
}
}

View File

@@ -449,7 +449,7 @@ class FileServiceTest {
byte[] invalidData = "not an image".getBytes();
Path outputPath = tempDir.resolve("invalid.jpg");
assertThrows(IllegalArgumentException.class, () ->
assertThrows(IOException.class, () ->
fileService.saveImage(invalidData, outputPath.toString()));
}
@@ -458,7 +458,7 @@ class FileServiceTest {
byte[] emptyData = new byte[0];
Path outputPath = tempDir.resolve("empty.jpg");
assertThrows(IllegalArgumentException.class, () ->
assertThrows(IOException.class, () ->
fileService.saveImage(emptyData, outputPath.toString()));
}
@@ -534,7 +534,6 @@ class FileServiceTest {
assertTrue(result);
// Verify the saved image has no transparency
BufferedImage saved = ImageIO.read(
new File(fileService.getCoverFile(3L)));
assertFalse(saved.getColorModel().hasAlpha(), "Saved image should not have transparency");
@@ -565,6 +564,54 @@ class FileServiceTest {
() -> assertEquals(1200, saved.getHeight())
);
}
@Test
void largeImage_isScaledDownToMaxDimensions() throws IOException {
// Create a very large image that will trigger scaling
int largeWidth = 2000; // > MAX_ORIGINAL_WIDTH (1000)
int largeHeight = 3000; // > MAX_ORIGINAL_HEIGHT (1500)
BufferedImage largeImage = createTestImage(largeWidth, largeHeight);
boolean result = fileService.saveCoverImages(largeImage, 5L);
assertTrue(result);
BufferedImage savedCover = ImageIO.read(
new File(fileService.getCoverFile(5L)));
assertNotNull(savedCover);
assertTrue(savedCover.getWidth() <= 1000,
"Cover width should be <= MAX_ORIGINAL_WIDTH (1000), was: " + savedCover.getWidth());
assertTrue(savedCover.getHeight() <= 1500,
"Cover height should be <= MAX_ORIGINAL_HEIGHT (1500), was: " + savedCover.getHeight());
double originalRatio = (double) largeWidth / largeHeight;
double savedRatio = (double) savedCover.getWidth() / savedCover.getHeight();
assertEquals(originalRatio, savedRatio, 0.01, "Aspect ratio should be preserved");
}
@Test
void smallImage_maintainsOriginalDimensions() throws IOException {
// Create a small image that should NOT be scaled down
int smallWidth = 400; // < MAX_ORIGINAL_WIDTH (1000)
int smallHeight = 600; // < MAX_ORIGINAL_HEIGHT (1500)
BufferedImage smallImage = createTestImage(smallWidth, smallHeight);
boolean result = fileService.saveCoverImages(smallImage, 6L);
assertTrue(result);
BufferedImage savedCover = ImageIO.read(
new File(fileService.getCoverFile(6L)));
assertNotNull(savedCover);
assertEquals(smallWidth, savedCover.getWidth(),
"Small image width should be preserved");
assertEquals(smallHeight, savedCover.getHeight(),
"Small image height should be preserved");
}
}
@Nested