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 <meta refines="#id"> 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
This commit is contained in:
Ilya Shaplyko
2026-03-04 22:37:58 +01:00
committed by GitHub
parent 52e865b880
commit 463ff3eae5
2 changed files with 567 additions and 79 deletions

View File

@@ -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 <meta refines="#id"> 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);

View File

@@ -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 = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Test Book</dc:title>
</metadata>
</package>""";
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Test Book</dc:title>
</metadata>
</package>""";
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Test Book</dc:title>
</metadata>
</package>""";
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>EPUB2 Book</dc:title>
</metadata>
</package>""";
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Original Title</dc:title>
</metadata>
</package>""";
File epubFile = createEpubWithOpf(opfContent, "test-mimetype-" + System.nanoTime() + ".epub");
writer.saveMetadataToFile(epubFile, metadata, null, new MetadataClearFlags());
try (ZipFile zf = new ZipFile(epubFile)) {
Enumeration<? extends ZipEntry> 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 = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Original Title</dc:title>
</metadata>
</package>""";
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<? extends ZipEntry> 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 = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Series Book</dc:title>
</metadata>
</package>""";
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Series Book</dc:title>
</metadata>
</package>""";
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Main Title</dc:title>
</metadata>
</package>""";
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Main Title</dc:title>
</metadata>
</package>""";
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Test Book</dc:title>
</metadata>
</package>""";
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 = """
<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:title>Test Book</dc:title>
</metadata>
</package>""";
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");