From 463ff3eae5847b98ff9cccd439fdc43252b5c55d Mon Sep 17 00:00:00 2001 From: Ilya Shaplyko Date: Wed, 4 Mar 2026 22:37:58 +0100 Subject: [PATCH] fix(metadata): ensure EPUB version-aware metadata writing (#2998) * fix(metadata): ensure EPUB version-aware metadata writing EpubMetadataWriter unconditionally wrote EPUB3-only constructs into all EPUB files regardless of version, producing invalid OPF documents for EPUB3 files (e.g. opf:file-as/opf:role attributes on dc:creator) and writing EPUB3-only elements into EPUB2 files. Changes: - createCreatorElement: EPUB3 uses for file-as/role; EPUB2 uses opf: attributes on dc:creator - addFolderContentsToZip: mimetype is now STORED (uncompressed) and written as the first ZIP entry per EPUB spec - replaceBelongsToCollection: EPUB3 uses belongs-to-collection with refines; EPUB2 uses calibre:series/calibre:series_index convention - addSubtitleToTitle: EPUB3 uses separate dc:title with title-type refinement; EPUB2 stores subtitle via booklore:subtitle metadata only - addBookloreMetadata/createBookloreMetaElement: EPUB3 uses property attribute with prefix; EPUB2 uses name/content attribute form - removeAllBookloreMetadata: now handles both EPUB3 property and EPUB2 name attributes - cleanupCalibreArtifacts: preserves calibre:series and calibre:series_index metas used for EPUB2 series - organizeMetadataElements: correctly categorizes EPUB2-style series and booklore metas into their respective buckets - addBookloreMetadata: writes booklore:subtitle for round-trip fidelity - Added isEpub3() helper method Closes #2997 * fix(metadata): address review feedback for EPUB version-aware writing - Only preserve calibre:series/calibre:series_index for EPUB2 in cleanupCalibreArtifacts; EPUB3 files now properly remove stale entries - Use isEpub3() helper in createCreatorElement instead of inline detection - Add trim() to isEpub3() to handle whitespace in version attribute - Log warning when mimetype file is missing from extracted EPUB --- .../metadata/writer/EpubMetadataWriter.java | 266 ++++++++---- .../writer/EpubMetadataWriterTest.java | 380 ++++++++++++++++++ 2 files changed, 567 insertions(+), 79 deletions(-) diff --git a/booklore-api/src/main/java/org/booklore/service/metadata/writer/EpubMetadataWriter.java b/booklore-api/src/main/java/org/booklore/service/metadata/writer/EpubMetadataWriter.java index 3369f8e50..a7736f2fd 100644 --- a/booklore-api/src/main/java/org/booklore/service/metadata/writer/EpubMetadataWriter.java +++ b/booklore-api/src/main/java/org/booklore/service/metadata/writer/EpubMetadataWriter.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.lingala.zip4j.ZipFile; import net.lingala.zip4j.model.ZipParameters; +import net.lingala.zip4j.model.enums.CompressionMethod; import org.apache.commons.lang3.StringUtils; import org.booklore.model.MetadataClearFlags; import org.booklore.model.dto.settings.MetadataPersistenceSettings; @@ -108,7 +109,7 @@ public class EpubMetadataWriter implements MetadataWriter { String first = parts.length > 1 ? parts[0] : ""; String last = parts.length > 1 ? parts[1] : parts[0]; String fileAs = last + ", " + first; - metadataElement.appendChild(createCreatorElement(opfDoc, name, fileAs, "aut")); + metadataElement.appendChild(createCreatorElement(opfDoc, metadataElement, name, fileAs, "aut")); } } hasChanges[0] = true; @@ -523,13 +524,31 @@ public class EpubMetadataWriter implements MetadataWriter { } private void addFolderContentsToZip(ZipFile zipFile, File baseDir, File currentDir) throws IOException { + // EPUB spec requires mimetype to be the first entry in the ZIP, uncompressed (STORED) + if (baseDir.equals(currentDir)) { + File mimetypeFile = new File(baseDir, "mimetype"); + if (mimetypeFile.exists()) { + ZipParameters mimetypeParams = new ZipParameters(); + mimetypeParams.setFileNameInZip("mimetype"); + mimetypeParams.setCompressionMethod(CompressionMethod.STORE); + zipFile.addFile(mimetypeFile, mimetypeParams); + } else { + log.warn("EPUB mimetype file not found in extracted directory — output may be spec-invalid"); + } + } + File[] files = Objects.requireNonNull(currentDir.listFiles()); for (File file : files) { if (file.isDirectory()) { addFolderContentsToZip(zipFile, baseDir, file); } else { + // Skip mimetype — already added as the first entry + String relativePath = baseDir.toPath().relativize(file.toPath()).toString().replace(File.separatorChar, '/'); + if ("mimetype".equals(relativePath)) { + continue; + } ZipParameters params = new ZipParameters(); - params.setFileNameInZip(baseDir.toPath().relativize(file.toPath()).toString().replace(File.separatorChar, '/')); + params.setFileNameInZip(relativePath); zipFile.addFile(file, params); } } @@ -620,15 +639,43 @@ public class EpubMetadataWriter implements MetadataWriter { } } - private Element createCreatorElement(Document doc, String fullName, String fileAs, String role) { + private Element createCreatorElement(Document doc, Element metadataElement, String fullName, String fileAs, String role) { Element creator = doc.createElementNS("http://purl.org/dc/elements/1.1/", "creator"); creator.setPrefix("dc"); creator.setTextContent(fullName); - if (fileAs != null) { - creator.setAttributeNS(OPF_NS, "opf:file-as", fileAs); - } - if (role != null) { - creator.setAttributeNS(OPF_NS, "opf:role", role); + + boolean isEpub3 = isEpub3(doc); + + if (isEpub3) { + // EPUB3: use elements instead of opf: attributes + String creatorId = "creator-" + UUID.randomUUID().toString().substring(0, 8); + creator.setAttribute("id", creatorId); + + if (fileAs != null) { + Element fileAsMeta = doc.createElementNS(OPF_NS, "meta"); + fileAsMeta.setPrefix("opf"); + fileAsMeta.setAttribute("refines", "#" + creatorId); + fileAsMeta.setAttribute("property", "file-as"); + fileAsMeta.setTextContent(fileAs); + metadataElement.appendChild(fileAsMeta); + } + if (role != null) { + Element roleMeta = doc.createElementNS(OPF_NS, "meta"); + roleMeta.setPrefix("opf"); + roleMeta.setAttribute("refines", "#" + creatorId); + roleMeta.setAttribute("property", "role"); + roleMeta.setAttribute("scheme", "marc:relators"); + roleMeta.setTextContent(role); + metadataElement.appendChild(roleMeta); + } + } else { + // EPUB2: use opf: attributes directly on dc:creator + if (fileAs != null) { + creator.setAttributeNS(OPF_NS, "opf:file-as", fileAs); + } + if (role != null) { + creator.setAttributeNS(OPF_NS, "opf:role", role); + } } return creator; } @@ -707,22 +754,32 @@ public class EpubMetadataWriter implements MetadataWriter { } } + private boolean isEpub3(Document doc) { + String version = doc.getDocumentElement().getAttribute("version"); + return version != null && version.trim().startsWith("3"); + } + private void removeAllBookloreMetadata(Element metadataElement) { NodeList metas = metadataElement.getElementsByTagNameNS("*", "meta"); for (int i = metas.getLength() - 1; i >= 0; i--) { Element meta = (Element) metas.item(i); String property = meta.getAttribute("property"); - if (property.startsWith("booklore:")) { + String name = meta.getAttribute("name"); + if (property.startsWith("booklore:") || name.startsWith("booklore:")) { metadataElement.removeChild(meta); } } } private void replaceBelongsToCollection(Element metadataElement, Document doc, String seriesName, Float seriesNumber, boolean[] hasChanges) { + boolean epub3 = isEpub3(doc); + + // Remove existing EPUB3 collection metas NodeList metas = metadataElement.getElementsByTagNameNS("*", "meta"); for (int i = metas.getLength() - 1; i >= 0; i--) { Element meta = (Element) metas.item(i); String property = meta.getAttribute("property"); + String name = meta.getAttribute("name"); if ("belongs-to-collection".equals(property) || "collection-type".equals(property) || "group-position".equals(property)) { String id = meta.getAttribute("id"); metadataElement.removeChild(meta); @@ -730,36 +787,60 @@ public class EpubMetadataWriter implements MetadataWriter { removeMetaByRefines(metadataElement, "#" + id); } } + // Also remove EPUB2-style series metas + if ("calibre:series".equals(name) || "calibre:series_index".equals(name)) { + metadataElement.removeChild(meta); + } } if (StringUtils.isNotBlank(seriesName)) { - String collectionId = "collection-" + UUID.randomUUID().toString().substring(0, 8); - - Element collectionMeta = doc.createElementNS(OPF_NS, "meta"); - collectionMeta.setPrefix("opf"); - collectionMeta.setAttribute("id", collectionId); - collectionMeta.setAttribute("property", "belongs-to-collection"); - collectionMeta.setTextContent(seriesName); - metadataElement.appendChild(collectionMeta); - - Element typeMeta = doc.createElementNS(OPF_NS, "meta"); - typeMeta.setPrefix("opf"); - typeMeta.setAttribute("property", "collection-type"); - typeMeta.setAttribute("refines", "#" + collectionId); - typeMeta.setTextContent("series"); - metadataElement.appendChild(typeMeta); - - if (seriesNumber != null && seriesNumber > 0) { - Element positionMeta = doc.createElementNS(OPF_NS, "meta"); - positionMeta.setPrefix("opf"); - positionMeta.setAttribute("property", "group-position"); - positionMeta.setAttribute("refines", "#" + collectionId); - if (seriesNumber % 1.0f == 0) { - positionMeta.setTextContent(String.format("%.0f", seriesNumber)); - } else { - positionMeta.setTextContent(String.valueOf(seriesNumber)); + if (epub3) { + // EPUB3: use belongs-to-collection with refines + String collectionId = "collection-" + UUID.randomUUID().toString().substring(0, 8); + + Element collectionMeta = doc.createElementNS(OPF_NS, "meta"); + collectionMeta.setPrefix("opf"); + collectionMeta.setAttribute("id", collectionId); + collectionMeta.setAttribute("property", "belongs-to-collection"); + collectionMeta.setTextContent(seriesName); + metadataElement.appendChild(collectionMeta); + + Element typeMeta = doc.createElementNS(OPF_NS, "meta"); + typeMeta.setPrefix("opf"); + typeMeta.setAttribute("property", "collection-type"); + typeMeta.setAttribute("refines", "#" + collectionId); + typeMeta.setTextContent("series"); + metadataElement.appendChild(typeMeta); + + if (seriesNumber != null && seriesNumber > 0) { + Element positionMeta = doc.createElementNS(OPF_NS, "meta"); + positionMeta.setPrefix("opf"); + positionMeta.setAttribute("property", "group-position"); + positionMeta.setAttribute("refines", "#" + collectionId); + if (seriesNumber % 1.0f == 0) { + positionMeta.setTextContent(String.format("%.0f", seriesNumber)); + } else { + positionMeta.setTextContent(String.valueOf(seriesNumber)); + } + metadataElement.appendChild(positionMeta); + } + } else { + // EPUB2: use calibre:series convention (widely supported by e-readers) + Element seriesMeta = doc.createElementNS(doc.getDocumentElement().getNamespaceURI(), "meta"); + seriesMeta.setAttribute("name", "calibre:series"); + seriesMeta.setAttribute("content", seriesName); + metadataElement.appendChild(seriesMeta); + + if (seriesNumber != null && seriesNumber > 0) { + Element indexMeta = doc.createElementNS(doc.getDocumentElement().getNamespaceURI(), "meta"); + indexMeta.setAttribute("name", "calibre:series_index"); + if (seriesNumber % 1.0f == 0) { + indexMeta.setAttribute("content", String.format("%.0f", seriesNumber)); + } else { + indexMeta.setAttribute("content", String.valueOf(seriesNumber)); + } + metadataElement.appendChild(indexMeta); } - metadataElement.appendChild(positionMeta); } hasChanges[0] = true; @@ -768,6 +849,9 @@ public class EpubMetadataWriter implements MetadataWriter { private void addSubtitleToTitle(Element metadataElement, Document doc, String subtitle) { final String DC_NS = "http://purl.org/dc/elements/1.1/"; + boolean epub3 = isEpub3(doc); + + // Remove existing subtitle elements (both EPUB2 and EPUB3 forms) NodeList metas = metadataElement.getElementsByTagNameNS("*", "meta"); for (int i = metas.getLength() - 1; i >= 0; i--) { Element meta = (Element) metas.item(i); @@ -787,89 +871,102 @@ public class EpubMetadataWriter implements MetadataWriter { metadataElement.removeChild(meta); } } - - String subtitleId = "subtitle-" + UUID.randomUUID().toString().substring(0, 8); - Element subtitleElement = doc.createElementNS(DC_NS, "title"); - subtitleElement.setPrefix("dc"); - subtitleElement.setAttribute("id", subtitleId); - subtitleElement.setTextContent(subtitle); - metadataElement.appendChild(subtitleElement); - - Element typeMeta = doc.createElementNS(OPF_NS, "meta"); - typeMeta.setPrefix("opf"); - typeMeta.setAttribute("refines", "#" + subtitleId); - typeMeta.setAttribute("property", "title-type"); - typeMeta.setTextContent("subtitle"); - metadataElement.appendChild(typeMeta); + + if (epub3) { + // EPUB3: add subtitle as separate dc:title with title-type refinement + String subtitleId = "subtitle-" + UUID.randomUUID().toString().substring(0, 8); + Element subtitleElement = doc.createElementNS(DC_NS, "title"); + subtitleElement.setPrefix("dc"); + subtitleElement.setAttribute("id", subtitleId); + subtitleElement.setTextContent(subtitle); + metadataElement.appendChild(subtitleElement); + + Element typeMeta = doc.createElementNS(OPF_NS, "meta"); + typeMeta.setPrefix("opf"); + typeMeta.setAttribute("refines", "#" + subtitleId); + typeMeta.setAttribute("property", "title-type"); + typeMeta.setTextContent("subtitle"); + metadataElement.appendChild(typeMeta); + } + // EPUB2: subtitle is stored only via booklore:subtitle metadata (written in addBookloreMetadata). + // No modification to dc:title is needed — this preserves round-trip fidelity. } private void addBookloreMetadata(Element metadataElement, Document doc, BookMetadataEntity metadata) { - Element packageElement = doc.getDocumentElement(); - String existingPrefix = packageElement.getAttribute("prefix"); - String bookloreNamespace = "booklore: http://booklore.org/metadata/1.0/"; - - if (!existingPrefix.contains("booklore:")) { - if (existingPrefix.isEmpty()) { - packageElement.setAttribute("prefix", bookloreNamespace); - } else { - packageElement.setAttribute("prefix", existingPrefix.trim() + " " + bookloreNamespace); + boolean epub3 = isEpub3(doc); + + if (epub3) { + Element packageElement = doc.getDocumentElement(); + String existingPrefix = packageElement.getAttribute("prefix"); + String bookloreNamespace = "booklore: http://booklore.org/metadata/1.0/"; + + if (!existingPrefix.contains("booklore:")) { + if (existingPrefix.isEmpty()) { + packageElement.setAttribute("prefix", bookloreNamespace); + } else { + packageElement.setAttribute("prefix", existingPrefix.trim() + " " + bookloreNamespace); + } } } removeAllBookloreMetadata(metadataElement); + if (StringUtils.isNotBlank(metadata.getSubtitle())) { + metadataElement.appendChild(createBookloreMetaElement(doc, "subtitle", metadata.getSubtitle(), epub3)); + } + if (metadata.getPageCount() != null && metadata.getPageCount() > 0) { - metadataElement.appendChild(createBookloreMetaElement(doc, "page_count", String.valueOf(metadata.getPageCount()))); + metadataElement.appendChild(createBookloreMetaElement(doc, "page_count", String.valueOf(metadata.getPageCount()), epub3)); } if (metadata.getSeriesTotal() != null && metadata.getSeriesTotal() > 0) { - metadataElement.appendChild(createBookloreMetaElement(doc, "series_total", String.valueOf(metadata.getSeriesTotal()))); + metadataElement.appendChild(createBookloreMetaElement(doc, "series_total", String.valueOf(metadata.getSeriesTotal()), epub3)); } if (metadata.getAmazonRating() != null && metadata.getAmazonRating() > 0) { - metadataElement.appendChild(createBookloreMetaElement(doc, "amazon_rating", String.valueOf(metadata.getAmazonRating()))); + metadataElement.appendChild(createBookloreMetaElement(doc, "amazon_rating", String.valueOf(metadata.getAmazonRating()), epub3)); } if (metadata.getAmazonReviewCount() != null && metadata.getAmazonReviewCount() > 0) { - metadataElement.appendChild(createBookloreMetaElement(doc, "amazon_review_count", String.valueOf(metadata.getAmazonReviewCount()))); + metadataElement.appendChild(createBookloreMetaElement(doc, "amazon_review_count", String.valueOf(metadata.getAmazonReviewCount()), epub3)); } if (metadata.getGoodreadsRating() != null && metadata.getGoodreadsRating() > 0) { - metadataElement.appendChild(createBookloreMetaElement(doc, "goodreads_rating", String.valueOf(metadata.getGoodreadsRating()))); + metadataElement.appendChild(createBookloreMetaElement(doc, "goodreads_rating", String.valueOf(metadata.getGoodreadsRating()), epub3)); } if (metadata.getGoodreadsReviewCount() != null && metadata.getGoodreadsReviewCount() > 0) { - metadataElement.appendChild(createBookloreMetaElement(doc, "goodreads_review_count", String.valueOf(metadata.getGoodreadsReviewCount()))); + metadataElement.appendChild(createBookloreMetaElement(doc, "goodreads_review_count", String.valueOf(metadata.getGoodreadsReviewCount()), epub3)); } if (metadata.getHardcoverRating() != null && metadata.getHardcoverRating() > 0) { - metadataElement.appendChild(createBookloreMetaElement(doc, "hardcover_rating", String.valueOf(metadata.getHardcoverRating()))); + metadataElement.appendChild(createBookloreMetaElement(doc, "hardcover_rating", String.valueOf(metadata.getHardcoverRating()), epub3)); } if (metadata.getHardcoverReviewCount() != null && metadata.getHardcoverReviewCount() > 0) { - metadataElement.appendChild(createBookloreMetaElement(doc, "hardcover_review_count", String.valueOf(metadata.getHardcoverReviewCount()))); + metadataElement.appendChild(createBookloreMetaElement(doc, "hardcover_review_count", String.valueOf(metadata.getHardcoverReviewCount()), epub3)); } if (metadata.getLubimyczytacRating() != null && metadata.getLubimyczytacRating() > 0) { - metadataElement.appendChild(createBookloreMetaElement(doc, "lubimyczytac_rating", String.valueOf(metadata.getLubimyczytacRating()))); + metadataElement.appendChild(createBookloreMetaElement(doc, "lubimyczytac_rating", String.valueOf(metadata.getLubimyczytacRating()), epub3)); } if (metadata.getRanobedbRating() != null && metadata.getRanobedbRating() > 0) { - metadataElement.appendChild(createBookloreMetaElement(doc, "ranobedb_rating", String.valueOf(metadata.getRanobedbRating()))); + metadataElement.appendChild(createBookloreMetaElement(doc, "ranobedb_rating", String.valueOf(metadata.getRanobedbRating()), epub3)); } if (metadata.getMoods() != null && !metadata.getMoods().isEmpty()) { String moodsJson = "[" + String.join(", ", metadata.getMoods().stream() .map(mood -> "\"" + mood.getName().replace("\"", "\\\"") + "\"") .toList()) + "]"; - metadataElement.appendChild(createBookloreMetaElement(doc, "moods", moodsJson)); + metadataElement.appendChild(createBookloreMetaElement(doc, "moods", moodsJson, epub3)); } if (metadata.getTags() != null && !metadata.getTags().isEmpty()) { String tagsJson = "[" + String.join(", ", metadata.getTags().stream() .map(tag -> "\"" + tag.getName().replace("\"", "\\\"") + "\"") .toList()) + "]"; - metadataElement.appendChild(createBookloreMetaElement(doc, "tags", tagsJson)); + metadataElement.appendChild(createBookloreMetaElement(doc, "tags", tagsJson, epub3)); } if (metadata.getAgeRating() != null) { @@ -881,12 +978,20 @@ public class EpubMetadataWriter implements MetadataWriter { } } - private Element createBookloreMetaElement(Document doc, String property, String value) { - Element meta = doc.createElementNS(OPF_NS, "meta"); - meta.setPrefix("opf"); - meta.setAttribute("property", "booklore:" + property); - meta.setTextContent(value); - return meta; + private Element createBookloreMetaElement(Document doc, String property, String value, boolean epub3) { + if (epub3) { + Element meta = doc.createElementNS(OPF_NS, "meta"); + meta.setPrefix("opf"); + meta.setAttribute("property", "booklore:" + property); + meta.setTextContent(value); + return meta; + } else { + // EPUB2: use name/content attribute form + Element meta = doc.createElementNS(doc.getDocumentElement().getNamespaceURI(), "meta"); + meta.setAttribute("name", "booklore:" + property); + meta.setAttribute("content", value); + return meta; + } } private void cleanupCalibreArtifacts(Element metadataElement, Document doc) { @@ -936,7 +1041,8 @@ public class EpubMetadataWriter implements MetadataWriter { String property = meta.getAttribute("property"); String name = meta.getAttribute("name"); - if (property.startsWith("calibre:") || name.startsWith("calibre:")) { + boolean isCalibreSeries = !isEpub3(doc) && ("calibre:series".equals(name) || "calibre:series_index".equals(name)); + if (!isCalibreSeries && (property.startsWith("calibre:") || name.startsWith("calibre:"))) { metadataElement.removeChild(meta); } } @@ -980,11 +1086,13 @@ public class EpubMetadataWriter implements MetadataWriter { } } else if ("meta".equals(localName)) { String property = elem.getAttribute("property"); - if (property.startsWith("booklore:")) { + String name = elem.getAttribute("name"); + if (property.startsWith("booklore:") || name.startsWith("booklore:")) { bookloreMetas.add(elem); } else if (property.equals("dcterms:modified") || property.equals("calibre:timestamp")) { modifiedMetas.add(elem); - } else if (property.equals("belongs-to-collection") || property.equals("collection-type") || property.equals("group-position")) { + } else if (property.equals("belongs-to-collection") || property.equals("collection-type") || property.equals("group-position") + || "calibre:series".equals(name) || "calibre:series_index".equals(name)) { seriesMetas.add(elem); } else { otherMetas.add(elem); diff --git a/booklore-api/src/test/java/org/booklore/service/metadata/writer/EpubMetadataWriterTest.java b/booklore-api/src/test/java/org/booklore/service/metadata/writer/EpubMetadataWriterTest.java index b03737c68..e782320f4 100644 --- a/booklore-api/src/test/java/org/booklore/service/metadata/writer/EpubMetadataWriterTest.java +++ b/booklore-api/src/test/java/org/booklore/service/metadata/writer/EpubMetadataWriterTest.java @@ -16,16 +16,23 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; import java.io.*; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Collections; +import java.util.Enumeration; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -184,6 +191,379 @@ class EpubMetadataWriterTest { } } + @Nested + @DisplayName("EPUB3 Creator Metadata Tests") + class Epub3CreatorTests { + + @Test + @DisplayName("Should use meta refines for file-as in EPUB3") + void epub3_shouldUseMetaRefines_forFileAs() throws Exception { + String opfContent = """ + + + + Test Book + + """; + + File epubFile = createEpubWithOpf(opfContent, "test-epub3-creator-" + System.nanoTime() + ".epub"); + writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags()); + + Document doc = parseOpf(epubFile); + NodeList creators = doc.getElementsByTagNameNS("http://purl.org/dc/elements/1.1/", "creator"); + assertThat(creators.getLength()).isGreaterThan(0); + + Element creator = (Element) creators.item(0); + assertThat(creator.getTextContent()).isEqualTo("Test Author"); + assertThat(creator.getAttribute("id")).isNotEmpty(); + + // Should NOT have opf:file-as or opf:role attributes + String fileAsAttr = creator.getAttributeNS("http://www.idpf.org/2007/opf", "file-as"); + String roleAttr = creator.getAttributeNS("http://www.idpf.org/2007/opf", "role"); + assertThat(fileAsAttr).isEmpty(); + assertThat(roleAttr).isEmpty(); + + // Should have meta refines elements instead + String creatorId = creator.getAttribute("id"); + String opfContent2 = readOpfContent(epubFile); + assertThat(opfContent2).contains("refines=\"#" + creatorId + "\""); + assertThat(opfContent2).contains("property=\"file-as\""); + assertThat(opfContent2).contains("property=\"role\""); + } + + @Test + @DisplayName("Should include role with marc:relators scheme in EPUB3") + void epub3_shouldIncludeRoleScheme() throws Exception { + String opfContent = """ + + + + Test Book + + """; + + File epubFile = createEpubWithOpf(opfContent, "test-epub3-role-" + System.nanoTime() + ".epub"); + writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags()); + + String content = readOpfContent(epubFile); + assertThat(content).contains("scheme=\"marc:relators\""); + assertThat(content).contains(">aut<"); + } + } + + @Nested + @DisplayName("EPUB2 Creator Metadata Tests") + class Epub2CreatorTests { + + @Test + @DisplayName("Should use opf:file-as attribute on dc:creator in EPUB2") + void epub2_shouldUseOpfAttributes() throws Exception { + String opfContent = """ + + + + Test Book + + """; + + File epubFile = createEpubWithOpf(opfContent, "test-epub2-creator-" + System.nanoTime() + ".epub"); + writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags()); + + String content = readOpfContent(epubFile); + assertThat(content).contains("opf:file-as="); + assertThat(content).contains("opf:role="); + // Should NOT have meta refines for creators + assertThat(content).doesNotContain("property=\"file-as\""); + assertThat(content).doesNotContain("property=\"role\""); + } + + @Test + @DisplayName("Should preserve EPUB2 structure without EPUB3 constructs") + void epub2_shouldNotContainEpub3Constructs() throws Exception { + String opfContent = """ + + + + EPUB2 Book + + """; + + metadata.setSubtitle("A Subtitle"); + metadata.setSeriesName("Test Series"); + metadata.setSeriesNumber(3.0f); + metadata.setPageCount(200); + + File epubFile = createEpubWithOpf(opfContent, "test-epub2-no-epub3-" + System.nanoTime() + ".epub"); + writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags()); + + String content = readOpfContent(epubFile); + // EPUB2 should not have prefix attribute on package + assertThat(content).doesNotContain("prefix="); + // EPUB2 should not have property= metas for series + assertThat(content).doesNotContain("property=\"belongs-to-collection\""); + assertThat(content).doesNotContain("property=\"collection-type\""); + assertThat(content).doesNotContain("property=\"group-position\""); + // EPUB2 should not have property= metas for subtitles + assertThat(content).doesNotContain("property=\"title-type\""); + } + } + + @Nested + @DisplayName("Mimetype ZIP Entry Tests") + class MimetypeZipTests { + + @Test + @DisplayName("Should store mimetype as first uncompressed entry in ZIP") + void saveMetadata_shouldStoreMimetypeFirst() throws Exception { + String opfContent = """ + + + + Original Title + + """; + + File epubFile = createEpubWithOpf(opfContent, "test-mimetype-" + System.nanoTime() + ".epub"); + writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags()); + + try (ZipFile zf = new ZipFile(epubFile)) { + Enumeration entries = zf.entries(); + assertThat(entries.hasMoreElements()).isTrue(); + + ZipEntry firstEntry = entries.nextElement(); + assertThat(firstEntry.getName()).isEqualTo("mimetype"); + assertThat(firstEntry.getMethod()).isEqualTo(ZipEntry.STORED); + + // Verify mimetype content + try (InputStream is = zf.getInputStream(firstEntry)) { + String content = new String(is.readAllBytes(), StandardCharsets.UTF_8); + assertThat(content).isEqualTo("application/epub+zip"); + } + } + } + + @Test + @DisplayName("Should not duplicate mimetype entry in ZIP") + void saveMetadata_shouldNotDuplicateMimetype() throws Exception { + String opfContent = """ + + + + Original Title + + """; + + File epubFile = createEpubWithOpf(opfContent, "test-no-dup-mimetype-" + System.nanoTime() + ".epub"); + writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags()); + + try (ZipFile zf = new ZipFile(epubFile)) { + int mimetypeCount = 0; + Enumeration entries = zf.entries(); + while (entries.hasMoreElements()) { + if ("mimetype".equals(entries.nextElement().getName())) { + mimetypeCount++; + } + } + assertThat(mimetypeCount).isEqualTo(1); + } + } + } + + @Nested + @DisplayName("EPUB3 Series Metadata Tests") + class Epub3SeriesTests { + + @Test + @DisplayName("Should use belongs-to-collection for series in EPUB3") + void epub3_shouldUseBelongsToCollection() throws Exception { + String opfContent = """ + + + + Series Book + + """; + + metadata.setSeriesName("The Dark Tower"); + metadata.setSeriesNumber(3.0f); + + File epubFile = createEpubWithOpf(opfContent, "test-epub3-series-" + System.nanoTime() + ".epub"); + writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags()); + + String content = readOpfContent(epubFile); + assertThat(content).contains("property=\"belongs-to-collection\""); + assertThat(content).contains("The Dark Tower"); + assertThat(content).contains("property=\"collection-type\""); + assertThat(content).contains("series"); + assertThat(content).contains("property=\"group-position\""); + assertThat(content).contains(">3<"); + } + } + + @Nested + @DisplayName("EPUB2 Series Metadata Tests") + class Epub2SeriesTests { + + @Test + @DisplayName("Should use calibre:series convention for series in EPUB2") + void epub2_shouldUseCalibreSeries() throws Exception { + String opfContent = """ + + + + Series Book + + """; + + metadata.setSeriesName("Wheel of Time"); + metadata.setSeriesNumber(5.0f); + + File epubFile = createEpubWithOpf(opfContent, "test-epub2-series-" + System.nanoTime() + ".epub"); + writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags()); + + String content = readOpfContent(epubFile); + assertThat(content).contains("name=\"calibre:series\""); + assertThat(content).contains("content=\"Wheel of Time\""); + assertThat(content).contains("name=\"calibre:series_index\""); + assertThat(content).contains("content=\"5\""); + // Should NOT contain EPUB3 series constructs + assertThat(content).doesNotContain("belongs-to-collection"); + assertThat(content).doesNotContain("collection-type"); + assertThat(content).doesNotContain("group-position"); + } + } + + @Nested + @DisplayName("EPUB3 Subtitle Tests") + class Epub3SubtitleTests { + + @Test + @DisplayName("Should add subtitle as separate dc:title with title-type refinement in EPUB3") + void epub3_shouldAddSubtitleWithRefinement() throws Exception { + String opfContent = """ + + + + Main Title + + """; + + metadata.setTitle("Main Title"); + metadata.setSubtitle("A Great Subtitle"); + + File epubFile = createEpubWithOpf(opfContent, "test-epub3-subtitle-" + System.nanoTime() + ".epub"); + writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags()); + + String content = readOpfContent(epubFile); + assertThat(content).contains("A Great Subtitle"); + assertThat(content).contains("property=\"title-type\""); + assertThat(content).contains(">subtitle<"); + } + } + + @Nested + @DisplayName("EPUB2 Subtitle Tests") + class Epub2SubtitleTests { + + @Test + @DisplayName("Should store subtitle via booklore:subtitle metadata in EPUB2 without modifying dc:title") + void epub2_shouldStoreSubtitleInBookloreMeta() throws Exception { + String opfContent = """ + + + + Main Title + + """; + + metadata.setTitle("Main Title"); + metadata.setSubtitle("A Great Subtitle"); + + File epubFile = createEpubWithOpf(opfContent, "test-epub2-subtitle-" + System.nanoTime() + ".epub"); + writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags()); + + String content = readOpfContent(epubFile); + // Title should remain unchanged (not appended with subtitle) + assertThat(content).contains(">Main Title<"); + assertThat(content).doesNotContain("Main Title: A Great Subtitle"); + // Subtitle should be stored via booklore:subtitle metadata + assertThat(content).contains("name=\"booklore:subtitle\""); + assertThat(content).contains("content=\"A Great Subtitle\""); + // Should NOT have EPUB3 title-type refinement + assertThat(content).doesNotContain("property=\"title-type\""); + } + } + + @Nested + @DisplayName("EPUB3 Booklore Metadata Tests") + class Epub3BookloreMetadataTests { + + @Test + @DisplayName("Should use property attribute for booklore metadata in EPUB3") + void epub3_shouldUsePropertyAttribute() throws Exception { + String opfContent = """ + + + + Test Book + + """; + + metadata.setPageCount(350); + + File epubFile = createEpubWithOpf(opfContent, "test-epub3-booklore-" + System.nanoTime() + ".epub"); + writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags()); + + String content = readOpfContent(epubFile); + assertThat(content).contains("property=\"booklore:page_count\""); + assertThat(content).contains(">350<"); + assertThat(content).contains("prefix="); + assertThat(content).contains("booklore:"); + } + } + + @Nested + @DisplayName("EPUB2 Booklore Metadata Tests") + class Epub2BookloreMetadataTests { + + @Test + @DisplayName("Should use name/content attributes for booklore metadata in EPUB2") + void epub2_shouldUseNameContentAttributes() throws Exception { + String opfContent = """ + + + + Test Book + + """; + + metadata.setPageCount(350); + + File epubFile = createEpubWithOpf(opfContent, "test-epub2-booklore-" + System.nanoTime() + ".epub"); + writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags()); + + String content = readOpfContent(epubFile); + assertThat(content).contains("name=\"booklore:page_count\""); + assertThat(content).contains("content=\"350\""); + // Should NOT have EPUB3 prefix attribute + assertThat(content).doesNotContain("prefix="); + // Should NOT use property= form + assertThat(content).doesNotContain("property=\"booklore:"); + } + } + + private Document parseOpf(File epubFile) throws Exception { + try (ZipFile zf = new ZipFile(epubFile)) { + ZipEntry ze = zf.getEntry("OEBPS/content.opf"); + try (InputStream is = zf.getInputStream(ze)) { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(is); + } + } + } + private String readOpfContent(File epubFile) throws IOException { try (ZipFile zf = new ZipFile(epubFile)) { ZipEntry ze = zf.getEntry("OEBPS/content.opf");