//! Preview an article the way the Xteink X4 will show it. //! //! Browsers are a poor stand-in for the X4: its pages are rendered ahead of //! time by the XTC converter's CREngine, which has its own defaults (`code` is a //! block, `pre` has no margin) and only four grey levels. This builds both //! EPUB editions around one article, runs the real converter on the X4 one and //! decodes every page to `page-NNN.png`. //! //! ```text //! cargo run --example x4_preview --
//! cargo run --example x4_preview -- --convert //! ``` //! //! A URL goes through the real extraction path; an HTML file is used as the //! stored body as-is. `--convert` skips the build, for iterating on a //! stylesheet inside an unzipped EPUB. Needs the converter at //! `/opt/epub-to-xtc-converter` and runs it with `xtc-settings.example.json`. use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use daily_epub::config::Config; use daily_epub::epub; use daily_epub::extract::Extractor; use daily_epub::types::Edition; const CONVERTER: &str = "/opt/epub-to-xtc-converter/cli/index.js"; const SETTINGS: &str = "xtc-settings.example.json"; #[tokio::main] async fn main() -> anyhow::Result<()> { let args: Vec = std::env::args().skip(1).collect(); match args.as_slice() { [flag, epub, out] if flag == "--convert" => { let pages = convert(Path::new(epub), Path::new(out))?; println!("{pages} pages in {out}"); } [source, out] => { let out = PathBuf::from(out); std::fs::create_dir_all(&out)?; let html = if source.starts_with("http") { let extractor = Extractor::new(reqwest::Client::new(), vec![]); let page = extractor.fetch_readable(source).await?; extractor.finish_readable(source, &page).content_html } else { std::fs::read_to_string(source)? }; std::fs::write(out.join("stored.html"), &html)?; let mut issue = epub::fixtures::issue(); issue.lineup.picks.truncate(1); issue.lineup.picks[0].article.content_html = html; let config = Config::default(); for edition in [Edition::Standard, Edition::X4] { let artifact = epub::build_edition_with_images(&issue, edition, &config, &out, &[])?; println!("{edition:?}: {}", artifact.path.display()); if edition == Edition::X4 { let pages = convert(&artifact.path, &out)?; println!("{pages} X4 pages in {}", out.display()); } } } _ => anyhow::bail!( "usage: x4_preview | --convert " ), } Ok(()) } /// Run the converter on `epub` and decode its pages into `out`. fn convert(epub: &Path, out: &Path) -> anyhow::Result { std::fs::create_dir_all(out)?; let xtch = out.join("x4.xtch"); let status = Command::new("node") .args([CONVERTER, "convert"]) .arg(epub) .arg("-o") .arg(&xtch) .args(["-f", "xtch", "-c", SETTINGS]) .stdout(Stdio::null()) .status()?; anyhow::ensure!(status.success(), "converter failed: {status}"); decode_xtch(&xtch, out) } fn u16_at(bytes: &[u8], at: usize) -> usize { u16::from_le_bytes([bytes[at], bytes[at + 1]]) as usize } fn u64_at(bytes: &[u8], at: usize) -> usize { u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize } /// Decode every page of an XTCH container to a PNG. /// /// Layout per the converter's `docs/xtc-format-spec.md`: the page index is at /// the offset in header bytes 0x18..0x20, 16 bytes per page (offset, size, /// width, height). Each page is a 22-byte header and two bit planes stored /// column by column, right to left, 8 vertical pixels per byte. The plane /// order follows the encoder (`cli/encoder.js`), not the spec's table: the /// first plane holds the level's low bit, and 0b01 is dark grey. fn decode_xtch(path: &Path, out: &Path) -> anyhow::Result { let bytes = std::fs::read(path)?; let pages = u16_at(&bytes, 0x06); let index = u64_at(&bytes, 0x18); for page in 0..pages { let entry = index + page * 16; let (offset, width, height) = ( u64_at(&bytes, entry), u16_at(&bytes, entry + 12), u16_at(&bytes, entry + 14), ); let plane = (width * height).div_ceil(8); let column = height.div_ceil(8); let data = &bytes[offset + 22..]; let mut image = image::GrayImage::new(width as u32, height as u32); for x in 0..width { for y in 0..height { let byte = (width - 1 - x) * column + y / 8; let bit = 7 - (y % 8); let low = (data[byte] >> bit) & 1; let high = (data[plane + byte] >> bit) & 1; let shade = [255, 85, 170, 0][usize::from(high << 1 | low)]; image.put_pixel(x as u32, y as u32, image::Luma([shade])); } } image.save(out.join(format!("page-{page:03}.png")))?; } Ok(pages) }