diff --git a/Dockerfile b/Dockerfile index 86e3e0571a..3e0e772bf6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,28 +50,6 @@ RUN set -eux; \ FROM mwader/static-ffmpeg:8.1 AS ffprobe-layer -FROM scratch AS kepubify-layer-amd64 - -ARG KEPUBIFY_VERSION="4.0.4" -ARG KEPUBIFY_AMD64_CHECKSUM="sha256:37d7628d26c5c906f607f24b36f781f306075e7073a6fe7820a751bb60431fc5" - -ADD \ - --checksum="${KEPUBIFY_AMD64_CHECKSUM}" \ - --chmod=755 \ - https://github.com/pgaskin/kepubify/releases/download/v${KEPUBIFY_VERSION}/kepubify-linux-64bit /kepubify - -FROM scratch AS kepubify-layer-arm64 - -ARG KEPUBIFY_VERSION="4.0.4" -ARG KEPUBIFY_ARM64_CHECKSUM="sha256:5a15b8f6f6a96216c69330601bca29638cfee50f7bf48712795cff88ae2d03a3" - -ADD \ - --checksum="${KEPUBIFY_ARM64_CHECKSUM}" \ - --chmod=755 \ - https://github.com/pgaskin/kepubify/releases/download/v${KEPUBIFY_VERSION}/kepubify-linux-arm64 /kepubify - -FROM kepubify-layer-${TARGETARCH} AS kepubify-layer - FROM eclipse-temurin:25-jre-alpine ENV JAVA_TOOL_OPTIONS="-XX:+UseShenandoahGC \ @@ -102,7 +80,6 @@ COPY packaging/docker/entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh COPY --from=ffprobe-layer /ffprobe /usr/local/bin/ffprobe -COPY --from=kepubify-layer /kepubify /usr/local/bin/kepubify COPY --from=backend-build /workspace/backend/app.jar /app/app.jar diff --git a/backend/src/main/java/org/booklore/service/ArchiveService.java b/backend/src/main/java/org/booklore/service/ArchiveService.java index c3cd6040c3..ea7770ca81 100644 --- a/backend/src/main/java/org/booklore/service/ArchiveService.java +++ b/backend/src/main/java/org/booklore/service/ArchiveService.java @@ -16,9 +16,11 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; @@ -247,7 +249,7 @@ static final class LimitReachedException extends IOException { } } - public long extractEntryToPath(Path path, String entryName, Path outputPath) throws IOException { + public void extractEntryToPath(Path path, String entryName, Path outputPath) throws IOException { ReentrantLock lock = getFileLock(path); lock.lock(); @@ -255,7 +257,7 @@ public long extractEntryToPath(Path path, String entryName, Path outputPath) thr try (OutputStream outputStream = Files.newOutputStream(outputPath, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { hasCreatedFile = true; - return transferEntryTo(path, entryName, outputStream); + transferEntryTo(path, entryName, outputStream); } catch (Exception e) { if (hasCreatedFile) { try { @@ -270,4 +272,36 @@ public long extractEntryToPath(Path path, String entryName, Path outputPath) thr lock.unlock(); } } + + public List extractToDirectory(Path path, Path outputPath) throws IOException { + return extractToDirectory(path, outputPath, null); + } + + public List extractToDirectory(Path path, Path outputPath, Predicate predicate) throws IOException { + List extractedPaths = new ArrayList<>(); + + for (var entry : getEntries(path)) { + if (predicate != null && !predicate.test(entry)) { + continue; + } + + Path entryPath = outputPath.resolve(entry.name).normalize(); + if (!entryPath.startsWith(outputPath)) { + log.warn("Archive entry outside target directory: {}", entry.name); + throw new IOException("Archive entry outside target directory"); + } + + if (Files.exists(entryPath)) { + log.warn("Entry already exists, skipping: {}", entry.name); + continue; + } + + Files.createDirectories(entryPath.getParent()); + extractEntryToPath(path, entry.name, entryPath); + + extractedPaths.add(entryPath); + } + + return extractedPaths; + } } diff --git a/backend/src/main/java/org/booklore/service/kobo/CbxConversionService.java b/backend/src/main/java/org/booklore/service/kobo/CbxConversionService.java index 74a51938e7..3d277e1210 100644 --- a/backend/src/main/java/org/booklore/service/kobo/CbxConversionService.java +++ b/backend/src/main/java/org/booklore/service/kobo/CbxConversionService.java @@ -185,42 +185,28 @@ private Configuration initializeFreemarkerConfiguration() { } private List extractImagesFromCbx(File cbxFile, Path extractedImagesDir) throws IOException { - List imagePaths = new ArrayList<>(); - - for (ArchiveService.Entry entry : archiveService.getEntries(cbxFile.toPath())) { - if (!isImageFile(entry.name())) { - continue; - } - - validateImageSize(entry.name(), entry.size()); - - try { - Path outputPath = extractedImagesDir.resolve(extractFileName(entry.name())); + var imagePaths = archiveService.extractToDirectory( + cbxFile.toPath(), + extractedImagesDir, + (entry) -> { + if (!isImageFile(entry.name())) { + return false; + } - archiveService.extractEntryToPath(cbxFile.toPath(), entry.name(), outputPath); + if (entry.size() > MAX_IMAGE_SIZE_BYTES) { + log.warn("Image too large, skipping: {} ({})", entry.name(), entry.size()); + return false; + } - imagePaths.add(outputPath); - } catch (Exception e) { - log.warn("Error extracting image {}: {}", entry.name(), e.getMessage()); - } - } + return true; + } + ); log.debug("Found {} image entries in CBR file", imagePaths.size()); imagePaths.sort(Comparator.comparing(path -> path.getFileName().toString().toLowerCase())); return imagePaths; } - private String extractFileName(String entryPath) { - return Path.of(entryPath).getFileName().toString(); - } - - private void validateImageSize(String imageName, long size) throws IOException { - if (size > MAX_IMAGE_SIZE_BYTES) { - throw new IOException(String.format("Image '%s' exceeds maximum size limit: %d bytes (max: %d bytes)", - imageName, size, MAX_IMAGE_SIZE_BYTES)); - } - } - private boolean isImageFile(String fileName) { if (shouldIgnoreEntry(fileName)) { return false; diff --git a/backend/src/main/java/org/booklore/service/kobo/KepubConversionService.java b/backend/src/main/java/org/booklore/service/kobo/KepubConversionService.java index 3e88b06e82..1565ed6054 100644 --- a/backend/src/main/java/org/booklore/service/kobo/KepubConversionService.java +++ b/backend/src/main/java/org/booklore/service/kobo/KepubConversionService.java @@ -1,93 +1,224 @@ package org.booklore.service.kobo; -import org.booklore.util.FileService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.booklore.service.ArchiveService; +import org.booklore.util.MimeDetector; +import org.booklore.util.SecureXmlUtils; +import org.booklore.util.epub.CoverDetectorService; +import org.booklore.util.epub.EpubContentReader; +import org.booklore.util.epub.EpubContentWriter; import org.springframework.stereotype.Service; - +import org.springframework.util.FileSystemUtils; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.transform.*; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; -import java.util.stream.Collectors; +import java.util.Set; +import java.util.stream.Stream; @Slf4j @Service @RequiredArgsConstructor public class KepubConversionService { + private static final String OPF_NS = "http://www.idpf.org/2007/opf"; + + private static final Set HTML_MEDIA_TYPES = Set.of( + "text/html", + "application/xhtml", + "application/xhtml+xml" + ); + + private static final Set IGNORED_FILENAMES = Set.copyOf( + Stream.of( + "", + ".DS_STORE", + "iTunesMetadata.plist", + "iTunesArtwork.plist", + "calibre_bookmarks.txt", + "thumbs.db" + ).map(String::toLowerCase).toList() + ); + + private static final Set IGNORED_DIRECTORIES = Set.copyOf( + Stream.of( + "__MACOSX" + ).map(String::toLowerCase).toList() + ); + + private final ArchiveService archiveService; + private final KepubHtmlConversionService kepubHtmlConversionService; + private final CoverDetectorService coverDetectorService; + + private void transformHTML(Path path, boolean forceEnableHyphenation) throws IOException { + var transformedHtml = kepubHtmlConversionService.transform( + Files.readString(path), + forceEnableHyphenation + ); + + Files.writeString(path, transformedHtml, StandardCharsets.UTF_8); + } - private final FileService fileService; - - public File convertEpubToKepub(File epubFile, File tempDir, boolean forceEnableHyphenation) throws IOException, InterruptedException { - validateInputs(epubFile); + /** + * Adds the cover-image property to the cover item in the OPF manifest. + * Kobo devices will only support the EPUB3 "properties" attribute with + * the `cover-image` tag. + * + * Read more on the EPUB3 spec. + * + */ + private void transformOPFCoverImage(Document opfDoc, String coverImage) { + if (coverImage == null) { + return; + } - Path kepubifyBinary = fileService.findSystemFile("kepubify"); + NodeList manifestList = opfDoc.getElementsByTagNameNS(OPF_NS, "manifest"); - if (kepubifyBinary == null) { - throw new IOException("Kepubify conversion failed: could not find kepubify binary"); + if (manifestList.getLength() == 0) { + return; } - File outputFile = executeKepubifyConversion(epubFile, tempDir, kepubifyBinary, forceEnableHyphenation); + if (manifestList.item(0) instanceof Element manifest) { + NodeList itemList = manifest.getElementsByTagNameNS(OPF_NS, "item"); - log.info("Successfully converted {} to {} (size: {} bytes)", epubFile.getName(), outputFile.getName(), outputFile.length()); - return outputFile; - } + for (int i = 0; i < itemList.getLength(); i++) { + if (itemList.item(i) instanceof Element item) { + if (coverImage.equals(item.getAttribute("href"))) { + String properties = item.getAttribute("properties"); - private void validateInputs(File epubFile) { - if (epubFile == null || !epubFile.isFile() || !epubFile.getName().endsWith(".epub")) { - throw new IllegalArgumentException("Invalid EPUB file: " + epubFile); + if (properties.isBlank()) { + properties = "cover-image"; + } else { + properties += " cover-image"; + } + + item.setAttribute("properties", properties); + } + } + } } } - private File executeKepubifyConversion(File epubFile, File tempDir, Path kepubifyBinary, boolean forceEnableHyphenation) throws IOException, InterruptedException { - ProcessBuilder pb; - - if (forceEnableHyphenation) - pb = new ProcessBuilder(kepubifyBinary.toAbsolutePath().toString(), "--hyphenate", "-o", tempDir.getAbsolutePath(), epubFile.getAbsolutePath()); - else - pb = new ProcessBuilder(kepubifyBinary.toAbsolutePath().toString(), "-o", tempDir.getAbsolutePath(), epubFile.getAbsolutePath()); - - pb.directory(tempDir); + private void transformOPF(Path path, String coverHref) throws IOException { + try (var outputStream = new ByteArrayOutputStream()) { + try (var inputStream = Files.newInputStream(path)){ + var builder = SecureXmlUtils.createSecureDocumentBuilder(true); + var opfDoc = builder.parse(inputStream); + + transformOPFCoverImage(opfDoc, coverHref); + + Transformer transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + transformer.transform(new DOMSource(opfDoc), new StreamResult(outputStream)); + } catch (TransformerException | SAXException | ParserConfigurationException exception) { + log.error("unable to parse OPF"); + throw new IOException("unable to parse OPF", exception); + } + + // After we close the InputStream we can write the file. + Files.writeString(path, outputStream.toString(StandardCharsets.UTF_8)); + } + } - log.info("Starting kepubify conversion for {} -> output dir: {}", epubFile.getAbsolutePath(), tempDir.getAbsolutePath()); + private void transformExtractedEpubHtml( + Path path, + boolean forceEnableHyphenation + ) throws IOException { + try (var files = Files.walk(path)) { + var allFiles = files + .filter(Files::isRegularFile) + .toList(); + + for (var file : allFiles) { + String mediaType = MimeDetector.detect(file); + + if (HTML_MEDIA_TYPES.contains(mediaType)) { + transformHTML(file, forceEnableHyphenation); + } + } + } + } - Process process = pb.start(); + private boolean isAcceptedEntry(ArchiveService.Entry entry) { + String[] parts = entry.name().split("/"); - String output = readProcessOutput(process.getInputStream()); - String error = readProcessOutput(process.getErrorStream()); + if (parts.length == 0) { + // No empty path items allowed. + return false; + } - int exitCode = process.waitFor(); - logProcessResults(exitCode, output, error); + String filename = parts[parts.length - 1]; + if (IGNORED_FILENAMES.contains(filename.toLowerCase())) { + return false; + } - if (exitCode != 0) { - throw new IOException(String.format("Kepubify conversion failed with exit code: %d. Error: %s", exitCode, error)); + // Check everything except the "filename" (the last entry) + for (int i = 0; i < parts.length - 1; i++) { + if (IGNORED_DIRECTORIES.contains(parts[i].toLowerCase())) { + return false; + } } - return findOutputFile(tempDir); + return true; } - private String readProcessOutput(InputStream inputStream) { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { - return reader.lines().collect(Collectors.joining("\n")); - } catch (Exception e) { - log.warn("Error reading process output: {}", e.getMessage()); - return ""; + public void convertEpubToKepub(Path inputPath, Path outputPath, boolean forceEnableHyphenation) throws IOException { + validateInputs(inputPath); + + String coverHref = coverDetectorService.detectCoverImagePath(inputPath); + + var tempDir = Files.createTempDirectory("grimmory-kepubify"); + try { + archiveService.extractToDirectory(inputPath, tempDir, this::isAcceptedEntry); + + transformExtractedEpubHtml(tempDir, forceEnableHyphenation); + + try { + Path opfPath = EpubContentReader.findOPFInExtractedEpub(tempDir); + transformOPF(opfPath, coverHref); + } catch (Exception e) { + log.warn("Unable to transform OPF", e); + } + + EpubContentWriter.createEpubFromDirectory(tempDir, outputPath); + } finally { + if (tempDir != null) { + try { + FileSystemUtils.deleteRecursively(tempDir); + log.debug("Deleted temporary directory {}", tempDir); + } catch (Exception e) { + log.warn("Failed to delete temporary directory {}: {}", tempDir, e.getMessage()); + } + } } + + log.info( + "Successfully converted {} to {} (size: {} bytes)", + inputPath.getFileName(), + outputPath.getFileName(), + Files.size(outputPath) + ); } - private void logProcessResults(int exitCode, String output, String error) { - log.debug("Kepubify process exited with code {}", exitCode); - if (!output.isEmpty()) { - log.debug("Kepubify stdout: {}", output); - } - if (!error.isEmpty()) { - log.error("Kepubify stderr: {}", error); - } + public File convertEpubToKepub(File epubFile, File tempDir, boolean forceEnableHyphenation) throws IOException { + var outputPath = Files.createTempFile(tempDir.toPath(), "grimmory", ".kepub.epub"); + convertEpubToKepub(epubFile.toPath(), outputPath, forceEnableHyphenation); + return outputPath.toFile(); } - private File findOutputFile(File tempDir) throws IOException { - File[] kepubFiles = tempDir.listFiles((dir, name) -> name.endsWith(".kepub.epub")); - if (kepubFiles == null || kepubFiles.length == 0) { - throw new IOException("Kepubify conversion completed but no .kepub.epub file was created in: " + tempDir.getAbsolutePath()); + private void validateInputs(Path inputPath) { + if (inputPath == null || !Files.isRegularFile(inputPath)) { + throw new IllegalArgumentException("Invalid EPUB file: " + inputPath); } - return kepubFiles[0]; } } diff --git a/backend/src/main/java/org/booklore/service/kobo/KepubHtmlConversionService.java b/backend/src/main/java/org/booklore/service/kobo/KepubHtmlConversionService.java new file mode 100644 index 0000000000..ff61fde381 --- /dev/null +++ b/backend/src/main/java/org/booklore/service/kobo/KepubHtmlConversionService.java @@ -0,0 +1,352 @@ +package org.booklore.service.kobo; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jsoup.Jsoup; +import org.jsoup.nodes.*; +import org.jsoup.parser.Parser; +import org.jsoup.parser.Tag; +import org.jsoup.parser.TagSet; +import org.jsoup.select.NodeFilter; +import org.springframework.stereotype.Service; +import javax.xml.transform.*; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Gatherer; +import java.util.stream.Stream; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KepubHtmlConversionService { + private static final String CLASSNAME_KOBO_SPAN = "koboSpan"; + private static final String CLASSNAME_KOBO_STYLES = "kobostylehacks"; + private static final String CLASSNAME_KOBO_HYPHENATE = "kobostylehyphenate"; + private static final String ID_FORMAT_KOBO_SPAN = "kobo.%d"; + + private static final String CSS_KOBO_STYLES = """ + div#book-inner { + margin-top: 0; + margin-bottom: 0; + } + """; + + private static final String CSS_HYPHENATE = """ + * { + -webkit-hyphens: auto; + -moz-hyphens: auto; + hyphens: auto; + + -webkit-hyphenate-limit-after: 3; + -webkit-hyphenate-limit-before: 3; + -webkit-hyphenate-limit-lines: 2; + } + + h1, h2, h3, h4, h5, h6, td { + -moz-hyphens: none !important; + -webkit-hyphens: none !important; + hyphens: none !important; + } + """; + + private static final String CSS_NO_HYPHENATE = """ + * { + -moz-hyphens: none !important; + -webkit-hyphens: none !important; + hyphens: none !important; + } + """; + + private static final Set SENTENCE_PUNCTUATION = Set.of( + (int) '.', + (int) '?', + (int) '!', + (int) '…' + ); + + private static final Set SENTENCE_EXTRA_CHARS = Set.of( + (int) '\'', + (int) '"', + (int) '“', + (int) '”', + (int) '’' + ); + + private static final Set IGNORED_CONTAINERS = Set.of( + "script", + "style", + "pre", + "audio", + "video", + "svg", + "math" + ); + + private static final Set WRAPPABLE_TAGS = Set.of( + "math", + "svg", + "img" + ); + + private class SentenceParsingState { + private final StringBuilder window = new StringBuilder(); + private boolean hasSeenPunctuation = false; + + public void append(int codePoint) { + window.appendCodePoint(codePoint); + if (SENTENCE_PUNCTUATION.contains(codePoint)) { + hasSeenPunctuation = true; + } + } + + public boolean isEmpty() { + return window.isEmpty(); + } + + public boolean hasPunctuation() { + return hasSeenPunctuation; + } + + public void reset() { + window.setLength(0); + hasSeenPunctuation = false; + } + + public String flush() { + String value = window.toString(); + this.reset(); + return value; + } + } + + private Stream getSentences(String text) { + return text.codePoints() + .boxed() + .gather( + Gatherer.ofSequential( + SentenceParsingState::new, + Gatherer.Integrator.ofGreedy((state, element, downstream) -> { + if (!state.hasPunctuation()) { + state.append(element); + return true; + } + + // If we've seen punctuation, and this is more punctuation or other + // acceptable chars, keep going. + if (SENTENCE_EXTRA_CHARS.contains(element) || SENTENCE_PUNCTUATION.contains(element)) { + state.append(element); + return true; + } + + // If end of sentence: + var result = downstream.push(state.flush()); + state.append(element); + return result; + }), + (state, downstream) -> { + if (!state.isEmpty() && !downstream.isRejecting()) { + downstream.push(state.flush()); + } + } + ) + ); + } + + /** + * Find every image and text node, and add `kobospan` elements where + * appropriate - around each image, and around each sentence in the + * text node. + */ + private void transformContentAddKoboSpans(Document document) { + List wrappableText = new ArrayList<>(); + List wrappableElements = new ArrayList<>(); + + document.body() + .filter( + (node, depth) -> { + var parent = node.parentElement(); + + if (parent == null) { + // Node is not in the DOM or does not have parent. + // Cannot operate on it. + return NodeFilter.FilterResult.CONTINUE; + } + + if ("span".equals(parent.tagName()) && parent.hasClass(CLASSNAME_KOBO_SPAN)) { + // The iterator will pick up the koboSpan we're adding + return NodeFilter.FilterResult.SKIP_ENTIRELY; + } + + if (node instanceof TextNode textNode) { + if (!textNode.isBlank()) { + wrappableText.add(textNode); + } + + return NodeFilter.FilterResult.CONTINUE; + } + + if (node instanceof Element element) { + if (WRAPPABLE_TAGS.contains(element.tagName())) { + wrappableElements.add(element); + } + + if (IGNORED_CONTAINERS.contains(element.tagName())) { + return NodeFilter.FilterResult.SKIP_ENTIRELY; + } + } + + return NodeFilter.FilterResult.CONTINUE; + } + ); + + AtomicInteger koboSpanIndex = new AtomicInteger(); + Set existingIds = document.getElementsByAttribute("id") + .stream() + .map(Element::id) + .collect(Collectors.toSet()); + + Supplier nextKoboSpan = () -> { + String koboSpanId; + do { + koboSpanId = String.format(ID_FORMAT_KOBO_SPAN, koboSpanIndex.incrementAndGet()); + } while (existingIds.contains(koboSpanId)); + + var koboSpan = document.createElement("span"); + koboSpan.id(koboSpanId); + koboSpan.addClass(CLASSNAME_KOBO_SPAN); + return koboSpan; + }; + + for (var textNode : wrappableText) { + for (var sentence : getSentences(textNode.text()).toList()) { + var koboSpan = nextKoboSpan.get(); + koboSpan.text(sentence); + textNode.before(koboSpan); + } + + textNode.remove(); + } + + for (var element : wrappableElements) { + var koboSpan = nextKoboSpan.get(); + element.before(koboSpan); + koboSpan.appendChild(element); + } + } + + private void transformContentAddStyles(Document document, boolean forceEnableHyphenation) { + document.head().getElementsByClass(CLASSNAME_KOBO_STYLES).remove(); + document.head().getElementsByClass(CLASSNAME_KOBO_HYPHENATE).remove(); + + document.head().appendChild( + document.createElement("style") + .addClass(CLASSNAME_KOBO_STYLES) + .attr("type", "text/css") + .text(CSS_KOBO_STYLES) + ); + + if (forceEnableHyphenation) { + document.head().appendChild( + document.createElement("style") + .addClass(CLASSNAME_KOBO_HYPHENATE) + .attr("type", "text/css") + .text(CSS_HYPHENATE) + ); + } else { + document.head().appendChild( + document.createElement("style") + .addClass(CLASSNAME_KOBO_HYPHENATE) + .attr("type", "text/css") + .text(CSS_NO_HYPHENATE) + ); + } + } + + /** + * Wraps the `body` of the document in two divs: + * body > div#book-columns > div#book-inner > * + */ + private void transformContentAddWrappers(Document document) { + var children = document.body().childNodes(); + + var innerElement = document.createElement("div"); + innerElement.id("book-inner"); + innerElement.appendChildren(children); + + var columnElement = document.createElement("div"); + columnElement.id("book-columns"); + columnElement.appendChild(innerElement); + + document.body().appendChild(columnElement); + } + + private void transformContentRemoveGarbage(Document document) { + // Adobe Adept elements + var adobeAdeptExpectedResources = document.getElementsByAttributeValue("name", "Adept.expected.resource"); + for (var element : adobeAdeptExpectedResources) { + element.remove(); + } + + // More adobe Adept elements + var adobeAdeptResources = document.getElementsByAttributeValue("name", "Adept.resource"); + for (var element : adobeAdeptResources) { + element.remove(); + } + } + + private void transformContentAddXmlns(Document document) { + // Add XHTML XMLNS + document.getElementsByTag("html") + .attr("xmlns", "http://www.w3.org/1999/xhtml"); + + // Add SVG XMLNS + document.getElementsByTag("svg") + .attr("xmlns", "http://www.w3.org/2000/svg"); + } + + private void transformDocument(Document document, boolean forceEnableHyphenation) { + document.outputSettings( + document.outputSettings() + .clone() + .escapeMode(Entities.EscapeMode.xhtml) + .syntax(Document.OutputSettings.Syntax.xml) + ); + + document.charset(StandardCharsets.UTF_8); + + transformContentAddStyles(document, forceEnableHyphenation); + transformContentAddWrappers(document); + transformContentAddKoboSpans(document); + transformContentRemoveGarbage(document); + transformContentAddXmlns(document); + } + + private Parser getParser() { + var tagSet = TagSet.Html(); + + tagSet.onNewTag(tag -> { + // For some reason we want a hack to allow + // for self-closing anchor tags even though + // this is not valid HTML. + if (tag.name().equals("a")) { + tag.set(Tag.SelfClose); + } + }); + + var parser = Parser.htmlParser(); + parser.tagSet(tagSet); + return parser; + } + + public String transform(String html, boolean forceEnableHyphenation) { + Document document = Jsoup.parse(html, "/", getParser()); + transformDocument(document, forceEnableHyphenation); + return document.toString(); + } +} diff --git a/backend/src/main/java/org/booklore/service/metadata/writer/EpubMetadataWriter.java b/backend/src/main/java/org/booklore/service/metadata/writer/EpubMetadataWriter.java index 7e44cf1652..dbc4e479c9 100644 --- a/backend/src/main/java/org/booklore/service/metadata/writer/EpubMetadataWriter.java +++ b/backend/src/main/java/org/booklore/service/metadata/writer/EpubMetadataWriter.java @@ -12,6 +12,8 @@ import org.booklore.service.appsettings.AppSettingService; import org.booklore.util.MimeDetector; import org.booklore.util.SecureXmlUtils; +import org.booklore.util.epub.EpubContentReader; +import org.booklore.util.epub.EpubContentWriter; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; import org.w3c.dom.Document; @@ -35,7 +37,6 @@ import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; -import java.io.UncheckedIOException; import java.net.URI; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; @@ -45,9 +46,6 @@ import java.util.*; import java.util.regex.Pattern; import java.util.stream.Collectors; -import java.util.zip.CRC32; -import java.util.zip.ZipEntry; -import java.util.zip.ZipOutputStream; import java.util.function.Predicate; @Slf4j @@ -76,16 +74,12 @@ public void saveMetadataToFile(File epubFile, BookMetadataEntity metadata, Strin Path tempDir = null; try { tempDir = Files.createTempDirectory("epub_edit_" + UUID.randomUUID()); - extractZipToDirectory(epubFile, tempDir); + archiveService.extractToDirectory(epubFile.toPath(), tempDir); - File opfFile = findOpfFile(tempDir.toFile()); - if (opfFile == null) { - log.warn("Could not locate OPF file in EPUB"); - return; - } + Path opfPath = findOpfPath(tempDir); DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(true); - Document opfDoc = builder.parse(opfFile); + Document opfDoc = builder.parse(opfPath.toFile()); Element metadataElement = getOrCreateMetadataElement(opfDoc); final String DC_NS = "http://purl.org/dc/elements/1.1/"; @@ -236,10 +230,10 @@ public void saveMetadataToFile(File epubFile, BookMetadataEntity metadata, Strin Transformer transformer = TransformerFactory.newInstance().newTransformer(); transformer.setOutputProperty(OutputKeys.INDENT, "yes"); transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); - transformer.transform(new DOMSource(opfDoc), new StreamResult(opfFile)); + transformer.transform(new DOMSource(opfDoc), new StreamResult(opfPath.toFile())); File tempEpub = new File(epubFile.getParentFile(), epubFile.getName() + ".tmp"); - createEpubZipFromDirectory(tempDir, tempEpub.toPath()); + EpubContentWriter.createEpubFromDirectory(tempDir, tempEpub.toPath()); if (!epubFile.delete()) throw new IOException("Could not delete original EPUB"); if (!tempEpub.renameTo(epubFile)) throw new IOException("Could not rename temp EPUB"); @@ -440,16 +434,12 @@ private void replaceCoverImageInternal(BookEntity bookEntity, byte[] coverData, File epubFile = new File(bookEntity.getFullFilePath().toUri()); tempDir = Files.createTempDirectory("epub_cover_" + UUID.randomUUID()); - extractZipToDirectory(epubFile, tempDir); + archiveService.extractToDirectory(epubFile.toPath(), tempDir); - File opfFile = findOpfFile(tempDir.toFile()); - if (opfFile == null) { - log.warn("OPF file not found in EPUB: {}", epubFile.getName()); - return; - } + Path opfPath = findOpfPath(tempDir); DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(true); - Document opfDoc = builder.parse(opfFile); + Document opfDoc = builder.parse(opfPath.toFile()); applyCoverImageToEpub(tempDir, opfDoc, coverData); @@ -458,10 +448,10 @@ private void replaceCoverImageInternal(BookEntity bookEntity, byte[] coverData, Transformer transformer = TransformerFactory.newInstance().newTransformer(); transformer.setOutputProperty(OutputKeys.INDENT, "yes"); transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); - transformer.transform(new DOMSource(opfDoc), new StreamResult(opfFile)); + transformer.transform(new DOMSource(opfDoc), new StreamResult(opfPath.toFile())); File tempEpub = new File(epubFile.getParentFile(), epubFile.getName() + ".tmp"); - createEpubZipFromDirectory(tempDir, tempEpub.toPath()); + EpubContentWriter.createEpubFromDirectory(tempDir, tempEpub.toPath()); if (!epubFile.delete()) throw new IOException("Could not delete original EPUB"); if (!tempEpub.renameTo(epubFile)) throw new IOException("Could not rename temp EPUB"); @@ -651,36 +641,7 @@ private void applyCoverImageToEpub(Path tempDir, Document opfDoc, byte[] coverDa } private Path findOpfPath(Path tempDir) throws IOException, ParserConfigurationException, SAXException { - Path containerXml = tempDir.resolve("META-INF/container.xml"); - if (!Files.exists(containerXml)) { - throw new IOException("container.xml not found at expected location: " + containerXml); - } - - DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(false); - Document containerDoc = builder.parse(containerXml.toFile()); - Node rootfile = containerDoc.getElementsByTagName("rootfile").item(0); - if (rootfile == null) { - throw new IOException("No found in container.xml"); - } - - String opfPath = ((Element) rootfile).getAttribute("full-path"); - if (opfPath.isBlank()) { - throw new IOException("Missing or empty 'full-path' attribute in "); - } - - return tempDir.resolve(opfPath).normalize(); - } - - private File findOpfFile(File rootDir) { - File[] matches = rootDir.listFiles(path -> path.isFile() && path.getName().endsWith(".opf")); - if (matches != null && matches.length > 0) return matches[0]; - for (File file : Objects.requireNonNull(rootDir.listFiles())) { - if (file.isDirectory()) { - File child = findOpfFile(file); - if (child != null) return child; - } - } - return null; + return EpubContentReader.findOPFInExtractedEpub(tempDir); } private byte[] loadImage(String pathOrUrl) { @@ -692,73 +653,6 @@ private byte[] loadImage(String pathOrUrl) { } } - private void extractZipToDirectory(File zipSource, Path targetDir) throws IOException { - Path zipPath = zipSource.toPath(); - - if (!Files.isRegularFile(zipPath) || !Files.isReadable(zipPath)) { - throw new IOException("Target is not a readable regular file."); - } - - for (var name : archiveService.getEntryNames(zipPath)) { - Path entryPath = targetDir.resolve(name).normalize(); - if (!entryPath.startsWith(targetDir)) { - throw new IOException("ZIP entry outside target directory: " + name); - } - - if (Files.exists(entryPath)) { - log.warn("EPUB Entry already exists, skipping: {}", entryPath); - continue; - } - - Files.createDirectories(entryPath.getParent()); - archiveService.extractEntryToPath(zipPath, name, entryPath); - } - } - - private void createEpubZipFromDirectory(Path sourceDir, Path targetZip) throws IOException { - try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(targetZip))) { - // EPUB spec requires mimetype to be the first entry in the ZIP, uncompressed (STORED) - Path mimetypeFile = sourceDir.resolve("mimetype"); - if (Files.exists(mimetypeFile)) { - byte[] mimetypeData = Files.readAllBytes(mimetypeFile); - ZipEntry mimetypeEntry = new ZipEntry("mimetype"); - mimetypeEntry.setMethod(ZipEntry.STORED); - mimetypeEntry.setSize(mimetypeData.length); - mimetypeEntry.setCompressedSize(mimetypeData.length); - CRC32 crc = new CRC32(); - crc.update(mimetypeData); - mimetypeEntry.setCrc(crc.getValue()); - zos.putNextEntry(mimetypeEntry); - zos.write(mimetypeData); - zos.closeEntry(); - } else { - log.warn("EPUB mimetype file not found in extracted directory — output may be spec-invalid"); - } - - try (var pathStream = Files.walk(sourceDir)) { - pathStream - .filter(path -> !path.equals(sourceDir)) - .filter(path -> !path.equals(mimetypeFile)) - .sorted() - .forEach(path -> { - try { - String relativePath = sourceDir.relativize(path).toString().replace(File.separatorChar, '/'); - if (Files.isDirectory(path)) { - zos.putNextEntry(new ZipEntry(relativePath + "/")); - zos.closeEntry(); - } else { - zos.putNextEntry(new ZipEntry(relativePath)); - Files.copy(path, zos); - zos.closeEntry(); - } - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }); - } - } - } - private void removeMetaByName(Element metadataElement, String name) { NodeList metas = metadataElement.getElementsByTagNameNS("*", "meta"); for (int i = metas.getLength() - 1; i >= 0; i--) { diff --git a/backend/src/main/java/org/booklore/util/epub/EpubContentReader.java b/backend/src/main/java/org/booklore/util/epub/EpubContentReader.java index 9fa540ff79..c9e72c192d 100644 --- a/backend/src/main/java/org/booklore/util/epub/EpubContentReader.java +++ b/backend/src/main/java/org/booklore/util/epub/EpubContentReader.java @@ -1,5 +1,6 @@ package org.booklore.util.epub; +import org.booklore.util.SecureXmlUtils; import org.grimmory.epub4j.domain.Book; import org.grimmory.epub4j.domain.MediaType; import org.grimmory.epub4j.domain.MediaTypes; @@ -7,11 +8,18 @@ import org.grimmory.epub4j.domain.Spine; import org.grimmory.epub4j.epub.EpubReader; import lombok.extern.slf4j.Slf4j; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.xml.sax.SAXException; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.ParserConfigurationException; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; @@ -107,6 +115,28 @@ public static List getAllSpineItemHrefs(File epubFile) { return hrefs; } + + public static Path findOPFInExtractedEpub(Path path) throws IOException, ParserConfigurationException, SAXException { + Path containerXml = path.resolve("META-INF/container.xml"); + if (!Files.exists(containerXml)) { + throw new IOException("container.xml not found at expected location: " + containerXml); + } + + DocumentBuilder builder = SecureXmlUtils.createSecureDocumentBuilder(false); + Document containerDoc = builder.parse(containerXml.toFile()); + Node rootfile = containerDoc.getElementsByTagName("rootfile").item(0); + if (rootfile == null) { + throw new IOException("No found in container.xml"); + } + + String opfPath = ((Element) rootfile).getAttribute("full-path"); + if (opfPath.isBlank()) { + throw new IOException("Missing or empty 'full-path' attribute in "); + } + + return path.resolve(opfPath).normalize(); + } + public static class EpubReadException extends RuntimeException { public EpubReadException(String message) { super(message); diff --git a/backend/src/main/java/org/booklore/util/epub/EpubContentWriter.java b/backend/src/main/java/org/booklore/util/epub/EpubContentWriter.java new file mode 100644 index 0000000000..5ce4025654 --- /dev/null +++ b/backend/src/main/java/org/booklore/util/epub/EpubContentWriter.java @@ -0,0 +1,60 @@ +package org.booklore.util.epub; + +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +@Slf4j +public class EpubContentWriter { + public static void createEpubFromDirectory(Path sourceDir, Path targetZip) throws IOException { + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(targetZip))) { + // EPUB spec requires mimetype to be the first entry in the ZIP, uncompressed (STORED) + Path mimetypeFile = sourceDir.resolve("mimetype"); + if (Files.exists(mimetypeFile)) { + byte[] mimetypeData = Files.readAllBytes(mimetypeFile); + ZipEntry mimetypeEntry = new ZipEntry("mimetype"); + mimetypeEntry.setMethod(ZipEntry.STORED); + mimetypeEntry.setSize(mimetypeData.length); + mimetypeEntry.setCompressedSize(mimetypeData.length); + CRC32 crc = new CRC32(); + crc.update(mimetypeData); + mimetypeEntry.setCrc(crc.getValue()); + zos.putNextEntry(mimetypeEntry); + zos.write(mimetypeData); + zos.closeEntry(); + } else { + log.warn("EPUB mimetype file not found in extracted directory — output may be spec-invalid"); + } + + try (var pathStream = Files.walk(sourceDir)) { + pathStream + .filter(path -> !path.equals(sourceDir)) + .filter(path -> !path.equals(mimetypeFile)) + .sorted() + .forEach(path -> { + try { + String relativePath = sourceDir.relativize(path).toString().replace(File.separatorChar, '/'); + if (Files.isDirectory(path)) { + zos.putNextEntry(new ZipEntry(relativePath + "/")); + zos.closeEntry(); + } else { + zos.putNextEntry(new ZipEntry(relativePath)); + Files.copy(path, zos); + zos.closeEntry(); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + } + } + +} diff --git a/backend/src/test/java/org/booklore/service/kobo/KepubConversionServiceTest.java b/backend/src/test/java/org/booklore/service/kobo/KepubConversionServiceTest.java new file mode 100644 index 0000000000..6596e8c1cd --- /dev/null +++ b/backend/src/test/java/org/booklore/service/kobo/KepubConversionServiceTest.java @@ -0,0 +1,174 @@ +package org.booklore.service.kobo; + +import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream; +import org.booklore.service.ArchiveService; +import org.booklore.util.epub.CoverDetectorService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.function.Predicate; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class KepubConversionServiceTest { + @Mock + private KepubHtmlConversionService kepubHtmlConversionService; + + @Mock + private CoverDetectorService coverDetectorService; + + @Mock + private ArchiveService archiveService; + + @InjectMocks + private KepubConversionService kepubConversionService; + + @TempDir + Path tempDir; + + @Captor + ArgumentCaptor> predicateCaptor; + + @Test + void convertEpubToKepub_ShouldSkipSomeFiles() throws IOException { + var ignoredFiles = Set.of( + "", + "/example/.DS_STORE", + "/__MACOSX/bar.txt" + ); + + var acceptedFiles = Set.of( + "/example/foo.txt", + "/other.txt" + ); + + Path epubPath = writeFakeEpub("example.epub"); + Path kepubPath = tempDir.resolve("example.epub.kepub"); + + when(archiveService.extractToDirectory(any(), any(), any())).then( + (a) -> { + writeExtractedEpub(a.getArgument(1)); + return List.of(); + } + ); + + when(kepubHtmlConversionService.transform(anyString(), eq(true))).then( + args -> "transformed " + args.getArgument(0) + ); + + kepubConversionService.convertEpubToKepub( + epubPath, + kepubPath, + true + ); + + verify(archiveService).extractToDirectory(any(), any(), predicateCaptor.capture()); + + var predicate = predicateCaptor.getValue(); + + for (var f : ignoredFiles) { + assertThat(predicate.test(new ArchiveService.Entry(f, 0))) + .withFailMessage("Should ignore file `" + f + "`") + .isFalse(); + } + + for (var f : acceptedFiles) { + assertThat(predicate.test(new ArchiveService.Entry(f, 0))) + .withFailMessage("Should include file `" + f + "`") + .isTrue(); + } + } + + @Test + void convertEpubToKepub_ShouldOnlyTransformHTML() throws IOException { + Path epubPath = writeFakeEpub("example.epub"); + Path kepubPath = tempDir.resolve("example.epub.kepub"); + + when(kepubHtmlConversionService.transform(anyString(), eq(true))).then( + args -> "transformed " + args.getArgument(0) + ); + + when(archiveService.extractToDirectory(any(), any(), any())).then( + (a) -> { + writeExtractedEpub(a.getArgument(1)); + return List.of(); + } + ); + + kepubConversionService.convertEpubToKepub( + epubPath, + kepubPath, + true + ); + + verify(kepubHtmlConversionService).transform("", true); + + assertThat(kepubPath).exists(); + } + + private Path writeFakeEpub(String epubName) throws IOException { + var path = tempDir.resolve(epubName); + try ( + var os = Files.newOutputStream(path); + var zos = new ZipArchiveOutputStream(os) + ) { + // Do nothing to create an empty zip + } + + return path; + } + + private static final String CONTAINER_XML = """ + + + + + + + """; + + private static final String MINIMAL_OPF = """ + + + + + + + + + + """; + + + private void writeString(Path path, String content) { + try { + Files.createDirectories(path.getParent()); + Files.writeString(path, content); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void writeExtractedEpub(Path path) { + writeString(path.resolve("mimetype"), "application/epub+zip"); + writeString(path.resolve("META-INF/container.xml"), CONTAINER_XML); + writeString(path.resolve("OEBPS/content.opf"), MINIMAL_OPF); + writeString(path.resolve("OEBPS/example.txt"), "example"); + writeString(path.resolve("OEBPS/ch1.html"), ""); + } +} \ No newline at end of file diff --git a/backend/src/test/java/org/booklore/service/kobo/KepubHtmlConversionServiceTest.java b/backend/src/test/java/org/booklore/service/kobo/KepubHtmlConversionServiceTest.java new file mode 100644 index 0000000000..78ecb7eeaf --- /dev/null +++ b/backend/src/test/java/org/booklore/service/kobo/KepubHtmlConversionServiceTest.java @@ -0,0 +1,185 @@ +package org.booklore.service.kobo; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.regex.Pattern; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +@ExtendWith(MockitoExtension.class) +class KepubHtmlConversionServiceTest { + @InjectMocks + private KepubHtmlConversionService service; + + @Test + void transform_ShouldSplitSentences() { + String actual = service.transform("

Hello.World. This is a test!

", false); + + assertThat(actual).contains( + "Hello." + ); + assertThat(actual).contains( + "World." + ); + assertThat(actual).contains( + " This is a test!" + ); + } + + @Test + void transform_ShouldWrapImages() { + String actual = service.transform("

Hello World.

", false); + + assertThat(actual).contains( + "" + ); + } + + @Test + void transform_ShouldWrapMultipleImages() { + String actual = service.transform("

Hello World.

", false); + + assertThat(actual).contains( + "" + ); + assertThat(actual).contains( + "" + ); + } + + @Test + void transform_ShouldIncludeCSSHacks() { + String actual = service.transform("

Hello World.

", false); + + assertThat(actual).contains("class=\"kobostylehacks\""); + } + + @Test + void transform_ShouldIncludeRootXmlns() { + String actual = service.transform("

Hello World.

", false); + + assertThat(actual).contains("xmlns=\"http://www.w3.org/1999/xhtml\""); + } + + @Test + void transform_ShouldIncludeSVGXmlns() { + String actual = service.transform("", false); + + assertThat(actual).contains("Example", false); + + assertThat(actual).matches( + Pattern.compile( + ".*]+>\\s*\\s*Example\\s*.*", + Pattern.MULTILINE | Pattern.DOTALL + ) + ); + } + + @Test + void transform_shouldWrapBody() { + String actual = service.transform( + "

Hello World.

", + false + ); + + assertThat(actual).matches( + Pattern.compile( + ".*\\s*
\\s*
.*", + Pattern.DOTALL | Pattern.MULTILINE + ) + ); + } + + @Test + void transform_ShouldRemoveAdobeAdept() { + String actual = service.transform( + "Remove" + + "Remove", + false + ); + + assertThat(actual).doesNotContain("Remove"); + } + + @Test + void transform_ShouldHandleEmptyDocuments() { + String actual = service.transform( + "", + false + ); + + assertThat(actual).doesNotContain("Remove"); + } + + @Test + void transform_ShouldMarkEveryTextNodeInAnIllustratedDocument() { + StringBuilder body = new StringBuilder(); + for (int i = 0; i < 20; i++) { + body.append("
Entry ").append(i).append(".
"); + } + + String actual = service.transform("" + body + "", false); + + System.out.println(actual); + assertThat(countKoboSpans(actual)).isEqualTo(40); + } + + @Test + void transform_ShouldNotMarkInsideMathML() { + String actual = service.transform( + "

See " + + "x+1 here.

", + false + ); + + assertThat(actual).doesNotMatch(Pattern.compile(".*]*>\\s*]*koboSpan.*", Pattern.DOTALL)); + } + + @Test + void transform_ShouldNotReuseAnExistingMarkerId() { + String actual = service.transform( + "

Already marked." + + " Newly added text.

", + false + ); + + System.out.println(actual); + assertThat(actual).doesNotMatch(Pattern.compile(".+id=\"kobo\\.1\".+id=\"kobo\\.1\".+", Pattern.DOTALL)); + } + + @Test + void transform_ShouldPreserveWhitespaceInPreformattedText() { + String actual = service.transform( + "
alpha  beta\n  gamma
", + false + ); + + assertThat(actual).contains("alpha beta\n gamma"); + } + + @Test + void transform_ShouldNotLetEmptyAnchorsSwallowFollowingText() { + String actual = service.transform( + "

Body text.

", + false + ); + + assertThat(actual).containsPattern(""); + } + + private int countKoboSpans(String html) { + return countOccurrences(html, "class=\"koboSpan\""); + } + + private int countOccurrences(String haystack, String needle) { + return haystack.split(Pattern.quote(needle), -1).length - 1; + } +} \ No newline at end of file