Compare commits
4 commits
7c5a1b2df5
...
d7bb52d95b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7bb52d95b | ||
|
|
60cbbdf58b | ||
| 6379e8a56b | |||
|
|
a16ec085b1 |
11 changed files with 595 additions and 354 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
|
.DS_Store
|
||||||
.direnv/
|
.direnv/
|
||||||
.idea/
|
.idea/
|
||||||
/target
|
/target
|
||||||
|
|
|
||||||
753
Cargo.lock
generated
753
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
12
flake.lock
generated
|
|
@ -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
75
src/formats/cbr.rs
Normal 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
30
src/formats/cbx.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
23
tests/cbr_reader_tests.rs
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue