1#![doc = include_str!("../README.md")]
2
3mod ocr;
4mod opt;
5
6pub use crate::{ocr::process, ocr::Error as OcrError, ocr::OcrOpt, opt::Opt};
7
8use image::GrayImage;
9use log::warn;
10use rayon::{
11 iter::{IntoParallelRefIterator as _, ParallelIterator as _},
12 ThreadPoolBuildError,
13};
14use std::{
15 fs::File,
16 io::{self, BufReader, BufWriter},
17 path::{Path, PathBuf},
18};
19use subtile::{
20 image::{dump_images, luma_a_to_luma, ToImage as _, ToOcrImage as _, ToOcrImageOpt},
21 pgs::{self, DecodeTimeImage, RleToImage},
22 srt,
23 time::TimeSpan,
24 vobsub::{
25 self, conv_to_rgba, palette_rgb_to_luminance, VobSubError, VobSubIndexedImage,
26 VobSubOcrImage, VobSubToImage,
27 },
28 SubtileError,
29};
30use thiserror::Error;
31
32#[expect(missing_docs)]
34#[derive(Error, Debug)]
35pub enum Error {
36 #[error("failed to create a rayon ThreadPool")]
37 RayonThreadPool(#[from] ThreadPoolBuildError),
38
39 #[error("the file extension '{extension}' is not managed")]
40 InvalidFileExtension { extension: String },
41
42 #[error("the file doesn't have an extension, can't choose a parser")]
43 NoFileExtension,
44
45 #[error("the file doesn't have an utf8 extension, can't choose a parser")]
46 NotUtf8Extension,
47
48 #[error("failed to open `Index` file")]
49 IndexOpen(#[source] VobSubError),
50
51 #[error("failed to open `Sub` file")]
52 SubOpen(#[source] VobSubError),
53
54 #[error("failed to create PgsParser from file")]
55 PgsParserFromFile(#[source] pgs::PgsError),
56
57 #[error("failed to parse Pgs")]
58 PgsParsing(#[source] pgs::PgsError),
59
60 #[error("failed to dump subtitles images")]
61 DumpImage(#[source] SubtileError),
62
63 #[error("could not perform OCR on subtitles.")]
64 Ocr(#[from] ocr::Error),
65
66 #[error("error happen during OCR on {0} subtitles images")]
67 OcrFails(u32),
68
69 #[error("could not generate SRT file: {message}")]
70 GenerateSrt { message: String },
71
72 #[error("could not write SRT file {}", path.display())]
73 WriteSrtFile { path: PathBuf, source: io::Error },
74
75 #[error("could not write SRT on stdout.")]
76 WriteSrtStdout { source: io::Error },
77}
78
79#[profiling::function]
90pub fn run(opt: &Opt) -> Result<(), Error> {
91 rayon::ThreadPoolBuilder::new()
92 .thread_name(|idx| format!("Rayon_{idx}"))
93 .build_global()
94 .map_err(Error::RayonThreadPool)?;
95
96 let (times, images) = match extract_extension(&opt.input)? {
97 "sup" => process_pgs(opt),
98 "sub" | "idx" => process_vobsub(opt),
99 ext => Err(Error::InvalidFileExtension {
100 extension: ext.into(),
101 }),
102 }?;
103
104 if opt.dump {
106 dump_images("dumps", &images).map_err(Error::DumpImage)?;
107 }
108
109 let ocr_opt = OcrOpt::new(&opt.tessdata_dir, opt.lang.as_str(), &opt.config, opt.dpi);
110 let texts = ocr::process(images, &ocr_opt)?;
111 let subtitles = check_subtitles(times.into_iter().zip(texts))?;
112
113 write_srt(opt.output.as_deref(), &subtitles)?;
115
116 Ok(())
117}
118
119pub fn extract_extension(path: &Path) -> Result<&str, Error> {
126 path.extension()
127 .ok_or(Error::NoFileExtension)?
128 .to_str()
129 .ok_or(Error::NotUtf8Extension)
130}
131
132#[profiling::function]
140pub fn process_pgs(opt: &Opt) -> Result<(Vec<TimeSpan>, Vec<GrayImage>), Error> {
141 let parser = {
142 profiling::scope!("Create PGS parser");
143 subtile::pgs::SupParser::<BufReader<File>, DecodeTimeImage>::from_file(&opt.input)
144 .map_err(Error::PgsParserFromFile)?
145 };
146
147 let (times, rle_images) = {
148 profiling::scope!("Parse PGS file");
149 parser
150 .collect::<Result<(Vec<_>, Vec<_>), _>>()
151 .map_err(Error::PgsParsing)?
152 };
153
154 if opt.dump_raw {
155 let images = rle_images
156 .iter()
157 .map(|rle_img| RleToImage::new(rle_img, |pix| pix).to_image());
158 dump_images("dumps_raw", images).map_err(Error::DumpImage)?;
159 }
160
161 let conv_fn = luma_a_to_luma::<_, _, 100, 100>; let images = {
164 profiling::scope!("Convert images for OCR");
165 let ocr_opt = ocr_opt(opt);
166 rle_images
167 .par_iter()
168 .map(|rle_img| RleToImage::new(rle_img, &conv_fn).image(&ocr_opt))
169 .collect::<Vec<_>>()
170 };
171
172 Ok((times, images))
173}
174
175#[profiling::function]
182pub fn process_vobsub(opt: &Opt) -> Result<(Vec<TimeSpan>, Vec<GrayImage>), Error> {
183 let mut input_path = opt.input.clone();
184 let sub = {
185 profiling::scope!("Open sub");
186 input_path.set_extension("sub");
187 vobsub::Sub::open(&input_path).map_err(Error::SubOpen)?
188 };
189 let idx = {
190 profiling::scope!("Open idx");
191 input_path.set_extension("idx");
192 vobsub::Index::open(&input_path).map_err(Error::IndexOpen)?
193 };
194 let (times, images): (Vec<_>, Vec<_>) = {
195 profiling::scope!("Parse subtitles");
196 sub.subtitles::<(TimeSpan, VobSubIndexedImage)>()
197 .filter_map(|sub| match sub {
198 Ok(sub) => Some(sub),
199 Err(e) => {
200 warn!(
201 "warning: unable to read subtitle: {e}. (This can usually be safely ignored.)"
202 );
203 None
204 }
205 })
206 .unzip()
207 };
208
209 if opt.dump_raw {
210 let images = images.iter().map(|rle_img| {
211 let image: image::RgbaImage =
212 VobSubToImage::new(rle_img, idx.palette(), conv_to_rgba).to_image();
213 image
214 });
215 dump_images("dumps_raw", images).map_err(Error::DumpImage)?;
216 }
217
218 let images_for_ocr = {
219 profiling::scope!("Convert images for OCR");
220
221 let ocr_opt = ocr_opt(opt);
222 let palette = palette_rgb_to_luminance(idx.palette());
223 images
224 .par_iter()
225 .map(|vobsub_img| {
226 let converter = VobSubOcrImage::new(vobsub_img, &palette);
227 converter.image(&ocr_opt)
228 })
229 .collect::<Vec<_>>()
230 };
231
232 Ok((times, images_for_ocr))
233}
234
235fn ocr_opt(opt: &Opt) -> ToOcrImageOpt {
237 ToOcrImageOpt {
238 border: opt.border,
239 ..Default::default()
240 }
241}
242
243#[profiling::function]
248pub fn check_subtitles<In>(subtitles: In) -> Result<Vec<(TimeSpan, String)>, Error>
249where
250 In: IntoIterator<Item = (TimeSpan, Result<String, ocr::Error>)>,
251{
252 let mut ocr_error_count = 0;
253 let subtitles = subtitles
254 .into_iter()
255 .enumerate()
256 .filter_map(|(idx, (time, maybe_text))| match maybe_text {
257 Ok(text) => Some((time, text)),
258 Err(e) => {
259 let err = anyhow::Error::new(e); warn!(
261 "Error while running OCR on subtitle image ({} - {time:?}):\n\t {err:#}",
262 idx + 1,
263 );
264 ocr_error_count += 1;
265 None
266 }
267 })
268 .collect::<Vec<_>>();
269
270 if ocr_error_count > 0 {
271 Err(Error::OcrFails(ocr_error_count))
272 } else {
273 Ok(subtitles)
274 }
275}
276
277#[profiling::function]
278fn write_srt(path: Option<&Path>, subtitles: &[(TimeSpan, String)]) -> Result<(), Error> {
279 if let Some(path) = &path {
280 write_to_file(path, subtitles).map_err(|source| Error::WriteSrtFile {
281 path: path.to_path_buf(),
282 source,
283 })?;
284 } else {
285 let mut stdout = io::stdout();
287 srt::write_srt(&mut stdout, subtitles)
288 .map_err(|source| Error::WriteSrtStdout { source })?;
289 }
290 Ok(())
291}
292
293fn write_to_file(path: &Path, subtitles: &[(TimeSpan, String)]) -> Result<(), io::Error> {
295 let subtitle_file = File::create(path)?;
296 let mut stream = BufWriter::new(subtitle_file);
297 srt::write_srt(&mut stream, subtitles)
298}