use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use crate::dom::Stylesheet;
use crate::import::{ChapterId, Importer, SpineEntry};
use crate::model::{AnchorTarget, Chapter, FontFace, Landmark, Metadata, TocEntry};
#[derive(Debug, Clone)]
pub struct PassReport {
pub pass: &'static str,
pub assets_changed: usize,
pub bytes_saved: u64,
}
#[derive(Debug, Clone, Default)]
pub struct OptimizeReport {
pub passes: Vec<PassReport>,
}
impl OptimizeReport {
pub fn assets_changed(&self) -> usize {
self.passes.iter().map(|p| p.assets_changed).sum()
}
pub fn bytes_saved(&self) -> u64 {
self.passes.iter().map(|p| p.bytes_saved).sum()
}
}
pub(crate) struct AssetEdit {
pub path: String,
pub new_path: Option<String>,
pub data: Vec<u8>,
}
pub(crate) trait OptimizePass {
fn name(&self) -> &'static str;
fn run(&self, backend: &dyn Importer) -> Vec<AssetEdit>;
}
#[allow(clippy::vec_init_then_push)]
fn default_passes() -> Vec<Box<dyn OptimizePass>> {
#[allow(unused_mut)]
let mut passes: Vec<Box<dyn OptimizePass>> = Vec::new();
#[cfg(feature = "optimize-images")]
passes.push(Box::new(passes::Images {
quality: 80,
min_size: 10 * 1024,
max_dimension: 1236,
}));
passes
}
pub(crate) fn css_referenced_text(backend: &dyn Importer) -> String {
let mut css_text = String::new();
for path in backend.list_assets() {
if path
.rsplit('.')
.next()
.is_some_and(|e| e.eq_ignore_ascii_case("css"))
&& let Ok(data) = backend.load_asset(path)
{
css_text.push_str(&String::from_utf8_lossy(&data));
}
}
css_text
}
pub(crate) struct OptimizedImporter {
inner: Box<dyn Importer>,
assets: Vec<String>,
overrides: HashMap<String, Vec<u8>>,
renames: HashMap<String, String>,
metadata: Metadata,
}
impl OptimizedImporter {
pub(crate) fn apply(inner: Box<dyn Importer>, pass: &dyn OptimizePass) -> (Self, PassReport) {
let mut report = PassReport {
pass: pass.name(),
assets_changed: 0,
bytes_saved: 0,
};
let asset_paths: Vec<String> = inner.list_assets().to_vec();
let mut overrides = HashMap::new();
let mut renames = HashMap::new();
for edit in pass.run(inner.as_ref()) {
if !asset_paths.contains(&edit.path) {
continue;
}
let serving_path = edit.new_path.as_ref().unwrap_or(&edit.path);
if *serving_path != edit.path
&& (asset_paths.iter().any(|p| p == serving_path)
|| overrides.contains_key(serving_path))
{
continue;
}
let Ok(original) = inner.load_asset(&edit.path) else {
continue;
};
if edit.data.len() >= original.len() {
continue;
}
report.assets_changed += 1;
report.bytes_saved += (original.len() - edit.data.len()) as u64;
if *serving_path != edit.path {
renames.insert(edit.path.clone(), serving_path.clone());
}
overrides.insert(serving_path.clone(), edit.data);
}
let assets = asset_paths
.iter()
.map(|p| renames.get(p).unwrap_or(p).clone())
.collect();
let mut metadata = inner.metadata().clone();
if let Some(cover) = &metadata.cover_image
&& let Some(new_path) = renames.get(cover)
{
metadata.cover_image = Some(new_path.clone());
}
(
Self {
inner,
assets,
overrides,
renames,
metadata,
},
report,
)
}
fn rewrite_chapter(&self, mut chapter: Chapter) -> Chapter {
if self.renames.is_empty() {
return chapter;
}
let updates: Vec<(crate::model::NodeId, String)> = chapter
.iter_dfs()
.filter_map(|node| {
let src = chapter.semantics.src(node)?;
Some((node, self.renames.get(src)?.clone()))
})
.collect();
for (node, new_src) in updates {
chapter.semantics.set_src(node, &new_src);
}
chapter
}
}
impl Importer for OptimizedImporter {
fn open(_path: &Path) -> crate::Result<Self>
where
Self: Sized,
{
Err(crate::Error::UnsupportedFormat {
detail: "OptimizedImporter wraps an existing backend".to_string(),
})
}
fn metadata(&self) -> &Metadata {
&self.metadata
}
fn toc(&self) -> &[TocEntry] {
self.inner.toc()
}
fn landmarks(&self) -> &[Landmark] {
self.inner.landmarks()
}
fn spine(&self) -> &[SpineEntry] {
self.inner.spine()
}
fn load_chapter(&self, id: ChapterId) -> crate::Result<Chapter> {
self.inner
.load_chapter(id)
.map(|ch| self.rewrite_chapter(ch))
}
fn load_chapters(&self, ids: &[ChapterId]) -> Vec<crate::Result<Chapter>> {
self.inner
.load_chapters(ids)
.into_iter()
.map(|res| res.map(|ch| self.rewrite_chapter(ch)))
.collect()
}
fn source_id(&self, id: ChapterId) -> Option<&str> {
self.inner.source_id(id)
}
fn load_raw(&self, id: ChapterId) -> crate::Result<Vec<u8>> {
self.inner.load_raw(id)
}
fn list_assets(&self) -> &[String] {
&self.assets
}
fn load_asset(&self, path: &str) -> crate::Result<Vec<u8>> {
if let Some(data) = self.overrides.get(path) {
return Ok(data.clone());
}
if let Some(new_path) = self.renames.get(path) {
return Ok(self.overrides[new_path].clone());
}
self.inner.load_asset(path)
}
fn load_stylesheet(&self, path: &str) -> Option<Arc<Stylesheet>> {
self.inner.load_stylesheet(path)
}
fn font_faces(&self) -> Vec<FontFace> {
self.inner.font_faces()
}
fn requires_normalized_export(&self) -> bool {
!self.renames.is_empty() || self.inner.requires_normalized_export()
}
fn index_anchors(&self, chapters: &[(ChapterId, Arc<Chapter>)]) {
self.inner.index_anchors(chapters)
}
fn resolve_toc(&self) -> Option<Vec<TocEntry>> {
self.inner.resolve_toc()
}
fn resolve_href(&self, from_chapter: ChapterId, href: &str) -> Option<AnchorTarget> {
self.inner.resolve_href(from_chapter, href)
}
}
struct EmptyBackend(Metadata);
impl Importer for EmptyBackend {
fn open(_path: &Path) -> crate::Result<Self>
where
Self: Sized,
{
unreachable!("EmptyBackend is never opened from a path")
}
fn metadata(&self) -> &Metadata {
&self.0
}
fn toc(&self) -> &[TocEntry] {
&[]
}
fn landmarks(&self) -> &[Landmark] {
&[]
}
fn spine(&self) -> &[SpineEntry] {
&[]
}
fn source_id(&self, _id: ChapterId) -> Option<&str> {
None
}
fn load_raw(&self, _id: ChapterId) -> crate::Result<Vec<u8>> {
Err(crate::Error::UnsupportedFormat {
detail: "placeholder backend".to_string(),
})
}
fn list_assets(&self) -> &[String] {
&[]
}
fn load_asset(&self, _path: &str) -> crate::Result<Vec<u8>> {
Err(crate::Error::UnsupportedFormat {
detail: "placeholder backend".to_string(),
})
}
}
impl crate::Book {
pub fn optimize(&mut self) -> OptimizeReport {
let mut report = OptimizeReport::default();
let mut backend = self.replace_backend(Box::new(EmptyBackend(Metadata::default())));
for pass in default_passes() {
let (wrapped, pass_report) = OptimizedImporter::apply(backend, pass.as_ref());
backend = Box::new(wrapped);
report.passes.push(pass_report);
}
self.replace_backend(backend);
report
}
}
mod passes {
#[cfg(feature = "optimize-images")]
pub(super) struct Images {
pub quality: u8,
pub min_size: usize,
pub max_dimension: u32,
}
#[cfg(feature = "optimize-images")]
const MIN_SAVING: f64 = 0.10;
#[cfg(feature = "optimize-images")]
impl super::OptimizePass for Images {
fn name(&self) -> &'static str {
"images"
}
fn run(&self, backend: &dyn super::Importer) -> Vec<super::AssetEdit> {
use crate::util::{MediaFormat, detect_media_format, reencode_image_as_jpeg};
let css_text = super::css_referenced_text(backend);
let mut edits = Vec::new();
for path in backend.list_assets() {
let Ok(data) = backend.load_asset(path) else {
continue;
};
if data.len() < self.min_size {
continue;
}
let format = detect_media_format(path, &data);
if !matches!(format, MediaFormat::Png | MediaFormat::Jpeg) {
continue;
}
let Some(jpeg) =
reencode_image_as_jpeg(&data, self.quality, Some(self.max_dimension))
else {
continue;
};
if (jpeg.len() as f64) > (data.len() as f64) * (1.0 - MIN_SAVING) {
continue;
}
let new_path = if format == MediaFormat::Png {
let basename = path.rsplit('/').next().unwrap_or(path);
if !css_text.is_empty() && css_text.contains(basename) {
continue;
}
Some(
match path
.strip_suffix(".png")
.or_else(|| path.strip_suffix(".PNG"))
{
Some(stem) => format!("{stem}.jpg"),
None => format!("{path}.jpg"),
},
)
} else {
None
};
edits.push(super::AssetEdit {
path: path.clone(),
new_path,
data: jpeg,
});
}
edits
}
}
}