Apache Commons Compress
| Language | Java |
|---|---|
| License | Apache-2.0 |
| Repository | github.com/apache/commons-compress |
| Documentation | TAR notes, Javadoc |
| Depends on | commons-io, commons-codec, commons-lang3 |
| Read / write | yes / yes (ustar, pax, GNU long names, star; compressed tar through the compressor classes) |
Commons Compress has three ways to handle TAR. TarFile reads all headers of a file up front and then opens any entry by seeking to it: the most convenient for a comic reader, because it gives random access to pages in an uncompressed CBT. TarArchiveInputStream reads entries sequentially from any stream. TarArchiveOutputStream writes archives. The same library reads and writes CBZ and CB7.
The JDK has no TAR support, so this is the standard choice in Java.
Installation#
Maven:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>1.28.0</version>
</dependency>
Gradle:
implementation "org.apache.commons:commons-compress:1.28.0"
Basic usage: list pages in natural order#
naturalCompare and isPage are the helpers from the java.util.zip CBZ page: a natural, case-insensitive sort and an image-extension filter that skips __MACOSX and dotfiles (including macOS ._ files).
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarFile;
public class CbtPages {
static List<TarArchiveEntry> pages(TarFile tar) {
List<TarArchiveEntry> pages = new ArrayList<>();
for (TarArchiveEntry e : tar.getEntries())
if (e.isFile() && Comic.isPage(e.getName())) pages.add(e);
pages.sort((a, b) -> Comic.naturalCompare(a.getName(), b.getName()));
return pages;
}
public static void main(String[] args) throws IOException {
try (TarFile tar = new TarFile(Path.of(args[0]))) {
List<TarArchiveEntry> pages = pages(tar);
for (int i = 0; i < pages.size(); i++)
System.out.printf("%3d %s (%d bytes)%n", i + 1, pages.get(i).getName(), pages.get(i).getSize());
}
}
}
How to extract the cover#
TarFile.getInputStream seeks straight to the entry’s data:
try (TarFile tar = new TarFile(Path.of("comic.cbt"))) {
List<TarArchiveEntry> pages = CbtPages.pages(tar);
if (!pages.isEmpty()) {
TarArchiveEntry cover = pages.get(0);
String ext = cover.getName().substring(cover.getName().lastIndexOf('.'));
try (InputStream in = tar.getInputStream(cover)) {
Files.copy(in, Path.of("cover" + ext), StandardCopyOption.REPLACE_EXISTING);
}
}
}
How to read ComicInfo.xml#
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Element;
try (TarFile tar = new TarFile(Path.of("comic.cbt"))) {
for (TarArchiveEntry e : tar.getEntries()) {
if (!e.getName().equalsIgnoreCase("ComicInfo.xml")) continue;
DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
try (InputStream in = tar.getInputStream(e)) {
Element ci = f.newDocumentBuilder().parse(in).getDocumentElement();
System.out.println(ci.getElementsByTagName("Series").item(0).getTextContent());
var list = ci.getElementsByTagName("Page");
for (int i = 0; i < list.getLength(); i++) {
Element p = (Element) list.item(i);
if (p.getAttribute("Type").contains("FrontCover")) // Image = index in sorted page list
System.out.println("cover index " + p.getAttribute("Image"));
}
}
}
}
How to read a CBT from a stream#
When the comic comes from the network or another archive, read it sequentially:
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
Map<String, byte[]> pages = new HashMap<>();
try (TarArchiveInputStream tin = new TarArchiveInputStream(inputStream)) {
TarArchiveEntry e;
while ((e = tin.getNextEntry()) != null) {
if (e.isFile() && Comic.isPage(e.getName()))
pages.put(e.getName(), tin.readAllBytes()); // reads only the current entry
}
}
How to create a CBT from a folder of images#
Set LONGFILE_POSIX so names longer than 100 characters get a pax header instead of an error:
import java.nio.file.*;
import java.util.*;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
static void makeCbt(Path dir, Path out) throws Exception {
List<Path> files;
try (var s = Files.list(dir)) {
files = new ArrayList<>(s.filter(p -> Comic.isPage(p.getFileName().toString())).toList());
}
files.sort((a, b) -> Comic.naturalCompare(a.getFileName().toString(), b.getFileName().toString()));
try (TarArchiveOutputStream tos = new TarArchiveOutputStream(Files.newOutputStream(out))) {
tos.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);
for (int i = 0; i < files.size(); i++) {
String file = files.get(i).getFileName().toString();
String name = String.format("%03d%s", i + 1, file.substring(file.lastIndexOf('.')).toLowerCase());
tos.putArchiveEntry(new TarArchiveEntry(files.get(i), name));
Files.copy(files.get(i), tos);
tos.closeArchiveEntry();
}
Path info = dir.resolve("ComicInfo.xml");
if (Files.exists(info)) {
tos.putArchiveEntry(new TarArchiveEntry(info, "ComicInfo.xml"));
Files.copy(info, tos);
tos.closeArchiveEntry();
}
tos.finish();
}
}
Tips#
new TarFile(path)scans all headers when it opens; for a 500 MB CBT that’s one pass of seeks, not a full read.- Skip entries where
e.isFile()is false: pax headers, directories and links are not pages.