Add an X4 preview tool that renders pages with the real XTC converter
Builds both editions around one article (a URL through real extraction, or an HTML file), runs the XTC converter on the X4 edition and decodes every page to PNG. --convert re-renders an existing EPUB, for iterating on a stylesheet. The decoder follows the converter's encoder, which writes the level's low bit to the first plane, rather than the spec's table. The device itself reads the X4 EPUB through its firmware, which lays out differently. Check those layouts with the firmware's own host tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 -- <article URL | HTML file> <out dir>
|
||||||
|
//! cargo run --example x4_preview -- --convert <x4.epub> <out dir>
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! 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<String> = 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 <URL | HTML file> <out dir> | --convert <epub> <out dir>"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the converter on `epub` and decode its pages into `out`.
|
||||||
|
fn convert(epub: &Path, out: &Path) -> anyhow::Result<usize> {
|
||||||
|
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<usize> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user