diff --git a/examples/x4_preview.rs b/examples/x4_preview.rs new file mode 100644 index 0000000..f1e78c2 --- /dev/null +++ b/examples/x4_preview.rs @@ -0,0 +1,131 @@ +//! 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) +}