mirror of
https://github.com/booklore-app/booklore.git
synced 2025-12-23 22:28:11 -05:00
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:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user