link2epub-rs/src/lib.rs
src/lib.rsRaw
1use std::fs::{self, File};
2use std::io::Cursor;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::Duration;
7
8use anyhow::{Context, Result, bail};
9use epub_builder::{EpubBuilder, EpubContent, ReferenceType, ZipLibrary};
10use legible::parse;
11use reqwest::blocking::Client;
12use reqwest::header::{ACCEPT, CONTENT_TYPE, USER_AGENT};
13use scraper::{Html, Selector};
14use url::Url;
15
16// browser user-agent value used for the https requests
17const USER_AGENT_VALUE: &str = concat!("link2epub/", env!("CARGO_PKG_VERSION"));
18static TEMPORARY_IMAGE_ID: AtomicU64 = AtomicU64::new(0);
19const STYLESHEET: &str = r#"
20body { font-family: serif; line-height: 1.5; margin: 5%; }
21h1 { line-height: 1.15; margin-bottom: 0.3em; }
22.byline, .source { color: #555; font-size: 0.9em; }
23.article p { margin: 0 0 1em; }
24.article blockquote { border-left: 0.2em solid #999; margin-left: 0; padding-left: 1em; }
25.article pre { white-space: pre-wrap; }
26figure { margin: 1.5em 0; text-align: center; }
27img { height: auto; max-width: 100%; }
28"#;
29
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum Format {
32 Epub,
33 Mobi,
34}
35
36pub struct Options {
37 pub url: String,
38 pub format: Format,
39}
40
41#[derive(Debug)]
42struct Article {
43 title: String,
44 author: Option<String>,
45 content_html: String,
46 source_url: String,
47 images: Vec<ArticleImage>,
48 image_urls: Vec<(Url, String)>,
49}
50
51#[derive(Debug)]
52struct ArticleImage {
53 source_url: Url,
54 path: String,
55 media_type: &'static str,
56 bytes: Vec<u8>,
57}
58
59/// download an article and write it in the requested ebook format
60pub fn convert(options: Options) -> Result<PathBuf> {
61 let url = validate_url(&options.url)?;
62 eprintln!("downloading article: {url}");
63 let article = download_article(&url)?;
64 let output = default_output_path(&article.title, options.format);
65 eprintln!("article: {}", article.title);
66 eprintln!("output: {}", output.display());
67
68 match options.format {
69 Format::Epub => write_epub(&article, &output)?,
70 Format::Mobi => write_mobi(&article, &output)?,
71 }
72 Ok(output)
73}
74
75fn validate_url(input: &str) -> Result<Url> {
76 let url = Url::parse(input).context("invalid article URL")?;
77 if !matches!(url.scheme(), "http" | "https") {
78 bail!("url scheme must be http or https");
79 }
80 Ok(url)
81}
82
83/// fetches the page and applies a reader-mode extraction algorithm
84fn download_article(url: &Url) -> Result<Article> {
85 let client = Client::builder()
86 .timeout(Duration::from_secs(30))
87 .redirect(reqwest::redirect::Policy::limited(10))
88 .build()
89 .context("could not initialize http client")?;
90 let response = client
91 .get(url.clone())
92 .header(USER_AGENT, USER_AGENT_VALUE)
93 .header(ACCEPT, "text/html,application/xhtml+xml")
94 .send()
95 .with_context(|| format!("could not download {url}"))?
96 .error_for_status()
97 .with_context(|| format!("server returned an error for {url}"))?;
98 let final_url = response.url().clone();
99 let html = response
100 .text()
101 .context("could not read the downloaded page")?;
102 if final_url != *url {
103 eprintln!("redirected to: {final_url}");
104 }
105 eprintln!("extracting reader-mode content...");
106 let mut article = extract_article(&html, &final_url)?;
107 let image_count = article.image_urls.len();
108 eprintln!(
109 "found {} article image{}.",
110 image_count,
111 if image_count == 1 { "" } else { "s" }
112 );
113 article.images = download_images(&client, &article.image_urls);
114 article.image_urls.clear();
115 if image_count > 0 {
116 eprintln!("embedded {} of {image_count} images.", article.images.len());
117 }
118 Ok(article)
119}
120
121fn extract_article(html: &str, url: &Url) -> Result<Article> {
122 let extracted = parse(html, Some(url.as_str()), None)
123 .context("could not identify the article content on this page")?;
124 let title = extracted.title.trim().to_owned();
125 if title.is_empty() {
126 bail!("the page did not contain an article title");
127 }
128
129 let image_urls = discover_image_urls(&extracted.content, url);
130 let text = html2text::from_read(extracted.content.as_bytes(), 100)
131 .context("could not convert article HTML to readable text")?;
132 if text.trim().is_empty() {
133 bail!("the page did not contain readable article text");
134 }
135
136 Ok(Article {
137 title,
138 author: extracted.byline.filter(|value| !value.trim().is_empty()),
139 content_html: extracted.content,
140 source_url: url.to_string(),
141 images: Vec::new(),
142 image_urls,
143 })
144}
145
146fn discover_image_urls(html: &str, base_url: &Url) -> Vec<(Url, String)> {
147 let document = Html::parse_fragment(html);
148 let selector = Selector::parse("img").expect("valid image selector");
149 document
150 .select(&selector)
151 .filter_map(|image| {
152 let attributes = image.value();
153 let source = attributes
154 .attr("data-src")
155 .or_else(|| attributes.attr("data-lazy-src"))
156 .or_else(|| attributes.attr("src"))?;
157 let url = base_url.join(source).ok()?;
158 matches!(url.scheme(), "http" | "https")
159 .then(|| (url, attributes.attr("alt").unwrap_or_default().to_owned()))
160 })
161 .collect()
162}
163
164/// downloads supported article images (treats individual broken images as optional)
165fn download_images(client: &Client, candidates: &[(Url, String)]) -> Vec<ArticleImage> {
166 let mut images = Vec::new();
167 for (index, (url, _alt)) in candidates.iter().enumerate() {
168 eprintln!(
169 "[{}/{}] downloading image: {url}",
170 index + 1,
171 candidates.len()
172 );
173 let result = (|| -> Result<ArticleImage> {
174 let response = client
175 .get(url.clone())
176 .header(USER_AGENT, USER_AGENT_VALUE)
177 .send()?
178 .error_for_status()?;
179 let reported_type = response
180 .headers()
181 .get(CONTENT_TYPE)
182 .and_then(|value| value.to_str().ok())
183 .map(|value| {
184 value
185 .split(';')
186 .next()
187 .unwrap_or(value)
188 .trim()
189 .to_ascii_lowercase()
190 });
191 let bytes = response.bytes()?.to_vec();
192 if bytes.is_empty() {
193 bail!("empty image");
194 }
195 let source_type = reported_type
196 .as_deref()
197 .and_then(supported_image_type)
198 .or_else(|| image_type_from_url(url).and_then(supported_image_type))
199 .context("unsupported image type")?;
200 let (bytes, media_type) = if source_type == "image/avif" {
201 eprintln!(
202 "[{}/{}] converting avif to png...",
203 index + 1,
204 candidates.len()
205 );
206 (
207 avif_to_png(&bytes).context("could not convert avif image to png")?,
208 "image/png",
209 )
210 } else {
211 (bytes, source_type)
212 };
213 let extension = image_extension(media_type);
214 Ok(ArticleImage {
215 source_url: url.clone(),
216 path: format!("images/image-{}.{}", images.len() + 1, extension),
217 media_type,
218 bytes,
219 })
220 })();
221 match result {
222 Ok(image) => {
223 eprintln!("[{}/{}] image ready.", index + 1, candidates.len());
224 images.push(image);
225 }
226 Err(error) => eprintln!("warning: skipping image {url}: {error:#}"),
227 }
228 }
229 images
230}
231
232/// transcodes avif to the broadly supported png format used inside the ebook.
233fn avif_to_png(bytes: &[u8]) -> Result<Vec<u8>> {
234 match avif_to_png_rust(bytes) {
235 Ok(png) => Ok(png),
236 Err(rust_error) => {
237 eprintln!("pure-rust avif decoder could not handle this image; trying avifdec...");
238 avif_to_png_with_avifdec(bytes).with_context(|| {
239 format!("pure-rust decoder failed ({rust_error}); avifdec fallback also failed")
240 })
241 }
242 }
243}
244
245fn avif_to_png_rust(bytes: &[u8]) -> Result<Vec<u8>> {
246 let image = avif_rust::image_from_bytes(bytes).context("could not decode avif pixels")?;
247 lodepng::encode_memory(
248 &image.rgba,
249 image.width,
250 image.height,
251 lodepng::ColorType::RGBA,
252 8,
253 )
254 .context("could not encode png pixels")
255}
256
257/// uses libavif's reference CLI for bitstreams unsupported by the Rust decoder.
258fn avif_to_png_with_avifdec(bytes: &[u8]) -> Result<Vec<u8>> {
259 let id = TEMPORARY_IMAGE_ID.fetch_add(1, Ordering::Relaxed);
260 let stem = format!("link2epub-{}-{id}", std::process::id());
261 let input = std::env::temp_dir().join(format!("{stem}.avif"));
262 let output = std::env::temp_dir().join(format!("{stem}.png"));
263 fs::write(&input, bytes).context("could not create temporary avif")?;
264 let result = Command::new("avifdec").arg(&input).arg(&output).output();
265 let _ = fs::remove_file(&input);
266
267 let command_output =
268 result.context("avifdec is not available on PATH")?;
269 if !command_output.status.success() {
270 let _ = fs::remove_file(&output);
271 let stderr = String::from_utf8_lossy(&command_output.stderr);
272 bail!("avifdec could not transcode the image: {}", stderr.trim());
273 }
274 let png = fs::read(&output).context("avifdec did not create the temporary png")?;
275 let _ = fs::remove_file(&output);
276 if !png.starts_with(b"\x89png\r\n\x1a\n") {
277 bail!("avifdec produced an invalid png");
278 }
279 Ok(png)
280}
281
282fn supported_image_type(value: &str) -> Option<&'static str> {
283 match value.trim().to_ascii_lowercase().as_str() {
284 "image/jpeg" | "image/jpg" => Some("image/jpeg"),
285 "image/png" => Some("image/png"),
286 "image/gif" => Some("image/gif"),
287 "image/webp" => Some("image/webp"),
288 "image/svg+xml" => Some("image/svg+xml"),
289 "image/avif" => Some("image/avif"),
290 _ => None,
291 }
292}
293
294fn image_type_from_url(url: &Url) -> Option<&'static str> {
295 let path = url.path().to_ascii_lowercase();
296 let extension = path.rsplit('.').next()?;
297 match extension {
298 "jpg" | "jpeg" => Some("image/jpeg"),
299 "png" => Some("image/png"),
300 "gif" => Some("image/gif"),
301 "webp" => Some("image/webp"),
302 "svg" => Some("image/svg+xml"),
303 "avif" => Some("image/avif"),
304 _ => None,
305 }
306}
307
308fn image_extension(media_type: &str) -> &'static str {
309 match media_type {
310 "image/jpeg" => "jpg",
311 "image/png" => "png",
312 "image/gif" => "gif",
313 "image/webp" => "webp",
314 "image/svg+xml" => "svg",
315 "image/avif" => "avif",
316 _ => unreachable!("only supported image types reach this point"),
317 }
318}
319
320fn write_epub(article: &Article, path: &Path) -> Result<()> {
321 eprintln!("building epub: {}", path.display());
322 let file =
323 File::create(path).with_context(|| format!("could not create {}", path.display()))?;
324 build_epub(article, file).with_context(|| format!("could not generate {}", path.display()))
325}
326
327fn build_epub(article: &Article, output: impl std::io::Write + std::io::Seek) -> Result<()> {
328 let author = article.author.as_deref().unwrap_or("unknown author");
329 let content = article_xhtml(article);
330 let mut builder = EpubBuilder::new(ZipLibrary::new()?)?;
331 builder
332 .metadata("title", &article.title)?
333 .metadata("author", author)?
334 .metadata("lang", "en")?
335 .stylesheet(STYLESHEET.as_bytes())?;
336 for image in &article.images {
337 builder.add_resource(
338 &image.path,
339 Cursor::new(image.bytes.as_slice()),
340 image.media_type,
341 )?;
342 }
343 builder.add_content(
344 EpubContent::new("article.xhtml", Cursor::new(content.into_bytes()))
345 .title(&article.title)
346 .reftype(ReferenceType::Text),
347 )?;
348 builder.generate(output)?;
349 Ok(())
350}
351
352/// builds an intermediate epub and delegates mobi encoding to Calibre.
353fn write_mobi(article: &Article, output: &Path) -> Result<()> {
354 let temporary = temporary_epub_path(output);
355 eprintln!("preparing intermediate epub for mobi conversion...");
356 write_epub(article, &temporary)?;
357
358 let conversion = if command_available("ebook-convert") {
359 eprintln!("converting epub to mobi with Calibre...");
360 Command::new("ebook-convert")
361 .arg(&temporary)
362 .arg(output)
363 .status()
364 .context("could not run ebook-convert")
365 } else {
366 let _ = fs::remove_file(&temporary);
367 bail!(
368 "mobi output requires Calibre's ebook-convert on PATH; install Calibre or use --format epub"
369 );
370 };
371
372 let status = conversion?;
373 let _ = fs::remove_file(&temporary);
374 if !status.success() {
375 bail!("mobi converter exited with status {status}");
376 }
377 if !output.is_file() {
378 bail!(
379 "mobi converter succeeded but did not create {}",
380 output.display()
381 );
382 }
383 Ok(())
384}
385
386fn command_available(command: &str) -> bool {
387 Command::new(command)
388 .arg("--version")
389 .output()
390 .is_ok_and(|output| output.status.success())
391}
392
393fn article_xhtml(article: &Article) -> String {
394 let byline = article
395 .author
396 .as_deref()
397 .map_or_else(String::new, |author| {
398 format!("<p class=\"byline\">{}</p>", escape_html(author))
399 });
400 let body = render_article_html(article);
401 format!(
402 r#"<?xml version="1.0" encoding="utf-8"?>
403<!DOCTYPE html>
404<html xmlns="http://www.w3.org/1999/xhtml">
405<head><title>{title}</title><link rel="stylesheet" type="text/css" href="stylesheet.css" /></head>
406<body><h1>{title}</h1>{byline}<p class="source">Source: <a href="{url}">{url}</a></p><div class="article">{body}</div></body>
407</html>"#,
408 title = escape_html(&article.title),
409 url = escape_html(&article.source_url),
410 )
411}
412
413/// Converts reader-mode HTML to a conservative XHTML subset and rewrites images locally.
414fn render_article_html(article: &Article) -> String {
415 let document = Html::parse_fragment(&article.content_html);
416 let base_url = Url::parse(&article.source_url).expect("stored source url is valid");
417 let mut output = String::new();
418 for child in document.tree.root().children() {
419 render_node(child, &base_url, &article.images, &mut output);
420 }
421 output
422}
423
424fn render_node(
425 node: ego_tree::NodeRef<'_, scraper::node::Node>,
426 base_url: &Url,
427 images: &[ArticleImage],
428 output: &mut String,
429) {
430 use scraper::node::Node;
431
432 match node.value() {
433 Node::Text(text) => output.push_str(&escape_html(text)),
434 Node::Element(element) => {
435 let tag = element.name();
436 if matches!(
437 tag,
438 "script" | "style" | "iframe" | "form" | "nav" | "aside"
439 ) {
440 return;
441 }
442 if tag == "img" {
443 render_image(element, base_url, images, output);
444 return;
445 }
446 if matches!(tag, "br" | "hr") {
447 output.push('<');
448 output.push_str(tag);
449 output.push_str(" />");
450 return;
451 }
452
453 let allowed = matches!(
454 tag,
455 "p" | "div"
456 | "section"
457 | "article"
458 | "header"
459 | "footer"
460 | "h1"
461 | "h2"
462 | "h3"
463 | "h4"
464 | "h5"
465 | "h6"
466 | "ul"
467 | "ol"
468 | "li"
469 | "blockquote"
470 | "pre"
471 | "code"
472 | "strong"
473 | "b"
474 | "em"
475 | "i"
476 | "u"
477 | "s"
478 | "sup"
479 | "sub"
480 | "figure"
481 | "figcaption"
482 | "table"
483 | "thead"
484 | "tbody"
485 | "tfoot"
486 | "tr"
487 | "th"
488 | "td"
489 | "a"
490 | "span"
491 );
492 if allowed {
493 output.push('<');
494 output.push_str(tag);
495 if tag == "a"
496 && let Some(href) = element.attr("href")
497 && let Ok(url) = base_url.join(href)
498 && matches!(url.scheme(), "http" | "https")
499 {
500 output.push_str(" href=\"");
501 output.push_str(&escape_html(url.as_str()));
502 output.push('"');
503 }
504 output.push('>');
505 }
506 for child in node.children() {
507 render_node(child, base_url, images, output);
508 }
509 if allowed {
510 output.push_str("</");
511 output.push_str(tag);
512 output.push('>');
513 }
514 }
515 _ => {
516 for child in node.children() {
517 render_node(child, base_url, images, output);
518 }
519 }
520 }
521}
522
523fn render_image(
524 element: &scraper::node::Element,
525 base_url: &Url,
526 images: &[ArticleImage],
527 output: &mut String,
528) {
529 let Some(source) = element
530 .attr("data-src")
531 .or_else(|| element.attr("data-lazy-src"))
532 .or_else(|| element.attr("src"))
533 else {
534 return;
535 };
536 let Ok(url) = base_url.join(source) else {
537 return;
538 };
539 let Some(image) = images.iter().find(|image| image.source_url == url) else {
540 return;
541 };
542 output.push_str("<img src=\"");
543 output.push_str(&escape_html(&image.path));
544 output.push_str("\" alt=\"");
545 output.push_str(&escape_html(element.attr("alt").unwrap_or_default()));
546 output.push_str("\" />");
547}
548
549fn escape_html(value: &str) -> String {
550 value
551 .replace('&', "&")
552 .replace('<', "<")
553 .replace('>', ">")
554 .replace('"', """)
555 .replace('\'', "'")
556}
557
558fn default_output_path(title: &str, format: Format) -> PathBuf {
559 let mut filename: String = title
560 .chars()
561 .map(|character| match character {
562 '/' | '\\' | '\0' => '_',
563 character if character.is_control() => ' ',
564 character => character,
565 })
566 .collect();
567 filename = filename.trim().trim_matches('.').trim().to_owned();
568 if filename.is_empty() {
569 filename.push_str("article");
570 }
571 filename.truncate(filename.floor_char_boundary(200));
572 PathBuf::from(format!("{filename}.{}", extension(format)))
573}
574
575fn extension(format: Format) -> &'static str {
576 match format {
577 Format::Epub => "epub",
578 Format::Mobi => "mobi",
579 }
580}
581
582fn temporary_epub_path(output: &Path) -> PathBuf {
583 let mut temporary = output.as_os_str().to_owned();
584 temporary.push(format!(".{}.epub", std::process::id()));
585 PathBuf::from(temporary)
586}