Compare commits

...

4 commits

Author SHA1 Message Date
Renovate
d7bb52d95b chore(deps): lock file maintenance
All checks were successful
Checking Renovate configuration / validate (pull_request) Successful in 2m27s
Build and test / Clippy (pull_request) Successful in 3m24s
Checking yaml / Run yamllint (pull_request) Successful in 7s
Build and test / Tests (pull_request) Successful in 3m51s
Build and test / Build AMD64 (pull_request) Successful in 3m55s
Build and test / Generate Documentation (pull_request) Successful in 1m55s
2025-10-26 19:27:43 +11:00
Renovate
60cbbdf58b fix(deps): update rust crate clap to v4.5.50
All checks were successful
Build and test / Tests (pull_request) Successful in 1m4s
Build and test / Clippy (pull_request) Successful in 1m7s
Checking yaml / Run yamllint (pull_request) Successful in 4s
Build and test / Build AMD64 (pull_request) Successful in 1m19s
Checking Renovate configuration / validate (pull_request) Successful in 1m54s
Build and test / Generate Documentation (pull_request) Successful in 1m9s
2025-10-26 19:24:15 +11:00
6379e8a56b
feat: support cbr reading
All checks were successful
Build and test / Tests (pull_request) Successful in 1m12s
Checking yaml / Run yamllint (pull_request) Successful in 4s
Build and test / Build AMD64 (pull_request) Successful in 1m3s
Build and test / Generate Documentation (pull_request) Successful in 58s
Checking Renovate configuration / validate (pull_request) Successful in 1m50s
Build and test / Clippy (pull_request) Successful in 1m2s
2025-10-26 19:17:02 +11:00
Renovate
a16ec085b1 fix(deps): update rust crate indicatif to v0.18.1
All checks were successful
Build and test / Tests (pull_request) Successful in 51s
Build and test / Clippy (pull_request) Successful in 51s
Checking yaml / Run yamllint (pull_request) Successful in 5s
Build and test / Build AMD64 (pull_request) Successful in 1m0s
Checking Renovate configuration / validate (pull_request) Successful in 1m29s
Build and test / Generate Documentation (pull_request) Successful in 57s
2025-10-22 04:00:23 +11:00
11 changed files with 595 additions and 354 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.DS_Store
.direnv/ .direnv/
.idea/ .idea/
/target /target

753
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@ tabled = "0.20.0"
walkdir = "2.5.0" walkdir = "2.5.0"
zip = "6.0.0" zip = "6.0.0"
lopdf = "0.38.0" lopdf = "0.38.0"
unrar = "0.5.8"
tempfile = "3.12.0"
[dev-dependencies] [dev-dependencies]
tempfile = "3.12.0"

12
flake.lock generated
View file

@ -57,11 +57,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1755615617, "lastModified": 1761114652,
"narHash": "sha256-HMwfAJBdrr8wXAkbGhtcby1zGFvs+StOp19xNsbqdOg=", "narHash": "sha256-f/QCJM/YhrV/lavyCVz8iU3rlZun6d+dAiC3H+CDle4=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "20075955deac2583bb12f07151c2df830ef346b4", "rev": "01f116e4df6a15f4ccdffb1bcd41096869fb385c",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -80,11 +80,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1755960406, "lastModified": 1760663237,
"narHash": "sha256-RF7j6C1TmSTK9tYWO6CdEMtg6XZaUKcvZwOCD2SICZs=", "narHash": "sha256-BflA6U4AM1bzuRMR8QqzPXqh8sWVCNDzOdsxXEguJIc=",
"owner": "cachix", "owner": "cachix",
"repo": "git-hooks.nix", "repo": "git-hooks.nix",
"rev": "e891a93b193fcaf2fc8012d890dc7f0befe86ec2", "rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
"type": "github" "type": "github"
}, },
"original": { "original": {

75
src/formats/cbr.rs Normal file
View file

@ -0,0 +1,75 @@
use std::ffi::OsStr;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Result};
use walkdir::WalkDir;
use crate::model::Document;
use super::{CbxReader, FormatReader};
pub struct CbrReader;
impl CbxReader for CbrReader {
fn extract_images(&self, input: &Path) -> Result<Vec<(String, Vec<u8>)>> {
let tempdir = tempfile::tempdir()?;
let dest = tempdir.path().to_path_buf();
{
use std::env;
use unrar::Archive;
let cwd = env::current_dir()?;
let input_str = input.to_string_lossy().to_string();
env::set_current_dir(&dest)?;
let mut archive = Archive::new(&input_str)
.open_for_processing()
.map_err(|e| anyhow!("Failed to open RAR for processing: {e}"))?;
loop {
match archive.read_header() {
Ok(Some(header)) => {
archive = if header.entry().is_file() {
header
.extract()
.map_err(|e| anyhow!("Failed to extract entry: {e}"))?
} else {
header
.skip()
.map_err(|e| anyhow!("Failed to skip entry: {e}"))?
};
}
Ok(None) => break,
Err(e) => {
let _ = env::set_current_dir(cwd);
return Err(anyhow!("Failed to read RAR header: {e}"));
}
}
}
env::set_current_dir(cwd)?;
}
let mut files: Vec<(String, Vec<u8>)> = Vec::new();
for entry in WalkDir::new(&dest).into_iter().filter_map(Result::ok) {
let path: PathBuf = entry.path().to_path_buf();
if path.is_file() && path.extension() == Some(OsStr::new("jpg")) {
let mut data = Vec::new();
fs::File::open(&path)?.read_to_end(&mut data)?;
let name = path
.file_name()
.and_then(OsStr::to_str)
.map(|s| s.to_string())
.unwrap_or_else(|| path.display().to_string());
files.push((name, data));
}
}
Ok(files)
}
}
impl FormatReader for CbrReader {
fn read(&self, input: &Path) -> Result<Document> {
self.read_cbx(input)
}
}

30
src/formats/cbx.rs Normal file
View file

@ -0,0 +1,30 @@
use anyhow::Result;
use std::path::Path;
use crate::model::Document;
// Shared reader logic for CBx (CBZ/CBR) formats
pub trait CbxReader: Send + Sync {
// Implementors should return a list of (file_name, jpeg_bytes)
fn extract_images(&self, input: &Path) -> Result<Vec<(String, Vec<u8>)>>;
// Build a Document from extracted JPEG bytes
fn read_cbx(&self, input: &Path) -> Result<Document> {
let files = self.extract_images(input)?;
let mut pages: Vec<crate::model::ImagePage> = Vec::new();
{
use rayon::prelude::*;
files
.par_iter()
.map(|(name, data)| crate::model::ImagePage {
name: name.clone(),
image: image::load_from_memory(data).expect("Failed to decode image"),
jpeg_dct: Some(data.clone()),
})
.collect_into_vec(&mut pages);
pages.par_sort_by_key(|p| p.name.clone());
}
Ok(Document::new(pages))
}
}

View file

@ -4,17 +4,16 @@ use std::io::{Read, Write};
use std::path::Path; use std::path::Path;
use anyhow::Result; use anyhow::Result;
use rayon::prelude::*;
use zip::ZipArchive; use zip::ZipArchive;
use crate::model::{Document, ImagePage}; use crate::model::Document;
use super::{FormatReader, FormatWriter}; use super::{CbxReader, FormatReader, FormatWriter};
pub struct CbzReader; pub struct CbzReader;
impl FormatReader for CbzReader { impl CbxReader for CbzReader {
fn read(&self, input: &Path) -> Result<Document> { fn extract_images(&self, input: &Path) -> Result<Vec<(String, Vec<u8>)>> {
let mut zip = ZipArchive::new(File::open(input)?)?; let mut zip = ZipArchive::new(File::open(input)?)?;
let mut files: Vec<(String, Vec<u8>)> = Vec::new(); let mut files: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..zip.len() { for i in 0..zip.len() {
@ -35,20 +34,13 @@ impl FormatReader for CbzReader {
)); ));
} }
} }
Ok(files)
}
}
let mut pages: Vec<ImagePage> = Vec::new(); impl FormatReader for CbzReader {
files fn read(&self, input: &Path) -> Result<Document> {
.par_iter() self.read_cbx(input)
.map(|(name, data)| ImagePage {
name: name.clone(),
image: image::load_from_memory(data).expect("Failed to decode image"),
jpeg_dct: Some(data.clone()),
})
.collect_into_vec(&mut pages);
pages.par_sort_by_key(|p| p.name.clone());
Ok(Document::new(pages))
} }
} }

View file

@ -5,9 +5,14 @@ use anyhow::Result;
use crate::model::Document; use crate::model::Document;
pub mod cbr;
pub mod cbx;
pub mod cbz; pub mod cbz;
pub mod pdf; pub mod pdf;
pub use cbx::CbxReader;
use cbr::CbrReader;
use cbz::{CbzReader, CbzWriter}; use cbz::{CbzReader, CbzWriter};
use pdf::{PdfReader, PdfWriter}; use pdf::{PdfReader, PdfWriter};
@ -15,6 +20,7 @@ use pdf::{PdfReader, PdfWriter};
pub enum FormatId { pub enum FormatId {
Cbz, Cbz,
Pdf, Pdf,
Cbr,
} }
impl FormatId { impl FormatId {
@ -32,6 +38,7 @@ impl FormatId {
match path.extension().and_then(OsStr::to_str) { match path.extension().and_then(OsStr::to_str) {
Some("cbz") => Some(FormatId::Cbz), Some("cbz") => Some(FormatId::Cbz),
Some("pdf") => Some(FormatId::Pdf), Some("pdf") => Some(FormatId::Pdf),
Some("cbr") => Some(FormatId::Cbr),
_ => None, _ => None,
} }
} }
@ -49,6 +56,7 @@ pub fn get_reader(format: FormatId) -> Option<Box<dyn FormatReader>> {
match format { match format {
FormatId::Cbz => Some(Box::new(CbzReader)), FormatId::Cbz => Some(Box::new(CbzReader)),
FormatId::Pdf => Some(Box::new(PdfReader)), FormatId::Pdf => Some(Box::new(PdfReader)),
FormatId::Cbr => Some(Box::new(CbrReader)),
} }
} }
@ -56,5 +64,6 @@ pub fn get_writer(format: FormatId) -> Option<Box<dyn FormatWriter>> {
match format { match format {
FormatId::Pdf => Some(Box::new(PdfWriter)), FormatId::Pdf => Some(Box::new(PdfWriter)),
FormatId::Cbz => Some(Box::new(CbzWriter)), FormatId::Cbz => Some(Box::new(CbzWriter)),
FormatId::Cbr => None,
} }
} }

View file

@ -21,6 +21,7 @@ impl Job {
match to { match to {
FormatId::Pdf => output_path.set_extension("pdf"), FormatId::Pdf => output_path.set_extension("pdf"),
FormatId::Cbz => output_path.set_extension("cbz"), FormatId::Cbz => output_path.set_extension("cbz"),
FormatId::Cbr => output_path.set_extension("cbr"),
}; };
Self { Self {

23
tests/cbr_reader_tests.rs Normal file
View file

@ -0,0 +1,23 @@
use std::fs::File;
use std::io::Write;
use cbz2pdf::formats::cbr::CbrReader;
use cbz2pdf::formats::FormatReader;
// We cannot reliably create a RAR archive in tests (tools cannot create .rar),
// Instead, verify that the reader fails gracefully (returns an error) when given an invalid .cbr
// file.
#[test]
fn cbr_reader_errors_on_invalid_archive() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let cbr_path = temp_dir.path().join("invalid.cbr");
// Write some junk that is definitely not a RAR archive
let mut f = File::create(&cbr_path).expect("create cbr");
f.write_all(b"this is not a rar archive").unwrap();
let reader = CbrReader;
let res = reader.read(&cbr_path);
assert!(res.is_err(), "CbrReader should error on invalid archives");
}

View file

@ -7,8 +7,10 @@ use cbz2pdf::job::Job;
fn detect_from_path_recognizes_extensions() { fn detect_from_path_recognizes_extensions() {
let cbz = PathBuf::from("/tmp/book.cbz"); let cbz = PathBuf::from("/tmp/book.cbz");
let pdf = PathBuf::from("/tmp/book.pdf"); let pdf = PathBuf::from("/tmp/book.pdf");
let cbr = PathBuf::from("/tmp/book.cbr");
assert_eq!(FormatId::detect_from_path(&cbz), Some(FormatId::Cbz)); assert_eq!(FormatId::detect_from_path(&cbz), Some(FormatId::Cbz));
assert_eq!(FormatId::detect_from_path(&pdf), Some(FormatId::Pdf)); assert_eq!(FormatId::detect_from_path(&pdf), Some(FormatId::Pdf));
assert_eq!(FormatId::detect_from_path(&cbr), Some(FormatId::Cbr));
assert_eq!( assert_eq!(
FormatId::detect_from_path(&PathBuf::from("/tmp/book.txt")), FormatId::detect_from_path(&PathBuf::from("/tmp/book.txt")),
None None
@ -24,11 +26,19 @@ fn job_new_sets_output_extension() {
let job2 = Job::new( let job2 = Job::new(
PathBuf::from("/tmp/book.pdf"), PathBuf::from("/tmp/book.pdf"),
outdir, outdir.clone(),
FormatId::Pdf, FormatId::Pdf,
FormatId::Cbz, FormatId::Cbz,
); );
assert!(job2.output_path.ends_with("book.cbz")); assert!(job2.output_path.ends_with("book.cbz"));
let job3 = Job::new(
PathBuf::from("/tmp/book.cbz"),
outdir,
FormatId::Cbz,
FormatId::Cbr,
);
assert!(job3.output_path.ends_with("book.cbr"));
} }
#[test] #[test]
@ -37,4 +47,6 @@ fn format_capabilities_consistent() {
assert!(FormatId::Cbz.can_write()); assert!(FormatId::Cbz.can_write());
assert!(FormatId::Pdf.can_write()); assert!(FormatId::Pdf.can_write());
assert!(FormatId::Pdf.can_read()); assert!(FormatId::Pdf.can_read());
assert!(FormatId::Cbr.can_read());
assert!(!FormatId::Cbr.can_write());
} }