diff --git a/README.md b/README.md index 7652e00..df17199 100644 --- a/README.md +++ b/README.md @@ -404,6 +404,10 @@ When `input_format` is omitted, Renderflow auto-detects the format from the file | `epub` | Renders a reflowable EPUB 3 | Pandoc | | `kepub` | Renders EPUB 3, then Kobo enhancements | Pandoc + Kepubify | +These document-source outputs are reflowable. The exact +[fixed-layout EPUB route](docs/user-guide/fixed-layout-epub.md) uses an explicit, +ordered PNG or JPEG collection with its own policy and publication metadata. + Not every input → output combination is supported. For example, `epub` and `latex` inputs cannot currently be converted to `docx`. Renderflow reports a clear error when an unsupported combination is specified. **Image formats** (via FFmpeg): diff --git a/ROADMAP.md b/ROADMAP.md index 8b525bc..0a91fe8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,7 @@ schema: aether.architecture-document/v1 id: renderflow-roadmap title: Renderflow Roadmap kind: architecture-document -version: 0.1.4 +version: 0.1.5 status: draft owners: - egohygiene @@ -26,6 +26,29 @@ supersedes: [] # Renderflow Roadmap +## 2026-09-28 fixed-layout EPUB implementation handoff + +[#417](https://github.com/egohygiene/renderflow/issues/417) adds the exact +`ebook.generate.epub.fixed-layout` route to the canonical ordered-collection +planner. The initial native packager accepts only homogeneous local PNG or JPEG +pages with frozen IDs, digests, order, and geometry. A declared publication +contract supplies title, issue identity, date, language, contributor, rights, +cover selection, per-page descriptions, and accessibility metadata. Explicit +LTR/RTL progression never mirrors source artwork. SVG is refused until safe +parsing and fixture evidence support it. Deterministic package members, bounded +inputs/output, retained run provenance, and unchanged source files are the #417 +review boundary. + +After #417 review and merge, +[#418](https://github.com/egohygiene/renderflow/issues/418) independently +validates fixed-layout EPUB 3.3 output and advertises exact capability truth; +[#419](https://github.com/egohygiene/renderflow/issues/419) packages the +immutable Renderflow integration candidate for +[Flow #52](https://github.com/egohygiene/flow/issues/52). Neither this route nor +the first three-input [#433](https://github.com/egohygiene/renderflow/issues/433) +gallery claims fixed-layout KEPUB, retailer acceptance, complete accessibility, +or publication approval. Later gallery expansion remains tracked by #412. + ## 2026-09-28 artifact gallery review handoff [#433](https://github.com/egohygiene/renderflow/issues/433) is the first bounded diff --git a/crates/renderflow-cli/Cargo.toml b/crates/renderflow-cli/Cargo.toml index 4b5d89b..7776957 100644 --- a/crates/renderflow-cli/Cargo.toml +++ b/crates/renderflow-cli/Cargo.toml @@ -36,6 +36,10 @@ path = "../../tests/ordered_collection_cli.rs" name = "artifact_gallery_cli" path = "../../tests/artifact_gallery_cli.rs" +[[test]] +name = "fixed_layout_epub_cli" +path = "../../tests/fixed_layout_epub_cli.rs" + [[test]] name = "graph_integration_test" path = "../../tests/graph_integration_test.rs" diff --git a/crates/renderflow-core/data/tool-registry.yaml b/crates/renderflow-core/data/tool-registry.yaml index 766e232..80af68c 100644 --- a/crates/renderflow-core/data/tool-registry.yaml +++ b/crates/renderflow-core/data/tool-registry.yaml @@ -1,6 +1,22 @@ schema: renderflow.tool-registry/v1 tools: + - id: tool.renderflow-epub + name: Renderflow fixed-layout EPUB packager + discovery: + kind: virtual + operating_systems: [linux, macos, windows] + capabilities: + - ebook.generate.epub.fixed-layout + input_media_types: [image/png, image/jpeg] + output_media_types: [application/epub+zip] + determinism: deterministic + locality: local + fidelity: lossless + support_tier: experimental + license_notes: "In-process Renderflow implementation; see the Renderflow repository license." + distribution_notes: "Exact bounded ordered-page PNG/JPEG route only; SVG, reflowable EPUB, fixed-layout KEPUB, retailer acceptance, and independent EPUB 3.3 conformance are separate contracts." + - id: tool.lulu-rules name: Pinned Lulu publication rule pack discovery: diff --git a/crates/renderflow-core/src/fixed_layout_epub.rs b/crates/renderflow-core/src/fixed_layout_epub.rs new file mode 100644 index 0000000..df17f7b --- /dev/null +++ b/crates/renderflow-core/src/fixed_layout_epub.rs @@ -0,0 +1,785 @@ +//! Deterministic EPUB 3.3 fixed-layout packaging for a bounded ordered raster +//! collection. The input images are copied byte-for-byte into a local EPUB; +//! generated XHTML supplies one page and a declared viewport per image. + +use std::collections::HashSet; +use std::fs::File; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +use anyhow::Result; +use serde_json::json; +use sha2::{Digest, Sha256}; +use zip::{write::SimpleFileOptions, CompressionMethod, DateTime, ZipArchive, ZipWriter}; + +use crate::artifact::{ + Artifact, ArtifactCollection, ArtifactCollectionTransform, ArtifactDescriptor, + ArtifactStorageClass, ArtifactStore, +}; +use crate::evidence::FidelityDeclaration; +use crate::graph::Format; +use crate::print_pdf_image::inspect_print_image; +use crate::publication::PublicationContract; +use crate::spec::FixedLayoutEpubPolicy; + +pub const FIXED_EPUB_CAPABILITY: &str = "ebook.generate.epub.fixed-layout"; +pub const FIXED_EPUB_PROVIDER: &str = "tool.renderflow-epub"; + +const MIMETYPE: &[u8] = b"application/epub+zip"; +const CONTAINER: &str = "\n\n"; +const CSS: &str = "@charset \"UTF-8\";\nhtml,body{width:100%;height:100%;margin:0;padding:0;}\nbody{overflow:hidden;}\nimg.page{display:block;width:100%;height:100%;object-fit:contain;}\n"; +const BUFFER_SIZE: usize = 64 * 1024; + +/// A machine-readable refusal that can be propagated into DAG step evidence. +#[derive(Debug, thiserror::Error)] +#[error("{code}: {message}")] +pub struct FixedEpubError { + pub code: &'static str, + pub message: String, + pub cancelled: bool, +} + +fn refusal(code: &'static str, message: impl Into) -> anyhow::Error { + FixedEpubError { + code, + message: message.into(), + cancelled: code == "fixed_epub.cancelled", + } + .into() +} + +fn zip_write_error(error: impl std::fmt::Display) -> anyhow::Error { + let message = error.to_string(); + refusal( + if message.contains("fixed_epub.output.bounds") { + "fixed_epub.output.bounds" + } else { + "fixed_epub.output.write" + }, + message, + ) +} + +/// Planned identity and accessible label for one immutable raster page. +#[derive(Debug, Clone)] +pub struct FixedEpubPage { + pub source_id: String, + pub format: Format, + pub width_px: u32, + pub height_px: u32, + pub alt_text: String, + /// Lowercase hexadecimal SHA-256 of the exact PNG/JPEG bytes. + pub digest: String, +} + +pub struct FixedLayoutEpubTransform { + policy: FixedLayoutEpubPolicy, + publication: PublicationContract, + pages: Vec, + input_digests: Vec, + cache_identity: String, + cancellation: Option>, +} + +impl FixedLayoutEpubTransform { + pub fn new( + policy: FixedLayoutEpubPolicy, + publication: PublicationContract, + pages: Vec, + input_digests: Vec, + cancellation: Option>, + ) -> Result { + policy + .validate() + .map_err(|error| refusal("fixed_epub.policy", error.to_string()))?; + if pages.is_empty() || pages.len() > policy.max_pages || pages.len() != input_digests.len() + { + return Err(refusal( + "fixed_epub.bounds", + "ordered pages and digests must be nonempty, have matching counts, and fit max_pages", + )); + } + if pages[0].source_id != policy.cover_member_id { + return Err(refusal( + "fixed_epub.cover", + "cover_member_id must name the first ordered page", + )); + } + validate_publication(&publication)?; + let mut source_ids = HashSet::new(); + for (index, (page, digest)) in pages.iter().zip(&input_digests).enumerate() { + if !matches!(page.format, Format::Png | Format::Jpeg) { + return Err(refusal( + "fixed_epub.image.unsupported_format", + format!("page {} requires a PNG or JPEG source; SVG and other formats are not accepted", index + 1), + )); + } + if page.source_id.trim().is_empty() || !source_ids.insert(page.source_id.as_str()) { + return Err(refusal( + "fixed_epub.input.identity", + "page source IDs must be nonempty and unique", + )); + } + if page.width_px == 0 + || page.height_px == 0 + || page.width_px > 100_000 + || page.height_px > 100_000 + { + return Err(refusal( + "fixed_epub.image.geometry", + "page dimensions must be positive and within the image preflight limit", + )); + } + ensure_xml_text(&page.alt_text, "fixed_epub.accessibility.alt_text")?; + if page.alt_text.trim().is_empty() { + return Err(refusal( + "fixed_epub.accessibility.alt_text", + format!("page {} has no reviewed alt text", index + 1), + )); + } + if !valid_digest(&page.digest) || strip_sha256_prefix(digest) != page.digest { + return Err(refusal( + "fixed_epub.input.identity", + format!( + "page {} digest does not match its frozen input identity", + index + 1 + ), + )); + } + } + let cache_identity = serde_json::to_string(&json!({ + "policy": policy, + "publication": publication, + "pages": pages.iter().map(|page| json!({ + "source_id": page.source_id, + "format": page.format.to_string(), + "width_px": page.width_px, + "height_px": page.height_px, + "alt_text": page.alt_text, + "digest": page.digest, + })).collect::>(), + "input_digests": input_digests, + "package_version": 1, + }))?; + Ok(Self { + policy, + publication, + pages, + input_digests, + cache_identity, + cancellation, + }) + } + + fn check_cancelled(&self) -> Result<()> { + if self + .cancellation + .as_ref() + .is_some_and(|flag| flag.load(Ordering::SeqCst)) + { + Err(refusal( + "fixed_epub.cancelled", + "EPUB packaging was cancelled before artifact import", + )) + } else { + Ok(()) + } + } + + fn write_epub( + &self, + inputs: &ArtifactCollection, + store: &ArtifactStore, + output: &mut File, + ) -> Result<()> { + let bounded = BoundedWriter::new(output, self.policy.max_output_bytes); + let mut zip = ZipWriter::new(bounded); + // Stored entries, a fixed DOS timestamp, fixed mode, fixed order, and + // no arbitrary source file names make clean builds byte-identical. + let options = SimpleFileOptions::default() + .compression_method(CompressionMethod::Stored) + .last_modified_time(DateTime::default()) + .unix_permissions(0o644); + + write_entry(&mut zip, "mimetype", MIMETYPE, options)?; + write_entry( + &mut zip, + "META-INF/container.xml", + CONTAINER.as_bytes(), + options, + )?; + write_entry(&mut zip, "EPUB/book.opf", self.opf()?.as_bytes(), options)?; + write_entry(&mut zip, "EPUB/nav.xhtml", self.nav().as_bytes(), options)?; + write_entry(&mut zip, "EPUB/styles.css", CSS.as_bytes(), options)?; + + for (index, (page, artifact)) in self.pages.iter().zip(inputs.iter()).enumerate() { + self.check_cancelled()?; + if artifact.format().as_str() != page.format.to_string() + || artifact.digest().value() != page.digest + { + return Err(refusal( + "fixed_epub.input.changed", + format!("page {} does not match its frozen format/digest", index + 1), + )); + } + if strip_sha256_prefix(&self.input_digests[index]) != artifact.digest().value() { + return Err(refusal( + "fixed_epub.input.changed", + format!("page {} input digest changed", index + 1), + )); + } + let input_path = store + .payload_path(artifact) + .map_err(|error| refusal("fixed_epub.input.unavailable", error.to_string()))?; + let metadata = input_path + .symlink_metadata() + .map_err(|error| refusal("fixed_epub.input.unavailable", error.to_string()))?; + if !metadata.file_type().is_file() || metadata.len() != artifact.size_bytes() { + return Err(refusal( + "fixed_epub.input.changed", + format!( + "page {} source is not a regular file of the planned size", + index + 1 + ), + )); + } + let image = inspect_print_image(&input_path, page.format).map_err(|error| { + refusal( + "fixed_epub.image.unreadable", + format!("page {}: {error:#}", index + 1), + ) + })?; + if image.width_px != page.width_px || image.height_px != page.height_px { + return Err(refusal( + "fixed_epub.image.geometry", + format!("page {} dimensions changed from the plan", index + 1), + )); + } + + let number = index + 1; + write_entry( + &mut zip, + &format!("EPUB/pages/page-{number:04}.xhtml"), + self.page_xhtml(number, page).as_bytes(), + options, + )?; + zip.start_file( + format!("EPUB/images/page-{number:04}.{}", extension(page.format)), + options, + ) + .map_err(zip_write_error)?; + let mut file = File::open(&input_path) + .map_err(|error| refusal("fixed_epub.input.unavailable", error.to_string()))?; + let mut digest = Sha256::new(); + let mut copied = 0_u64; + let mut buffer = [0u8; BUFFER_SIZE]; + loop { + self.check_cancelled()?; + let read = file + .read(&mut buffer) + .map_err(|error| refusal("fixed_epub.input.unavailable", error.to_string()))?; + if read == 0 { + break; + } + copied = copied + .checked_add(read as u64) + .ok_or_else(|| refusal("fixed_epub.bounds", "source size overflow"))?; + if copied > artifact.size_bytes() { + return Err(refusal( + "fixed_epub.input.changed", + format!("page {} grew during packaging", number), + )); + } + digest.update(&buffer[..read]); + zip.write_all(&buffer[..read]).map_err(zip_write_error)?; + } + if copied != artifact.size_bytes() || format!("{:x}", digest.finalize()) != page.digest + { + return Err(refusal( + "fixed_epub.input.changed", + format!("page {} bytes changed during packaging", number), + )); + } + } + self.check_cancelled()?; + zip.finish().map_err(zip_write_error)?; + Ok(()) + } + + fn opf(&self) -> Result { + let title = xml_escape(&self.publication.title); + let id = xml_escape(&self.publication.issue_id); + let lang = xml_escape(&self.publication.language); + let rights = xml_escape( + self.publication + .rights + .rights_holder + .as_deref() + .unwrap_or_default(), + ); + let license = xml_escape( + self.publication + .rights + .license + .as_deref() + .unwrap_or_default(), + ); + let summary = xml_escape( + self.publication + .accessibility + .summary + .as_deref() + .unwrap_or_default(), + ); + let modified = modified_date(&self.publication.publication_date)?; + let mut xml = format!("\n\n\n{id}\n{title}\n{lang}\n{}\n{rights}; {license}\n{modified}\npre-paginated\nnone\ntableOfContents\npageNavigation\n{summary}\n", xml_escape(&self.publication.publication_date)); + for (index, contributor) in self.publication.contributors.iter().enumerate() { + let element = if contributor.role.eq_ignore_ascii_case("author") + || contributor.role.eq_ignore_ascii_case("creator") + { + "dc:creator" + } else { + "dc:contributor" + }; + let number = index + 1; + xml.push_str(&format!( + "<{element} id=\"person-{number:04}\">{}\n{}\n", + xml_escape(&contributor.name), + xml_escape(&contributor.role) + )); + } + for mode in &self.publication.accessibility.access_modes { + xml.push_str(&format!( + "{}\n", + xml_escape(mode) + )); + } + for hazard in &self.publication.accessibility.hazards { + xml.push_str(&format!( + "{}\n", + xml_escape(hazard) + )); + } + xml.push_str("\n\n\n\n"); + for (index, page) in self.pages.iter().enumerate() { + let number = index + 1; + xml.push_str(&format!("\n")); + let cover = if index == 0 { + " properties=\"cover-image\"" + } else { + "" + }; + xml.push_str(&format!("\n", extension(page.format), media_type(page.format))); + } + xml.push_str(&format!( + "\n\n", + self.policy.page_progression_direction + )); + for number in 1..=self.pages.len() { + xml.push_str(&format!("\n")); + } + xml.push_str("\n\n"); + Ok(xml) + } + + fn nav(&self) -> String { + let mut xml = format!("\n{}\n\n\n\n"); + xml + } + + fn page_xhtml(&self, number: usize, page: &FixedEpubPage) -> String { + format!("\nPage {number}\"{}\"/\n", xml_escape(&self.publication.language), page.width_px, page.height_px, extension(page.format), xml_escape(&page.alt_text)) + } +} + +impl ArtifactCollectionTransform for FixedLayoutEpubTransform { + fn name(&self) -> &str { + FIXED_EPUB_CAPABILITY + } + + fn version(&self) -> &str { + env!("CARGO_PKG_VERSION") + } + + fn cache_identity(&self) -> String { + self.cache_identity.clone() + } + + fn fidelity(&self) -> Option { + Some(FidelityDeclaration::Lossless) + } + + fn apply( + &self, + inputs: &ArtifactCollection, + output_format: Format, + store: &ArtifactStore, + ) -> Result { + if output_format != Format::Epub || inputs.len() != self.pages.len() { + return Err(refusal( + "fixed_epub.input.count", + "expected one EPUB target and the exact ordered page collection", + )); + } + self.check_cancelled()?; + let mut total = 0u64; + for input in inputs.iter() { + total = total + .checked_add(input.size_bytes()) + .ok_or_else(|| refusal("fixed_epub.bounds", "source sizes overflowed u64"))?; + if total > self.policy.max_input_bytes { + return Err(refusal( + "fixed_epub.bounds", + "source bytes exceed max_input_bytes", + )); + } + } + let mut temporary = tempfile::NamedTempFile::new_in(store.temporary_directory()) + .map_err(|error| refusal("fixed_epub.output.temporary", error.to_string()))?; + self.write_epub(inputs, store, temporary.as_file_mut())?; + temporary + .as_file() + .sync_all() + .map_err(|error| refusal("fixed_epub.output.sync", error.to_string()))?; + let size = temporary + .as_file() + .metadata() + .map_err(|error| refusal("fixed_epub.output.write", error.to_string()))? + .len(); + if size == 0 || size > self.policy.max_output_bytes { + return Err(refusal( + "fixed_epub.output.bounds", + "packaged EPUB exceeds max_output_bytes", + )); + } + inspect_container(&temporary, self.pages.len())?; + self.check_cancelled()?; + let ordered_pages = self + .pages + .iter() + .enumerate() + .map(|(index, page)| { + json!({ + "index": index, "source_id": page.source_id, "sha256": page.digest, + "width_px": page.width_px, "height_px": page.height_px, + "format": page.format.to_string(), + }) + }) + .collect::>(); + store + .import_path( + temporary.path(), + ArtifactDescriptor::for_format(Format::Epub, ArtifactStorageClass::Intermediate) + .with_sources(inputs.iter().map(|artifact| artifact.id().clone())) + .with_metadata("renderflow.transform", FIXED_EPUB_CAPABILITY) + .with_metadata("renderflow.fixed_epub.provider", FIXED_EPUB_PROVIDER) + .with_metadata( + "renderflow.fixed_epub.policy", + serde_json::to_value(&self.policy)?, + ) + .with_metadata("renderflow.fixed_epub.layout", "pre-paginated") + .with_metadata("renderflow.fixed_epub.package_version", "3.0") + .with_metadata( + "renderflow.fixed_epub.publication_id", + self.publication.issue_id.clone(), + ) + .with_metadata("renderflow.fixed_epub.ordered_pages", json!(ordered_pages)), + ) + .map_err(|error| refusal("fixed_epub.output.import", error.to_string())) + } +} + +fn write_entry( + zip: &mut ZipWriter, + name: &str, + bytes: &[u8], + options: SimpleFileOptions, +) -> Result<()> { + zip.start_file(name, options).map_err(zip_write_error)?; + zip.write_all(bytes).map_err(zip_write_error)?; + Ok(()) +} + +fn inspect_container(temporary: &tempfile::NamedTempFile, expected_pages: usize) -> Result<()> { + let file = temporary + .reopen() + .map_err(|error| refusal("fixed_epub.output.invalid", error.to_string()))?; + let mut zip = ZipArchive::new(file) + .map_err(|error| refusal("fixed_epub.output.invalid", error.to_string()))?; + if zip.len() != 5 + 2 * expected_pages { + return Err(refusal( + "fixed_epub.output.invalid", + "ZIP entry count differs from the package contract", + )); + } + let mut mimetype = zip + .by_index(0) + .map_err(|error| refusal("fixed_epub.output.invalid", error.to_string()))?; + if mimetype.name() != "mimetype" + || mimetype.compression() != CompressionMethod::Stored + || mimetype.extra_data().is_some_and(|extra| !extra.is_empty()) + || mimetype.data_start() != 38 + { + return Err(refusal( + "fixed_epub.output.invalid", + "mimetype is not the first, stored, extra-free ZIP entry", + )); + } + let mut bytes = Vec::new(); + mimetype + .read_to_end(&mut bytes) + .map_err(|error| refusal("fixed_epub.output.invalid", error.to_string()))?; + if bytes != MIMETYPE { + return Err(refusal( + "fixed_epub.output.invalid", + "mimetype entry does not contain the exact EPUB media type", + )); + } + Ok(()) +} + +/// Validate the exact metadata envelope required by this fixed-layout route. +/// Planning calls this before executing so refusals retain a planning cause. +pub(crate) fn validate_publication(publication: &PublicationContract) -> Result<()> { + let required = [ + ("issue_id", publication.issue_id.as_str()), + ("title", publication.title.as_str()), + ("language", publication.language.as_str()), + ("publication_date", publication.publication_date.as_str()), + ( + "rights_holder", + publication + .rights + .rights_holder + .as_deref() + .unwrap_or_default(), + ), + ( + "license", + publication.rights.license.as_deref().unwrap_or_default(), + ), + ( + "accessibility_summary", + publication + .accessibility + .summary + .as_deref() + .unwrap_or_default(), + ), + ]; + for (name, value) in required { + ensure_xml_text(value, "fixed_epub.metadata.invalid")?; + if value.trim().is_empty() { + return Err(refusal( + "fixed_epub.metadata.missing", + format!("publication {name} is required"), + )); + } + } + if publication.contributors.is_empty() + || publication.accessibility.access_modes.is_empty() + || publication.accessibility.hazards.is_empty() + { + return Err(refusal( + "fixed_epub.metadata.missing", + "contributors, access modes, and accessibility hazards are required", + )); + } + for contributor in &publication.contributors { + ensure_xml_text(&contributor.name, "fixed_epub.metadata.invalid")?; + ensure_xml_text(&contributor.role, "fixed_epub.metadata.invalid")?; + if contributor.name.trim().is_empty() || contributor.role.trim().is_empty() { + return Err(refusal( + "fixed_epub.metadata.missing", + "contributor name and role are required", + )); + } + } + for value in publication + .accessibility + .access_modes + .iter() + .chain(&publication.accessibility.hazards) + { + ensure_xml_text(value, "fixed_epub.metadata.invalid")?; + if value.trim().is_empty() { + return Err(refusal( + "fixed_epub.metadata.missing", + "access modes and hazards must be nonempty", + )); + } + } + if !publication + .accessibility + .access_modes + .iter() + .any(|mode| mode == "visual") + || publication + .accessibility + .access_modes + .iter() + .any(|mode| mode != "visual") + { + return Err(refusal("fixed_epub.accessibility.claim", "raster-only pages support only the declared visual access mode; other modes need independent reviewed content")); + } + modified_date(&publication.publication_date)?; + Ok(()) +} + +fn modified_date(value: &str) -> Result { + let bytes = value.as_bytes(); + let valid_date = bytes.len() >= 10 + && bytes[0..4].iter().all(u8::is_ascii_digit) + && bytes[4] == b'-' + && bytes[5..7].iter().all(u8::is_ascii_digit) + && bytes[7] == b'-' + && bytes[8..10].iter().all(u8::is_ascii_digit) + && valid_calendar_date(bytes); + if !valid_date || (bytes.len() != 10 && !valid_utc_timestamp(bytes)) { + return Err(refusal( + "fixed_epub.metadata.date", + "publication_date must be YYYY-MM-DD or YYYY-MM-DDThh:mm:ssZ", + )); + } + Ok(if bytes.len() == 10 { + format!("{value}T00:00:00Z") + } else { + value.to_string() + }) +} + +fn valid_calendar_date(bytes: &[u8]) -> bool { + let year = std::str::from_utf8(&bytes[0..4]) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let month = parse_u8(&bytes[5..7]); + let day = parse_u8(&bytes[8..10]); + let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)); + let max_day = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return false, + }; + year != 0 && (1..=max_day).contains(&day) +} + +fn valid_utc_timestamp(bytes: &[u8]) -> bool { + bytes.len() == 20 + && bytes[10] == b'T' + && bytes[11..13].iter().all(u8::is_ascii_digit) + && bytes[13] == b':' + && bytes[14..16].iter().all(u8::is_ascii_digit) + && bytes[16] == b':' + && bytes[17..19].iter().all(u8::is_ascii_digit) + && bytes[19] == b'Z' + && (0..=23).contains(&parse_u8(&bytes[11..13])) + && (0..=59).contains(&parse_u8(&bytes[14..16])) + && (0..=59).contains(&parse_u8(&bytes[17..19])) +} + +fn parse_u8(bytes: &[u8]) -> u8 { + std::str::from_utf8(bytes) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(255) +} + +fn ensure_xml_text(text: &str, code: &'static str) -> Result<()> { + if text.chars().any(|ch| !matches!(ch as u32, 0x9 | 0xA | 0xD | 0x20..=0xD7FF | 0xE000..=0xFFFD | 0x10000..=0x10FFFF)) { + return Err(refusal(code, "publication text contains a character XML 1.0 cannot represent")); + } + Ok(()) +} + +fn xml_escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn valid_digest(digest: &str) -> bool { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn strip_sha256_prefix(digest: &str) -> &str { + digest.strip_prefix("sha256:").unwrap_or(digest) +} + +fn extension(format: Format) -> &'static str { + match format { + Format::Png => "png", + Format::Jpeg => "jpg", + _ => unreachable!("validated page format"), + } +} + +fn media_type(format: Format) -> &'static str { + match format { + Format::Png => "image/png", + Format::Jpeg => "image/jpeg", + _ => unreachable!("validated page format"), + } +} + +/// Enforce the bound *during* ZIP writes, including headers and the central +/// directory. ZipWriter seeks backwards when completing local file headers. +struct BoundedWriter { + inner: W, + max_bytes: u64, +} + +impl BoundedWriter { + fn new(inner: W, max_bytes: u64) -> Self { + Self { inner, max_bytes } + } +} + +impl Write for BoundedWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let position = self.inner.stream_position()?; + if position + .checked_add(bytes.len() as u64) + .is_none_or(|end| end > self.max_bytes) + { + return Err(io::Error::new( + io::ErrorKind::OutOfMemory, + "fixed_epub.output.bounds: ZIP exceeds max_output_bytes", + )); + } + self.inner.write(bytes) + } + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +impl Seek for BoundedWriter { + fn seek(&mut self, position: SeekFrom) -> io::Result { + // Backward header rewrites are expected. The next write is independently + // bounded, so seeking alone can never enlarge the resulting file. + self.inner.seek(position) + } +} diff --git a/crates/renderflow-core/src/graph/dag_executor.rs b/crates/renderflow-core/src/graph/dag_executor.rs index 6a76bb8..8d8f767 100644 --- a/crates/renderflow-core/src/graph/dag_executor.rs +++ b/crates/renderflow-core/src/graph/dag_executor.rs @@ -162,7 +162,11 @@ fn failed_step( let inspection_failure = error.chain().find_map(|cause| { cause.downcast_ref::() }); - let cancelled = provider_failure.is_some_and(|failure| failure.cancelled); + let fixed_epub_failure = error + .chain() + .find_map(|cause| cause.downcast_ref::()); + let cancelled = provider_failure.is_some_and(|failure| failure.cancelled) + || fixed_epub_failure.is_some_and(|failure| failure.cancelled); StepEvidence { step_id: step_id.clone(), transform: edge_identity(edge), @@ -206,6 +210,7 @@ fn failed_step( code: provider_failure .map(|failure| failure.code) .or_else(|| inspection_failure.map(|failure| failure.code)) + .or_else(|| fixed_epub_failure.map(|failure| failure.code)) .unwrap_or("execution.transform_failed") .to_string(), message, diff --git a/crates/renderflow-core/src/lib.rs b/crates/renderflow-core/src/lib.rs index 0edb912..e6b1420 100644 --- a/crates/renderflow-core/src/lib.rs +++ b/crates/renderflow-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod dna; pub mod ebook; pub mod error; pub mod evidence; +pub mod fixed_layout_epub; pub mod font; pub mod graph; pub mod hygiene; diff --git a/crates/renderflow-core/src/planning.rs b/crates/renderflow-core/src/planning.rs index a366920..7812ad3 100644 --- a/crates/renderflow-core/src/planning.rs +++ b/crates/renderflow-core/src/planning.rs @@ -25,6 +25,10 @@ use crate::evidence::{ ProducerEvidence, RunManifest, RunState, StepEvidence, StepState, ValidationDiagnostic, ValidationState, ValidatorEvidence, ARTIFACT_MANIFEST_SCHEMA_V1, RUN_MANIFEST_SCHEMA_V1, }; +use crate::fixed_layout_epub::{ + validate_publication as validate_fixed_epub_publication, FixedEpubPage, + FixedLayoutEpubTransform, FIXED_EPUB_CAPABILITY, FIXED_EPUB_PROVIDER, +}; use crate::graph::capability::{FormatCapabilityRegistry, FormatFamily}; use crate::graph::{ ArtifactForest, DagExecutionReport, DagExecutor, DiagnosticLevel, ExecutionPlan, ForestBranch, @@ -39,10 +43,10 @@ use crate::print_pdf_image::{inspect_print_image, PrintImageColorSpace}; use crate::print_pdf_inspect::ExpectedPrintPage; use crate::publication::write_release_metadata; use crate::spec::{ - load_spec, AiPolicy, CollisionPolicy, DerivativeProfile, HygienePolicy, IntermediatePolicy, - PrintPdfInteriorPolicy, RejectedLossClass, SelectorSet, SourceKind, SourceSpec, - SourceSpecVersion, SpecV2, TargetRequirement, TargetSelection, TargetSpec, - ValidationFailureMode, + load_spec, AiPolicy, CollisionPolicy, DerivativeProfile, FixedLayoutEpubPolicy, HygienePolicy, + IntermediatePolicy, NetworkPolicy, PrintPdfInteriorPolicy, RejectedLossClass, SelectorSet, + SourceKind, SourceSpec, SourceSpecVersion, SpecV2, TargetRequirement, TargetSelection, + TargetSpec, ValidationFailureMode, }; use crate::super_resolution::{select_upscayl_variants, UpscaylModelCatalog}; use crate::toolchain::{ @@ -171,6 +175,7 @@ pub struct ResolvedExecution { source_format: Format, source_members: Vec, print_pages: Option>, + fixed_epub_pages: Option>, targets: Vec, dag: MultiTargetDag, executor: DagExecutor, @@ -416,6 +421,8 @@ pub fn resolve(request: PlanningRequest) -> Result { let source_path = source_members[0].path.clone(); let source_intake = &source_members[0].intake; let print_pages = resolve_print_pages(&spec, collection, source_format, &source_members)?; + let fixed_epub_pages = + resolve_fixed_epub_pages(&spec, collection, source_format, &source_members)?; let (mut graph, mut executor, mut tool_registry) = if let Some(transforms_path) = &spec.transforms { @@ -442,6 +449,17 @@ pub fn resolve(request: PlanningRequest) -> Result { if let Some(policy) = &spec.execution.print_pdf_interior { register_print_pdf_edge(&mut graph, &mut tool_registry, source_format, policy)?; } + if let (Some(policy), Some(publication)) = + (&spec.execution.fixed_layout_epub, &spec.publication) + { + register_fixed_epub_edge( + &mut graph, + &tool_registry, + source_format, + policy, + publication, + )?; + } let policy_graph = apply_execution_policy(&graph, &tool_registry, &spec); let policy_graph = if collection { policy_graph @@ -462,6 +480,14 @@ pub fn resolve(request: PlanningRequest) -> Result { { anyhow::bail!("print_pdf.target: exactly one required PDF target with role 'interior' must be selected"); } + if fixed_epub_pages.is_some() + && (requested_targets.len() != 1 + || requested_targets[0].format != Format::Epub + || requested_targets[0].role.as_deref() != Some("ebook") + || requested_targets[0].requirement != TargetRequirement::Required) + { + anyhow::bail!("fixed_epub.target: exactly one required EPUB target with role 'ebook' must be selected"); + } let provider_inventory = tool_registry.assess_ids_current(policy_graph.provider_ids()); let available_graph = @@ -523,6 +549,12 @@ pub fn resolve(request: PlanningRequest) -> Result { "print_pdf.route: selected plan must contain only the exact print-interior capability" ); } + if fixed_epub_pages.is_some() + && (dag.all_edges().len() != 1 + || dag.all_edges()[0].capability_id.as_deref() != Some(FIXED_EPUB_CAPABILITY)) + { + anyhow::bail!("fixed_epub.route: selected plan must contain only the exact fixed-layout EPUB capability"); + } register_builtin_strategy_executors( &mut executor, @@ -636,6 +668,7 @@ pub fn resolve(request: PlanningRequest) -> Result { source_format, source_members, print_pages, + fixed_epub_pages, targets, dag, executor, @@ -856,6 +889,34 @@ pub fn execute(mut resolved: ResolvedExecution, dry_run: bool) -> Result Result>> { + let Some(policy) = &spec.execution.fixed_layout_epub else { + return Ok(None); + }; + policy.validate()?; + if spec.execution.print_pdf_interior.is_some() { + anyhow::bail!("fixed_epub.policy: print PDF and fixed EPUB policies cannot be combined in one exact route"); + } + if !collection || !matches!(source_format, Format::Png | Format::Jpeg) { + anyhow::bail!("fixed_epub.input.format: only a homogeneous ordered PNG or JPEG collection is proven; SVG requires separately reviewed safe XML handling"); + } + if spec.transforms.is_some() + || spec.execution.hygiene_policy.is_some() + || spec.execution.network != NetworkPolicy::Deny + || spec.execution.ai != AiPolicy::Deny + { + anyhow::bail!("fixed_epub.policy: custom transforms, post-render hygiene, network, and AI execution are unsupported on this route"); + } + if members.is_empty() || members.len() > policy.max_pages { + anyhow::bail!("fixed_epub.bounds: collection has no pages or exceeds max_pages"); + } + if policy.cover_member_id != members[0].spec.id { + anyhow::bail!("fixed_epub.cover: cover_member_id must name the first ordered page"); + } + let input_bytes = members.iter().try_fold(0_u64, |sum, member| { + sum.checked_add(member.intake.source.size_bytes()) + .context("fixed_epub.bounds: input byte count overflow") + })?; + if input_bytes > policy.max_input_bytes { + anyhow::bail!("fixed_epub.bounds: collection exceeds max_input_bytes"); + } + let publication = spec + .publication + .as_ref() + .context("fixed_epub.metadata.missing: publication contract is required")?; + if serde_json::to_vec(publication)?.len() > 1024 * 1024 { + anyhow::bail!("fixed_epub.bounds: publication metadata exceeds 1 MiB"); + } + validate_fixed_epub_publication(publication)?; + if let Some(role) = publication.output_roles.get("ebook") { + if role.format != "epub" + || role + .geometry + .as_ref() + .is_some_and(|geometry| geometry != &publication.geometry) + { + anyhow::bail!("fixed_epub.publication.constraints: ebook role format or geometry differs from the EPUB route"); + } + } + let mut pages = Vec::with_capacity(members.len()); + for member in members { + let geometry = member.spec.geometry.as_ref().with_context(|| { + format!( + "fixed_epub.geometry.missing: '{}' requires explicit page geometry", + member.spec.id + ) + })?; + if geometry != &publication.geometry + || geometry.unit != "mm" + || !geometry.width.is_finite() + || !geometry.height.is_finite() + || geometry.width <= 0.0 + || geometry.height <= 0.0 + || geometry.width > 10_000.0 + || geometry.height > 10_000.0 + || geometry.bleed.is_some_and(|bleed| bleed != 0.0) + || geometry.margin.is_some() + || geometry.safe_area.is_some() + { + anyhow::bail!("fixed_epub.geometry.unsupported: '{}' requires matching positive millimeter publication geometry without bleed, margin, or safe area", member.spec.id); + } + let image = inspect_print_image(&member.path, source_format).with_context(|| { + format!( + "fixed_epub.image.unreadable: '{}' did not pass bounded image preflight", + member.spec.id + ) + })?; + let pixel_ratio = f64::from(image.width_px) / f64::from(image.height_px); + let page_ratio = geometry.width / geometry.height; + if (pixel_ratio - page_ratio).abs() > 0.01 { + anyhow::bail!( + "fixed_epub.geometry.aspect: '{}' page and image aspect ratios differ", + member.spec.id + ); + } + let source_path = member + .spec + .path + .as_deref() + .context("fixed_epub.metadata.alt_text")?; + let mut matching = publication + .artwork + .iter() + .filter(|artwork| artwork.role == "page" && artwork.path == source_path); + let artwork = matching.next().with_context(|| { + format!("fixed_epub.metadata.alt_text: '{}' requires artwork with role=page and matching source path", member.spec.id) + })?; + let alt_text = artwork.alt_text.as_deref().unwrap_or_default().trim(); + if matching.next().is_some() || alt_text.is_empty() || alt_text.len() > 4096 { + anyhow::bail!("fixed_epub.metadata.alt_text: '{}' needs exactly one bounded nonempty page description", member.spec.id); + } + pages.push(FixedEpubPage { + source_id: member.spec.id.clone(), + format: source_format, + width_px: image.width_px, + height_px: image.height_px, + alt_text: alt_text.to_string(), + digest: member.intake.source.digest().value().to_string(), + }); + } + Ok(Some(pages)) +} + +fn register_fixed_epub_edge( + graph: &mut TransformGraph, + tools: &ToolRegistry, + source_format: Format, + policy: &FixedLayoutEpubPolicy, + publication: &crate::publication::PublicationContract, +) -> Result<()> { + tools + .get(FIXED_EPUB_PROVIDER) + .context("fixed_epub.provider: native provider descriptor missing")?; + graph.add_transform( + TransformEdge::with_input_kind( + source_format, + Format::Epub, + 0.1, + 1.0, + InputKind::Collection, + ) + .with_provider(FIXED_EPUB_PROVIDER, FIXED_EPUB_CAPABILITY) + .with_variant(env!("CARGO_PKG_VERSION")) + .with_evidence("transform_id", FIXED_EPUB_CAPABILITY) + .with_evidence("fixed_epub_policy_sha256", sha256_serialized(policy)?.value) + .with_evidence( + "fixed_epub_publication_sha256", + sha256_serialized(publication)?.value, + ), + ); + Ok(()) +} + fn register_print_pdf_edge( graph: &mut TransformGraph, tools: &mut ToolRegistry, diff --git a/crates/renderflow-core/src/spec.rs b/crates/renderflow-core/src/spec.rs index f26d98a..c6eb8bf 100644 --- a/crates/renderflow-core/src/spec.rs +++ b/crates/renderflow-core/src/spec.rs @@ -508,6 +508,9 @@ pub struct ExecutionPolicy { /// Exact, bounded print-interior route for an ordered PNG/JPEG collection. #[serde(default, skip_serializing_if = "Option::is_none")] pub print_pdf_interior: Option, + /// Exact native fixed-layout EPUB route for an ordered PNG/JPEG collection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fixed_layout_epub: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -551,6 +554,38 @@ impl PrintPdfInteriorPolicy { } } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FixedLayoutEpubPolicy { + /// Reading order only; RTL never mirrors or alters artwork bytes. + pub page_progression_direction: String, + /// The proven route uses single-page spreads only. + pub spread: String, + /// Existing first collection member, reused as the EPUB cover image. + pub cover_member_id: String, + pub max_pages: usize, + pub max_input_bytes: u64, + pub max_output_bytes: u64, +} + +impl FixedLayoutEpubPolicy { + pub fn validate(&self) -> Result<()> { + if !matches!(self.page_progression_direction.as_str(), "ltr" | "rtl") + || self.spread != "none" + || !is_stable_id(&self.cover_member_id) + { + anyhow::bail!("fixed_epub.policy: require explicit ltr/rtl page progression, spread=none, and a stable cover member ID"); + } + if !(1..=1000).contains(&self.max_pages) + || !(1..=536_870_912).contains(&self.max_input_bytes) + || !(1..=536_870_912).contains(&self.max_output_bytes) + { + anyhow::bail!("fixed_epub.bounds: max_pages (1..1000) and input/output bytes (1..512 MiB) are required"); + } + Ok(()) + } +} + impl Default for ExecutionPolicy { fn default() -> Self { Self { @@ -571,6 +606,7 @@ impl Default for ExecutionPolicy { redaction_policy: None, hygiene_policy: None, print_pdf_interior: None, + fixed_layout_epub: None, } } } @@ -702,6 +738,15 @@ impl SpecV2 { )); } } + if let Some(epub) = &self.execution.fixed_layout_epub { + if let Err(error) = epub.validate() { + diagnostics.push(SpecDiagnostic::new( + "$.execution.fixed_layout_epub", + "fixed_epub.policy.invalid", + error.to_string(), + )); + } + } if self.sources.is_empty() { diagnostics.push(SpecDiagnostic::new( @@ -1796,6 +1841,18 @@ pub fn json_schema() -> Value { "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 3600} } }, + "fixedLayoutEpub": { + "type": "object", "additionalProperties": false, + "required": ["page_progression_direction", "spread", "cover_member_id", "max_pages", "max_input_bytes", "max_output_bytes"], + "properties": { + "page_progression_direction": {"enum": ["ltr", "rtl"]}, + "spread": {"const": "none"}, + "cover_member_id": {"$ref": "#/$defs/stableId"}, + "max_pages": {"type": "integer", "minimum": 1, "maximum": 1000}, + "max_input_bytes": {"type": "integer", "minimum": 1, "maximum": 536870912}, + "max_output_bytes": {"type": "integer", "minimum": 1, "maximum": 536870912} + } + }, "executionPolicy": { "type": "object", "additionalProperties": false, @@ -1822,6 +1879,7 @@ pub fn json_schema() -> Value { "redaction_policy": {"type": ["string", "null"]}, "hygiene_policy": {"anyOf": [{"$ref": "#/$defs/stableId"}, {"type": "null"}]} ,"print_pdf_interior": {"anyOf": [{"$ref": "#/$defs/printPdfInterior"}, {"type": "null"}]} + ,"fixed_layout_epub": {"anyOf": [{"$ref": "#/$defs/fixedLayoutEpub"}, {"type": "null"}]} } }, "outputLayout": { diff --git a/crates/renderflow-core/tests/fixed_layout_epub.rs b/crates/renderflow-core/tests/fixed_layout_epub.rs new file mode 100644 index 0000000..e09610d --- /dev/null +++ b/crates/renderflow-core/tests/fixed_layout_epub.rs @@ -0,0 +1,575 @@ +//! Synthetic, unconditional fixed-layout EPUB generation coverage. All source +//! artwork is tiny and redistributable; no external renderer is involved. + +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::{atomic::AtomicBool, Arc}; + +use renderflow::evidence::{sha256_serialized, ArtifactRole, RunState, StepState}; +use renderflow::planning::{execute, resolve, CanonicalExecutionResult, PlanningRequest}; +use sha2::{Digest, Sha256}; +use tempfile::TempDir; +use zip::{CompressionMethod, ZipArchive}; + +const PNG_FIRST: &[u8] = include_bytes!("fixtures/print-pdf/page-001.png"); +const PNG_SECOND: &[u8] = include_bytes!("fixtures/print-pdf/page-002.png"); +const JPEG_FIRST: &[u8] = include_bytes!("fixtures/print-pdf/page-001.jpg"); +const JPEG_SECOND: &[u8] = include_bytes!("fixtures/print-pdf/page-002.jpg"); +const SVG_FIRST: &[u8] = br#""#; +const SVG_SECOND: &[u8] = br#""#; + +struct Fixture { + dir: TempDir, + config: PathBuf, + originals: [Vec; 2], + extension: &'static str, +} + +impl Fixture { + fn new(format: &str, direction: &str) -> Self { + let (extension, media_type, pages): (&str, &str, [&[u8]; 2]) = match format { + "png" => ("png", "image/png", [PNG_FIRST, PNG_SECOND]), + "jpeg" => ("jpg", "image/jpeg", [JPEG_FIRST, JPEG_SECOND]), + "svg" => ("svg", "image/svg+xml", [SVG_FIRST, SVG_SECOND]), + other => panic!("unsupported synthetic format: {other}"), + }; + let dir = tempfile::tempdir().unwrap(); + let originals = [pages[0].to_vec(), pages[1].to_vec()]; + let mut source_specs = String::new(); + let mut artwork = String::new(); + for (index, bytes) in pages.iter().enumerate() { + let number = index + 1; + let filename = format!("page-{number:03}.{extension}"); + fs::write(dir.path().join(&filename), bytes).unwrap(); + let digest = format!("{:x}", Sha256::digest(bytes)); + source_specs.push_str(&format!( + " - id: source.page{number:03}\n path: {filename}\n format: {format}\n media_type: {media_type}\n sha256: \"{digest}\"\n geometry: {{ width: 90, height: 90, unit: mm }}\n" + )); + artwork.push_str(&format!( + " - role: page\n path: {filename}\n alt_text: Synthetic page {number} with a geometric mark.\n" + )); + } + let config = dir.path().join("renderflow.yaml"); + fs::write( + &config, + format!( + "schema: renderflow/v2\nsources:\n{source_specs} - id: source.pages\n kind: collection\n members: [source.page001, source.page002]\npublication:\n schema: renderflow.publication/v1\n publication: Synthetic Fixture Editions\n issue_id: synthetic-fixed-001\n title: Synthetic Fixed EPUB\n contributors:\n - name: Renderflow Contributors\n role: author\n publication_date: \"2026-09-27\"\n language: en-US\n geometry: {{ width: 90, height: 90, unit: mm }}\n artwork:\n{artwork} rights:\n license: CC0-1.0\n rights_holder: Renderflow Contributors\n reviewed: true\n accessibility:\n summary: Two synthetic geometric pages with alternative descriptions.\n access_modes: [visual]\n hazards: [none]\n output_roles:\n ebook:\n format: epub\n stage: candidate\ntargets:\n exact:\n - id: target.ebook\n role: ebook\n format: epub\n requirement: required\nexecution:\n fixed_layout_epub:\n page_progression_direction: {direction}\n spread: none\n cover_member_id: source.page001\n max_pages: 2\n max_input_bytes: 1000000\n max_output_bytes: 5000000\noutput:\n bundle_root: \"{}\"\n naming_template: \"{{source.id}}/{{target.role}}.{{ext}}\"\n", + dir.path().join("dist").display() + ), + ) + .unwrap(); + Self { + dir, + config, + originals, + extension: match format { + "jpeg" => "jpg", + "svg" => "svg", + _ => "png", + }, + } + } + + fn source(&self, index: usize) -> PathBuf { + self.dir + .path() + .join(format!("page-{:03}.{}", index + 1, self.extension)) + } + + fn rewrite(&self, before: &str, after: &str) { + let yaml = fs::read_to_string(&self.config).unwrap(); + assert!(yaml.contains(before), "test replacement missing: {before}"); + fs::write(&self.config, yaml.replacen(before, after, 1)).unwrap(); + } + + fn unchanged(&self) { + for index in 0..2 { + assert_eq!( + fs::read(self.source(index)).unwrap(), + self.originals[index], + "source artwork was mutated" + ); + } + } +} + +fn resolve_error(fixture: &Fixture, code: &str) { + let error = format!( + "{:#}", + resolve(PlanningRequest::from_path(&fixture.config)) + .err() + .expect("invalid fixture unexpectedly planned") + ); + assert!(error.contains(code), "expected {code}, got: {error}"); + assert!(!fixture.dir.path().join("dist").exists()); + fixture.unchanged(); +} + +fn zip_text(archive: &mut ZipArchive, name: &str) -> String { + let mut text = String::new(); + archive + .by_name(name) + .unwrap_or_else(|_| panic!("missing ZIP member {name}")) + .read_to_string(&mut text) + .unwrap(); + text +} + +fn zip_bytes(archive: &mut ZipArchive, name: &str) -> Vec { + let mut bytes = Vec::new(); + archive + .by_name(name) + .unwrap_or_else(|_| panic!("missing ZIP member {name}")) + .read_to_end(&mut bytes) + .unwrap(); + bytes +} + +fn published_epub(result: &CanonicalExecutionResult) -> &Path { + let epubs = result + .outputs + .iter() + .filter(|output| output.ends_with("/ebook.epub")) + .collect::>(); + assert_eq!(epubs.len(), 1, "expected exactly one EPUB output"); + let path = Path::new(epubs[0]); + assert!(path.is_file(), "EPUB missing at {}", path.display()); + path +} + +fn assert_publication_sidecars(result: &CanonicalExecutionResult, fixture: &Fixture) { + let metadata = fixture.dir.path().join("dist/metadata"); + for name in [ + "publication.json", + "manifest.json", + "provenance.json", + "preflight.json", + "checksums.sha256", + ] { + let path = metadata.join(name); + assert!( + path.is_file(), + "publication sidecar missing: {}", + path.display() + ); + assert!( + result + .outputs + .iter() + .any(|output| Path::new(output) == path), + "publication sidecar absent from output evidence: {}", + path.display() + ); + } +} + +fn expected_members(extension: &str) -> Vec { + let mut names = vec![ + "mimetype".to_string(), + "META-INF/container.xml".to_string(), + "EPUB/book.opf".to_string(), + "EPUB/nav.xhtml".to_string(), + "EPUB/styles.css".to_string(), + ]; + for number in 1..=2 { + names.push(format!("EPUB/pages/page-{number:04}.xhtml")); + names.push(format!("EPUB/images/page-{number:04}.{extension}")); + } + names +} + +fn inspect_generated_epub(path: &Path, fixture: &Fixture, direction: &str) { + let mut archive = ZipArchive::new(File::open(path).unwrap()).unwrap(); + let members = (0..archive.len()) + .map(|index| { + let entry = archive.by_index(index).unwrap(); + assert_eq!(entry.compression(), CompressionMethod::Stored); + let modified = entry.last_modified().expect("ZIP timestamp is present"); + assert_eq!(modified.year(), 1980); + assert_eq!(modified.month(), 1); + assert_eq!(modified.day(), 1); + entry.name().to_string() + }) + .collect::>(); + assert_eq!(members, expected_members(fixture.extension)); + assert_eq!(zip_bytes(&mut archive, "mimetype"), b"application/epub+zip"); + let container = zip_text(&mut archive, "META-INF/container.xml"); + assert!(container.contains("full-path=\"EPUB/book.opf\"")); + + let opf = zip_text(&mut archive, "EPUB/book.opf"); + assert!(opf.contains("version=\"3.0\""), "{opf}"); + assert!(opf.contains("pre-paginated"), "{opf}"); + assert!(opf.contains("Synthetic Fixed EPUB"), "{opf}"); + assert!(opf.contains("synthetic-fixed-001"), "{opf}"); + assert!(opf.contains("en-US"), "{opf}"); + assert!(opf.contains("Renderflow Contributors"), "{opf}"); + assert!(opf.contains("CC0-1.0"), "{opf}"); + assert!(opf.contains("schema:accessMode"), "{opf}"); + assert!(opf.contains("schema:accessibilitySummary"), "{opf}"); + assert!(opf.contains(&format!("page-progression-direction=\"{direction}\""))); + assert!(opf.contains("properties=\"nav\""), "{opf}"); + assert!(opf.contains("properties=\"cover-image\""), "{opf}"); + assert_eq!(opf.matches("properties=\"cover-image\"").count(), 1); + assert!(opf.contains(&format!( + "id=\"image-0001\" href=\"images/page-0001.{}\"", + fixture.extension + ))); + let first_opf = opf.find("pages/page-0001.xhtml").unwrap(); + let second_opf = opf.find("pages/page-0002.xhtml").unwrap(); + assert!( + first_opf < second_opf, + "manifest order differs from source order" + ); + let first_spine = opf.find(">(); + assert_eq!(source.len(), 2); + let ebook = evidence.iter().find(|entry| entry.role == "ebook").unwrap(); + assert_eq!( + ebook.sources, + source + .iter() + .map(|item| item.artifact_id.clone()) + .collect::>() + ); + assert_eq!(source[0].metadata["renderflow.collection.index"], 0); + assert_eq!(source[1].metadata["renderflow.collection.index"], 1); + fixture.unchanged(); + outputs.push(fs::read(epub).unwrap()); + } + assert_eq!(outputs[0], outputs[1], "{format} clean builds differ"); + } +} + +#[test] +fn fixed_layout_refuses_unsupported_sources_geometry_metadata_and_bounds() { + let svg = Fixture::new("svg", "ltr"); + resolve_error(&svg, "fixed_epub.input.format"); + for (before, after, code) in [ + ("width: 90", "width: 91", "fixed_epub.geometry"), + ( + "max_input_bytes: 1000000", + "max_input_bytes: 1", + "fixed_epub.bounds", + ), + ("role: ebook", "role: web", "fixed_epub.target"), + ( + "contributors:\n - name: Renderflow Contributors\n role: author", + "contributors: []", + "fixed_epub.metadata", + ), + ( + "alt_text: Synthetic page 1 with a geometric mark.", + "alt_text: \"\"", + "fixed_epub.metadata", + ), + ] { + let fixture = Fixture::new("png", "ltr"); + fixture.rewrite(before, after); + resolve_error(&fixture, code); + } +} + +#[test] +fn malformed_and_active_svg_sources_are_refused_without_parsing_or_publication() { + // SVG is outside the native image-only EPUB route. These inputs remain + // prohibited even if their source digest is honestly declared; this test + // does not claim to validate or sanitize any SVG/XML payload. + let cases: &[(&str, &[u8])] = &[ + ("malformed", b""#, + ), + ( + "script", + br#""#, + ), + ( + "external_entity", + br#"]>&remote;"#, + ), + ]; + for (name, bytes) in cases { + let fixture = Fixture::new("svg", "ltr"); + fs::write(fixture.source(0), bytes).unwrap(); + fixture.rewrite( + &format!("{:x}", Sha256::digest(SVG_FIRST)), + &format!("{:x}", Sha256::digest(bytes)), + ); + let error = format!( + "{:#}", + resolve(PlanningRequest::from_path(&fixture.config)) + .err() + .unwrap_or_else(|| panic!("{name} SVG unexpectedly planned")) + ); + assert!( + error.contains("fixed_epub.input.format") || error.contains("collection.member."), + "{name} SVG should be rejected by the format boundary or typed intake: {error}" + ); + assert!(!fixture.dir.path().join("dist").exists()); + assert_eq!(fs::read(fixture.source(0)).unwrap(), *bytes); + assert_eq!(fs::read(fixture.source(1)).unwrap(), SVG_SECOND); + } +} + +#[test] +fn output_byte_limit_prevents_publication() { + let fixture = Fixture::new("png", "ltr"); + fixture.rewrite("max_output_bytes: 5000000", "max_output_bytes: 1"); + let result = execute( + resolve(PlanningRequest::from_path(&fixture.config)).unwrap(), + false, + ) + .unwrap(); + assert_eq!(result.run_manifest.state, RunState::Failed); + assert!(result + .outputs + .iter() + .all(|output| !output.ends_with(".epub"))); + assert_publication_sidecars(&result, &fixture); + assert!(!fixture + .dir + .path() + .join("dist/source.pages/ebook.epub") + .exists()); + assert!(result.run_manifest.steps.iter().any(|step| { + step.diagnostics + .iter() + .any(|diagnostic| diagnostic.code.starts_with("fixed_epub.")) + })); + fixture.unchanged(); +} + +#[test] +fn changed_artwork_after_planning_never_publishes_epub() { + let fixture = Fixture::new("png", "ltr"); + let resolved = resolve(PlanningRequest::from_path(&fixture.config)).unwrap(); + fs::write(fixture.source(1), b"changed after planning").unwrap(); + let result = execute(resolved, false).unwrap(); + assert_eq!(result.run_manifest.state, RunState::Failed); + assert!(result.outputs.is_empty()); + assert!(result + .run_manifest + .diagnostics + .iter() + .any(|diagnostic| { diagnostic.code == "execution.source_changed_after_intake" })); + assert!(!fixture + .dir + .path() + .join("dist/source.pages/ebook.epub") + .exists()); + assert_eq!(fs::read(fixture.source(0)).unwrap(), fixture.originals[0]); +} + +#[test] +fn cancellation_before_first_wave_records_no_epub() { + let fixture = Fixture::new("png", "ltr"); + let cancellation = Arc::new(AtomicBool::new(true)); + let resolved = resolve(PlanningRequest::from_path(&fixture.config)) + .unwrap() + .with_cancellation_flag(cancellation); + let result = execute(resolved, false).unwrap(); + assert_eq!(result.run_manifest.state, RunState::Cancelled); + assert!(result.outputs.iter().all(|path| !path.ends_with(".epub"))); + assert!(!fixture + .dir + .path() + .join("dist/source.pages/ebook.epub") + .exists()); + assert_eq!(result.run_manifest.steps.len(), 1); + let step = &result.run_manifest.steps[0]; + assert_eq!(step.state, StepState::Cancelled); + assert!(step + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "execution.step_cancelled")); + assert!(Path::new(result.manifest_path.as_ref().unwrap()).is_file()); + fixture.unchanged(); +} + +#[test] +fn right_to_left_direction_is_structural_and_does_not_mirror_artwork() { + let fixture = Fixture::new("png", "rtl"); + let result = execute( + resolve(PlanningRequest::from_path(&fixture.config)).unwrap(), + false, + ) + .unwrap(); + assert_eq!(result.run_manifest.state, RunState::Complete); + inspect_generated_epub(published_epub(&result), &fixture, "rtl"); + fixture.unchanged(); +} + +#[test] +fn metadata_and_page_order_change_the_execution_plan_identity() { + let fixture = Fixture::new("png", "ltr"); + let initial = resolve(PlanningRequest::from_path(&fixture.config)).unwrap(); + let initial_digest = sha256_serialized(initial.plan()).unwrap().value; + fixture.rewrite("Synthetic Fixed EPUB", "Synthetic Fixed EPUB Revised"); + let metadata_plan = resolve(PlanningRequest::from_path(&fixture.config)).unwrap(); + let metadata_digest = sha256_serialized(metadata_plan.plan()).unwrap().value; + assert_ne!( + initial_digest, metadata_digest, + "metadata not bound to the plan" + ); + + let reordered = Fixture::new("png", "ltr"); + reordered.rewrite( + "members: [source.page001, source.page002]", + "members: [source.page002, source.page001]", + ); + reordered.rewrite( + "cover_member_id: source.page001", + "cover_member_id: source.page002", + ); + let reordered_plan = resolve(PlanningRequest::from_path(&reordered.config)).unwrap(); + let order_digest = sha256_serialized(reordered_plan.plan()).unwrap().value; + assert_ne!( + initial_digest, order_digest, + "member order not bound to the plan" + ); +} + +#[test] +fn member_digest_and_declared_geometry_change_the_plan_identity() { + let fixture = Fixture::new("png", "ltr"); + let initial = resolve(PlanningRequest::from_path(&fixture.config)).unwrap(); + let initial_digest = sha256_serialized(initial.plan()).unwrap().value; + + let altered_bytes = Fixture::new("png", "ltr"); + fs::write(altered_bytes.source(0), PNG_SECOND).unwrap(); + altered_bytes.rewrite( + &format!("{:x}", Sha256::digest(PNG_FIRST)), + &format!("{:x}", Sha256::digest(PNG_SECOND)), + ); + let changed = resolve(PlanningRequest::from_path(&altered_bytes.config)).unwrap(); + assert_ne!( + initial_digest, + sha256_serialized(changed.plan()).unwrap().value, + "member payload digest not bound to the plan" + ); + + let altered_geometry = Fixture::new("png", "ltr"); + let yaml = fs::read_to_string(&altered_geometry.config).unwrap(); + assert_eq!(yaml.matches("width: 90, height: 90").count(), 3); + fs::write( + &altered_geometry.config, + yaml.replace("width: 90, height: 90", "width: 95, height: 95"), + ) + .unwrap(); + let changed = resolve(PlanningRequest::from_path(&altered_geometry.config)).unwrap(); + assert_ne!( + initial_digest, + sha256_serialized(changed.plan()).unwrap().value, + "declared page geometry not bound to the plan" + ); + fixture.unchanged(); + altered_geometry.unchanged(); +} + +#[test] +fn planned_source_digests_match_original_artwork() { + let fixture = Fixture::new("png", "ltr"); + let resolved = resolve(PlanningRequest::from_path(&fixture.config)).unwrap(); + let members = &resolved.plan().source_collection.as_ref().unwrap().members; + let by_source = members + .iter() + .map(|member| (member.source_id.as_str(), member.artifact.digest.as_str())) + .collect::>(); + for (index, id) in ["source.page001", "source.page002"].iter().enumerate() { + assert_eq!( + by_source[id] + .strip_prefix("sha256:") + .unwrap_or(by_source[id]), + format!("{:x}", Sha256::digest(&fixture.originals[index])) + ); + } +} diff --git a/docs/architecture/graph-engine.md b/docs/architecture/graph-engine.md index 32fd539..fafee1f 100644 --- a/docs/architecture/graph-engine.md +++ b/docs/architecture/graph-engine.md @@ -30,6 +30,14 @@ Each edge records: - expected `quality` - `input_kind`: `single` or `collection` +The built-in fixed-layout EPUB edge consumes a bounded ordered PNG or JPEG +collection and produces one EPUB artifact. Its exact capability is +`ebook.generate.epub.fixed-layout`; the document-source Pandoc EPUB edge +remains a separate single-input reflowable path. Ordered source IDs and digests, +configuration, and the selected toolchain bind checkpoint reuse. The package +is generated in process and cannot be mistaken for an implicit +`epub -> kepub` fixed-layout capability. + ## Graph construction from YAML `build_graph_and_executor_from_yaml` reads a transform YAML file and: diff --git a/docs/user-guide/adapter-ecosystem.md b/docs/user-guide/adapter-ecosystem.md index 9885e97..89dce3e 100644 --- a/docs/user-guide/adapter-ecosystem.md +++ b/docs/user-guide/adapter-ecosystem.md @@ -50,9 +50,11 @@ registers an edge and executor. | Archives | ZIP | Experimental | Normalize ordering and timestamps before promotion | | Generic image-to-PDF | img2pdf | Experimental | Does not claim print-interior validation | | Ordered print-interior PDF | img2pdf 0.6.3 | Integrated exact route | PNG or JPEG collection; inspect ordered streams and page boxes | +| Fixed-layout EPUB | Renderflow native packager | Exact route under #417 | Ordered PNG or JPEG collection; SVG refused; #418 owns independent conformance | | PDF processing | Ghostscript | Experimental | Require explicit licensing and fidelity policy | | Local image AI | Upscayl NCNN | Experimental | Require model identity, license evidence, and AI opt-in | -| E-books | Calibre evaluation | Deferred to #344 | Integrate as ordinary providers | +| Reflowable EPUB / KEPUB | Pandoc / Kepubify | Integrated | Retain separate document and Kobo conversion edges | +| Calibre evaluation | Calibre | Deferred | Requires a separate bounded adapter and conformance case | | Video transcode | HandBrakeCLI evaluation | Deferred to #345 | Preserve the Aniflow ownership boundary | | Searchable PDF OCR | OCRmyPDF evaluation | Adapt | Add searchable-PDF validation after the Tesseract base pack | | Structured data | jq | Experimental | Constrain filters and validate declared output schemas | @@ -61,6 +63,13 @@ registers an edge and executor. The catalog also documents rejected candidates and why. Rejection prevents accidental dependency growth while leaving the decision inspectable and revisable. +The fixed-layout EPUB packager is in process and has the virtual +`tool.renderflow-epub` capability in the tool registry. The current adapter-pack +catalog schema describes command-backed `renderflow.process/v1` execution; it +does not model an in-process packager as an external executable. The EPUB route +is selected by the canonical graph and documented in the +[fixed-layout EPUB guide](fixed-layout-epub.md). + ## Adding an Adapter 1. Add or reuse a stable `tool.*` entry in `tool-registry.yaml`. diff --git a/docs/user-guide/ebook-derivatives.md b/docs/user-guide/ebook-derivatives.md index b947b79..58cba7d 100644 --- a/docs/user-guide/ebook-derivatives.md +++ b/docs/user-guide/ebook-derivatives.md @@ -1,9 +1,11 @@ # EPUB and KEPUB derivatives -Renderflow models EPUB and KEPUB as provider-neutral artifact formats. EPUB is -generated as EPUB 3 through Pandoc. KEPUB is a separate `epub -> kepub` graph -edge backed by Kepubify, so its provider and fallback behavior remain visible in -the execution plan and provenance. +Renderflow models EPUB and KEPUB as provider-neutral artifact formats. Pandoc +generates reflowable EPUB 3 from documents. The exact native +[fixed-layout EPUB](fixed-layout-epub.md) route packages a bounded ordered PNG +or JPEG collection. KEPUB is a separate `epub -> kepub` graph edge backed by +Kepubify for the established reflowable path; its provider and fallback remain +visible in the execution plan and provenance. ## Generate derivatives @@ -22,9 +24,11 @@ outputs: - type: kepub ``` -Spec v2 callers can request `format: epub` or `format: kepub`. The KEPUB branch -reuses the EPUB artifact and only runs Kepubify after EPUB generation. Final -KEPUB files use the conventional `.kepub.epub` suffix. +Document-source spec v2 callers can request `format: epub` or `format: kepub`. +That KEPUB branch reuses the reflowable EPUB artifact and only runs Kepubify +after EPUB generation. Final KEPUB files use the conventional `.kepub.epub` +suffix. Ordered image collections use the separate exact EPUB target and +`execution.fixed_layout_epub` policy described in the fixed-layout guide. An EPUB output's optional `template` field names an OPF metadata fragment under the configured template directory and is passed to Pandoc as `--epub-metadata`. @@ -40,6 +44,12 @@ renderflow ebook inspect --input dist/book.epub --format json --epubcheck renderflow ebook capabilities --format yaml ``` +The exact route is discoverable through its graph capability and the virtual +`tool.renderflow-epub` provider. The broad `ebook capabilities` summary still +reports fixed-layout generation as unsupported in #417; #418 owns changing that +claim after independent output validation. A false summary value does not +convert a selected exact route into the Pandoc reflow path. + Native inspection validates the EPUB container and EPUB 3 package envelope and reports XHTML content, internal title/language/identifier/creator/rights metadata, navigation, page-list, layout declarations, accessibility metadata, @@ -53,13 +63,18 @@ EPUBCheck is recorded as unavailable; it is never represented as a pass. ## Capability boundaries The bundled Pandoc path generates reflowable EPUB 3. The bundled Kepubify path -generates reflowable KEPUB from EPUB. Native inspection recognizes -`rendition:layout` declarations and reports fixed-layout and mixed publications, -but the bundled adapters do not claim fixed-layout generation. +generates reflowable KEPUB from EPUB. The native ordered-image route generates +fixed-layout EPUB packages with a pre-paginated declaration and explicit page +order. It does not establish fixed-layout KEPUB support. Native inspection +recognizes `rendition:layout` declarations and reports fixed-layout and mixed +publications, but independent EPUB 3.3 validation and final capability truth +remain the work of [#418](https://github.com/egohygiene/renderflow/issues/418). Page-list and accessibility metadata are inspected and carried as evidence; a missing page-list or sparse accessibility metadata produces a warning. EPUB 3.3 conformance is established by EPUBCheck evidence, not by ZIP validity alone. +Image descriptions and accessibility metadata also do not prove complete +accessibility for visually rich pages. Retailer acceptance is deliberately outside this generic format capability. Every inspection reports `requires_provider_profile`; Lulu, Kobo, or another diff --git a/docs/user-guide/fixed-layout-epub.md b/docs/user-guide/fixed-layout-epub.md new file mode 100644 index 0000000..18d57a2 --- /dev/null +++ b/docs/user-guide/fixed-layout-epub.md @@ -0,0 +1,138 @@ +# Fixed-layout EPUB from ordered pages + +`ebook.generate.epub.fixed-layout` consumes one explicitly ordered, immutable +`renderflow/v2` collection of local PNG **or** JPEG pages. Renderflow assembles +the package in process, without an external EPUB generator or network request. +The initial route requires a homogeneous collection and an explicit bounded +`execution.fixed_layout_epub` policy. SVG pages are refused until a safe parser, +fixture, and validation path establish their support. + +## Declare an exact build + +This example uses synthetic page artwork. Replace each placeholder digest with +the SHA-256 of the exact local input bytes, and keep the page dimensions and +publication metadata consistent with the artwork: + +```yaml +schema: renderflow/v2 +sources: + - id: source.page001 + path: pages/001.png + format: png + media_type: image/png + sha256: "<64 lowercase hex characters for page 001>" + geometry: { width: 90, height: 90, unit: mm } + - id: source.page002 + path: pages/002.png + format: png + media_type: image/png + sha256: "<64 lowercase hex characters for page 002>" + geometry: { width: 90, height: 90, unit: mm } + - id: source.pages + kind: collection + members: [source.page001, source.page002] +targets: + exact: + - id: target.ebook + role: ebook + format: epub + requirement: required +execution: + fixed_layout_epub: + page_progression_direction: ltr + spread: none + cover_member_id: source.page001 + max_pages: 44 + max_input_bytes: 268435456 + max_output_bytes: 268435456 +publication: + publication: Synthetic pages + issue_id: synthetic-pages-01 + title: Synthetic pages + publication_date: "2026-09-28" + language: en + contributors: + - { name: Example Author, role: author } + geometry: { width: 90, height: 90, unit: mm } + artwork: + - { role: page, path: pages/001.png, alt_text: Synthetic first page. } + - { role: page, path: pages/002.png, alt_text: Synthetic second page. } + rights: + license: CC0-1.0 + rights_holder: Example Author + accessibility: + summary: Each page has a concise image description; the illustrated content may require a fuller transcript. + access_modes: [visual] + hazards: [none] +output: + bundle_root: dist +``` + +The `members` array is the reading order. `cover_member_id` must identify its +first frozen member; it does not authorize an undeclared or separately fetched +cover. `page_progression_direction` changes EPUB progression metadata (`ltr` or +`rtl`), never the pixels or artwork orientation. The first supported spread +policy is `none`. The route requires publication title, issue identity, date, +language, contributor, rights holder and license, accessibility summary, +visual access mode and hazards declaration, and a +matching `publication.artwork` entry with an `alt_text` for every page. A +description of an image is useful accessibility metadata, but it is not a +transcript or proof that the publication is fully accessible. + +Every member must declare positive millimeter geometry matching +`publication.geometry`. This first route refuses nonzero bleed, margin, safe +area, or an image aspect ratio inconsistent with the declared page. Its bounded +image preflight admits RGB/grayscale PNG or JPEG in the proven subset and +rejects ambiguous color intent, alpha/palette, unsafe EXIF orientation, and +unsupported image structures. Each source image has its own preflight byte and +decoded-size bounds in addition to the collection limits. + +Run the public validation, planning, and execution path: + +```bash +renderflow spec validate --config "renderflow.yaml" +renderflow build --config "renderflow.yaml" --dry-run +renderflow build --config "renderflow.yaml" +renderflow ebook inspect --input "dist/source.pages/ebook.epub" --format json +``` + +The output path above follows the default naming template; use the path in the +run manifest when the output layout is customized. Inspect +`dist/renderflow-run.json`, the publication metadata under `dist/metadata/`, +and the generated EPUB. A failed or interrupted run does not claim a completed +e-book artifact. + +## Package and evidence + +The deterministic package starts with an uncompressed `mimetype` member, +followed by `META-INF/container.xml`, `EPUB/book.opf`, `EPUB/nav.xhtml`, +`EPUB/styles.css`, and numbered `EPUB/pages/` and `EPUB/images/` members. +Member order and timestamps are fixed; page XHTML carries an explicit +viewport. OPF records `rendition:layout` as `pre-paginated`, the manifest and +ordered spine, page progression, a cover-image relationship, and publication +metadata. Navigation contains a table of contents and a page list. Each +XHTML page references the matching local image and its declared description. + +The package targets EPUB 3.3 while the OPF `package` element +uses `version="3.0"`, as shown in the [EPUB 3.3 specification](https://www.w3.org/TR/epub-33/). +That OPF value does not mean the publication is limited to an older EPUB +release. Native structural inspection reports the resulting package shape; +independent EPUB 3.3 conformance and exact capability advertisement belong to +[#418](https://github.com/egohygiene/renderflow/issues/418). Optional local +EPUBCheck evidence may be requested by adding `--epubcheck` to the inspection +command above. +A missing EPUBCheck executable is reported as unavailable, never as a pass. + +Planning freezes each member's ID, path, digest, media type, geometry, and +position. Execution checks the sources again before import. The output records +ordered source lineage, transform configuration, toolchain, and artifact +digests. Source, order, geometry, metadata, or toolchain changes invalidate +compatible checkpoints. Source files are never modified. The policy's explicit +page and byte limits bound the package; unsupported or ambiguous inputs fail +with `fixed_epub.*` diagnostics. No renderer, retailer, upload, or publication +approval is invoked. + +Pandoc's existing reflowable EPUB route remains distinct. This route does not +generate fixed-layout KEPUB; EPUB-to-KEPUB conversion must not be taken as +proof of fixed-layout KEPUB compatibility. The selected reading system and +distribution channel still require their own review. diff --git a/docs/user-guide/ordered-collections.md b/docs/user-guide/ordered-collections.md index 9356515..86f0136 100644 --- a/docs/user-guide/ordered-collections.md +++ b/docs/user-guide/ordered-collections.md @@ -4,8 +4,10 @@ A `renderflow/v2` collection names immutable local artifacts in the exact order collection-input transform receives them. The public `renderflow spec validate`, `renderflow build --dry-run`, `renderflow build`, and Rust `planning::{resolve, execute}` surfaces use the same canonical plan and run -evidence. The exact [print-interior PDF](print-interior-pdf.md) route now consumes -homogeneous PNG or JPEG page collections. Fixed-layout EPUB remains separate. +evidence. The exact [print-interior PDF](print-interior-pdf.md) and +[fixed-layout EPUB](fixed-layout-epub.md) routes each consume homogeneous PNG or +JPEG page collections under separate policies. Neither route takes an implicit +file-system directory order as publication order. ```yaml schema: renderflow/v2 @@ -85,5 +87,6 @@ rules. The currently supported canonical execution path requires all root collection members to share a format and the first edge to consume a collection. Mixed -media, same-format aggregation output, arbitrary root fan-out, fixed-layout -EPUB generation, and publication approval remain outside this checkpoint. +media, same-format aggregation output, arbitrary root fan-out, and publication +approval remain outside this checkpoint. The bounded image-based PDF and EPUB +routes enforce their own input, metadata, and output constraints. diff --git a/docs/user-guide/spec-v2-reference.md b/docs/user-guide/spec-v2-reference.md index 51936f6..f6cc21a 100644 --- a/docs/user-guide/spec-v2-reference.md +++ b/docs/user-guide/spec-v2-reference.md @@ -96,6 +96,7 @@ Spec v2 describes source intent, derivative selection, execution policy, and det | --- | --- | --- | --- | | `ai` | `deny` / `local_only` / `allow` | no | `"deny"` | | `budgets` | `budgets` | no | — | +| `fixed_layout_epub` | `object` | no | — | | `hygiene_policy` | `object` | no | — | | `max_parallel` | `integer` | no | `1` | | `minimum_fidelity` | `number` / `null` | no | — | @@ -127,6 +128,17 @@ Spec v2 describes source intent, derivative selection, execution policy, and det | `scaling` | `"fit"` | yes | — | | `timeout_seconds` | `integer` | yes | — | +## Fixed-layout EPUB policy + +| Field | Type | Required | Default | +| --- | --- | --- | --- | +| `cover_member_id` | `stableId` | yes | — | +| `max_input_bytes` | `integer` | yes | — | +| `max_output_bytes` | `integer` | yes | — | +| `max_pages` | `integer` | yes | — | +| `page_progression_direction` | `ltr` / `rtl` | yes | — | +| `spread` | `"none"` | yes | — | + ## Output layout | Field | Type | Required | Default | diff --git a/docs/user-guide/tool-registry.md b/docs/user-guide/tool-registry.md index 7ab1844..9aa6245 100644 --- a/docs/user-guide/tool-registry.md +++ b/docs/user-guide/tool-registry.md @@ -27,6 +27,7 @@ without editing this built-in catalog. | `tool.kepubify` | Kepubify | executable: `kepubify` | optional | configuration_dependent | local | | `tool.lulu-rules` | Pinned Lulu publication rule pack | virtual | optional | deterministic | local | | `tool.pandoc` | Pandoc | executable: `pandoc` | required | configuration_dependent | local | +| `tool.renderflow-epub` | Renderflow fixed-layout EPUB packager | virtual | experimental | deterministic | local | | `tool.tectonic` | Tectonic | executable: `tectonic` | optional | configuration_dependent | network_optional | | `tool.tesseract` | Tesseract OCR | executable: `tesseract` | experimental | configuration_dependent | local | | `tool.upscayl-ncnn` | Upscayl NCNN | executable: `upscayl-ncnn`, `upscayl-bin` | experimental | configuration_dependent | local | @@ -57,6 +58,7 @@ without editing this built-in catalog. | `publication.lulu.print-direct.preflight` | `tool.lulu-rules` | | `document.convert` | `tool.pandoc` | | `document.generate` | `tool.pandoc` | +| `ebook.generate.epub.fixed-layout` | `tool.renderflow-epub` | | `latex.compile` | `tool.tectonic` | | `pdf.typeset` | `tool.tectonic` | | `ocr.extract.text` | `tool.tesseract` | diff --git a/mkdocs.yml b/mkdocs.yml index 4ae33ed..1bc2d8b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,6 +73,7 @@ nav: - Tool Registry: user-guide/tool-registry.md - Adapter Ecosystem: user-guide/adapter-ecosystem.md - EPUB and KEPUB Derivatives: user-guide/ebook-derivatives.md + - Fixed-layout EPUB: user-guide/fixed-layout-epub.md - Magazine Publications: user-guide/magazine-publications.md - Coloring-book Publications: user-guide/coloring-book-publications.md - LaTeX Components: user-guide/latex-components.md diff --git a/schemas/renderflow-v2.schema.json b/schemas/renderflow-v2.schema.json index 41ab948..0520ffc 100644 --- a/schemas/renderflow-v2.schema.json +++ b/schemas/renderflow-v2.schema.json @@ -126,6 +126,16 @@ "budgets": { "$ref": "#/$defs/budgets" }, + "fixed_layout_epub": { + "anyOf": [ + { + "$ref": "#/$defs/fixedLayoutEpub" + }, + { + "type": "null" + } + ] + }, "hygiene_policy": { "anyOf": [ { @@ -228,6 +238,47 @@ }, "type": "object" }, + "fixedLayoutEpub": { + "additionalProperties": false, + "properties": { + "cover_member_id": { + "$ref": "#/$defs/stableId" + }, + "max_input_bytes": { + "maximum": 536870912, + "minimum": 1, + "type": "integer" + }, + "max_output_bytes": { + "maximum": 536870912, + "minimum": 1, + "type": "integer" + }, + "max_pages": { + "maximum": 1000, + "minimum": 1, + "type": "integer" + }, + "page_progression_direction": { + "enum": [ + "ltr", + "rtl" + ] + }, + "spread": { + "const": "none" + } + }, + "required": [ + "page_progression_direction", + "spread", + "cover_member_id", + "max_pages", + "max_input_bytes", + "max_output_bytes" + ], + "type": "object" + }, "hygienePolicy": { "additionalProperties": false, "properties": { diff --git a/scripts/generate_spec_v2_reference.py b/scripts/generate_spec_v2_reference.py index 6cc611b..6ef9096 100644 --- a/scripts/generate_spec_v2_reference.py +++ b/scripts/generate_spec_v2_reference.py @@ -83,6 +83,7 @@ def main() -> None: lines.extend(render_properties("Target selection", definitions["targetSelection"])) lines.extend(render_properties("Execution policy", definitions["executionPolicy"])) lines.extend(render_properties("Print-interior PDF policy", definitions["printPdfInterior"])) + lines.extend(render_properties("Fixed-layout EPUB policy", definitions["fixedLayoutEpub"])) lines.extend(render_properties("Output layout", definitions["outputLayout"])) lines.extend( [ diff --git a/tests/fixed_layout_epub_cli.rs b/tests/fixed_layout_epub_cli.rs new file mode 100644 index 0000000..88aad8a --- /dev/null +++ b/tests/fixed_layout_epub_cli.rs @@ -0,0 +1,154 @@ +//! Public CLI smoke proof for the exact native ordered-page EPUB route. + +use std::fs; +use std::process::Command; + +use renderflow::evidence::{RunState, ValidationState}; +use renderflow::RunManifest; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +const PAGES: [&[u8]; 2] = [ + include_bytes!("../crates/renderflow-core/tests/fixtures/print-pdf/page-001.png"), + include_bytes!("../crates/renderflow-core/tests/fixtures/print-pdf/page-002.png"), +]; + +#[test] +fn public_cli_builds_bounded_fixed_layout_epub() { + let dir = tempfile::tempdir().unwrap(); + let mut sources = String::new(); + let mut artwork = String::new(); + for (index, bytes) in PAGES.iter().enumerate() { + let filename = format!("page-{:03}.png", index + 1); + fs::write(dir.path().join(&filename), bytes).unwrap(); + sources.push_str(&format!( + " - id: source.page{:03}\n path: {filename}\n format: png\n media_type: image/png\n sha256: \"{:x}\"\n geometry: {{ width: 90, height: 90, unit: mm }}\n", + index + 1, + Sha256::digest(bytes), + )); + artwork.push_str(&format!( + " - role: page\n path: {filename}\n alt_text: Original synthetic page {}.\n", + index + 1 + )); + } + fs::write( + dir.path().join("renderflow.yaml"), + format!( + "schema: renderflow/v2\nsources:\n{sources} - id: source.pages\n kind: collection\n members: [source.page001, source.page002]\npublication:\n publication: Synthetic fixture\n issue_id: synthetic-epub-cli\n title: Original synthetic pages\n contributors:\n - name: Renderflow contributors\n role: author\n publication_date: \"2026-09-28\"\n language: en-US\n geometry: {{ width: 90, height: 90, unit: mm }}\n artwork:\n{artwork} rights:\n license: CC0-1.0\n rights_holder: Renderflow contributors\n accessibility:\n summary: Original synthetic geometric pages, each with a page description.\n access_modes: [visual]\n hazards: [none]\ntargets:\n exact:\n - id: target.ebook\n role: ebook\n format: epub\n requirement: required\nexecution:\n fixed_layout_epub:\n page_progression_direction: ltr\n spread: none\n cover_member_id: source.page001\n max_pages: 2\n max_input_bytes: 1000000\n max_output_bytes: 5000000\noutput:\n bundle_root: dist\n" + ), + ) + .unwrap(); + let bin = env!("CARGO_BIN_EXE_renderflow"); + for args in [ + vec!["spec", "validate", "--config", "renderflow.yaml"], + vec!["build", "--config", "renderflow.yaml", "--dry-run"], + vec!["build", "--config", "renderflow.yaml"], + ] { + let result = Command::new(bin) + .current_dir(dir.path()) + .args(&args) + .output() + .unwrap(); + assert!( + result.status.success(), + "{args:?}: {}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + } + let output = dir.path().join("dist/source.pages/ebook.epub"); + assert!(output.is_file()); + let inspection = Command::new(bin) + .current_dir(dir.path()) + .args([ + "ebook", + "inspect", + "--input", + "dist/source.pages/ebook.epub", + "--format", + "json", + ]) + .output() + .unwrap(); + assert!( + inspection.status.success(), + "ebook inspect failed: {}\n{}", + String::from_utf8_lossy(&inspection.stdout), + String::from_utf8_lossy(&inspection.stderr) + ); + let inspected: Value = serde_json::from_slice(&inspection.stdout).unwrap(); + assert_eq!(inspected["schema"], "renderflow.ebook-evidence/v1"); + assert_eq!(inspected["valid"], true); + assert_eq!(inspected["variant"], "epub"); + assert_eq!(inspected["layout"], "pre_paginated"); + assert_eq!(inspected["epub_version"], "3.0"); + assert_eq!(inspected["spine_items"], 2); + assert_eq!(inspected["page_list"], true); + assert_eq!(inspected["navigation"], true); + assert_eq!( + inspected["source_sha256"], + format!("{:x}", Sha256::digest(fs::read(&output).unwrap())) + ); + let manifest: RunManifest = + serde_json::from_slice(&fs::read(dir.path().join("dist/renderflow-run.json")).unwrap()) + .unwrap(); + assert_eq!( + manifest.state, + RunState::Complete, + "{:?}", + manifest.diagnostics + ); + assert_eq!(manifest.artifact_manifest.outputs.len(), 6); + assert_eq!( + manifest + .artifact_manifest + .outputs + .iter() + .filter(|path| path.ends_with(".epub")) + .count(), + 1 + ); + assert!(manifest + .artifact_manifest + .outputs + .contains(&"dist/source.pages/ebook.epub".to_string())); + for name in [ + "publication.json", + "manifest.json", + "provenance.json", + "preflight.json", + "checksums.sha256", + ] { + let locator = format!("dist/metadata/{name}"); + assert!( + manifest.artifact_manifest.outputs.contains(&locator), + "missing publication sidecar {locator}" + ); + assert!(dir.path().join(&locator).is_file()); + } + let terminal = manifest + .artifact_manifest + .artifacts + .iter() + .find(|artifact| artifact.role == "ebook") + .unwrap(); + assert_eq!( + terminal.producer.provider.as_deref(), + Some("tool.renderflow-epub") + ); + assert!(matches!( + terminal.validation, + ValidationState::Valid | ValidationState::ValidWithWarnings + )); + assert_eq!(terminal.sources.len(), 2); + assert_eq!( + terminal.digest.value, + format!("{:x}", Sha256::digest(fs::read(output).unwrap())) + ); + for (index, bytes) in PAGES.iter().enumerate() { + assert_eq!( + fs::read(dir.path().join(format!("page-{:03}.png", index + 1))).unwrap(), + *bytes + ); + } +}