diff --git a/README.md b/README.md index ff65dc5..d450483 100644 --- a/README.md +++ b/README.md @@ -370,7 +370,7 @@ outputs: | `input_format` | ❌ No | auto-detect | Override the input format; auto-detected from file extension when omitted | | `output_dir` | ❌ No | `dist` | Directory where output files are written | | `outputs` | ✅ Yes | — | List of one or more output targets (must contain at least one entry) | -| `outputs[].type` | ✅ Yes | — | Output format: `html`, `pdf`, `docx`, or any supported audio/image format | +| `outputs[].type` | ✅ Yes | — | Output format: `html`, `pdf`, `docx`, `epub`, `kepub`, or any supported audio/image format | | `outputs[].template` | ❌ No | — | Name of a Tera template in the `templates/` directory to use for this output | | `variables` | ❌ No | `{}` | Map of string key/value pairs injected into the document via `{{key}}` placeholders | @@ -398,6 +398,8 @@ When `input_format` is omitted, Renderflow auto-detects the format from the file | `html` | Renders to HTML | Pandoc | | `pdf` | Renders to PDF via LaTeX | Pandoc + Tectonic | | `docx` | Renders to Word document | Pandoc | +| `epub` | Renders a reflowable EPUB 3 | Pandoc | +| `kepub` | Renders EPUB 3, then Kobo enhancements | Pandoc + Kepubify | 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. diff --git a/crates/renderflow-core/data/adapter-packs.yaml b/crates/renderflow-core/data/adapter-packs.yaml index e16fab3..2594c3b 100644 --- a/crates/renderflow-core/data/adapter-packs.yaml +++ b/crates/renderflow-core/data/adapter-packs.yaml @@ -243,6 +243,46 @@ providers: upstream: https://github.com/tesseract-ocr/tesseract rationale: Experimental local OCR provider pending language-pack discovery and confidence-aware extraction evidence. + - id: adapter.ebooks.kepubify + runtime_tool: tool.kepubify + name: Kepubify Kobo EPUB adapter + families: [ebooks] + maturity: integrated + selection_priority: 10 + capabilities: [ebook.convert.kepub] + input_media_types: [application/epub+zip] + output_media_types: [application/epub+zip] + determinism: configuration_dependent + locality: local + fidelity: partial_loss + execution: *bounded_local + configuration_schema: + output: declared_artifact_path + validation: [validator.core.epub, validator.epubcheck] + provenance: [provider_id, provider_version, argv_digest, input_digest, output_digest] + upstream: https://github.com/pgaskin/kepubify + rationale: Adopted as a fast, standalone, MIT-licensed EPUB-to-KEPUB provider behind an explicit graph edge. + + - id: adapter.ebooks.epubcheck + runtime_tool: tool.epubcheck + name: EPUBCheck conformance adapter + families: [ebooks] + maturity: integrated + selection_priority: 10 + capabilities: [ebook.validate.epub] + input_media_types: [application/epub+zip] + output_media_types: [application/json] + determinism: deterministic + locality: local + fidelity: lossless + execution: *bounded_local + configuration_schema: + report_format: json + validation: [validator.epubcheck] + provenance: [provider_id, provider_version, argv_digest, input_digest, report_digest] + upstream: https://github.com/w3c/epubcheck + rationale: Adopted as the official EPUB conformance checker; absence is reported rather than silently treated as a pass. + evaluations: - candidate: Pandoc families: [documents, ebooks, office] @@ -280,8 +320,17 @@ evaluations: families: [ebooks] decision: defer upstream: https://calibre-ebook.com/ - rationale: "Coordinate with EPUB and KEPUB issue #344 so ebook semantics are not duplicated in this foundation." - follow_up: "#344" + rationale: Broader conversion fallback remains deferred until its larger runtime and output-variation surface has dedicated conformance evidence. + - candidate: Kepubify + families: [ebooks] + decision: adopt + upstream: https://github.com/pgaskin/kepubify + rationale: Focused, portable, MIT-licensed KEPUB conversion with a small local-only execution surface. + - candidate: EPUBCheck + families: [ebooks] + decision: adopt + upstream: https://github.com/w3c/epubcheck + rationale: Official EPUB 3.3 conformance evidence complements the native bounded structural inspector. - candidate: 7-Zip and libarchive families: [archives] decision: adapt diff --git a/crates/renderflow-core/data/tool-registry.yaml b/crates/renderflow-core/data/tool-registry.yaml index 535ff7b..75ee2dd 100644 --- a/crates/renderflow-core/data/tool-registry.yaml +++ b/crates/renderflow-core/data/tool-registry.yaml @@ -220,6 +220,46 @@ tools: license_notes: "See the Tesseract upstream Apache-2.0 license and language-data licenses." distribution_notes: "Experimental OCR provider; language-pack identity and confidence evidence are required before graph integration." + - id: tool.kepubify + name: Kepubify + discovery: + kind: executable + candidates: [kepubify] + version_args: [--version] + version: + min_inclusive: "4.0.0" + operating_systems: [linux, macos, windows] + capabilities: + - ebook.convert.kepub + input_media_types: [application/epub+zip] + output_media_types: [application/epub+zip] + determinism: configuration_dependent + locality: local + fidelity: partial_loss + support_tier: optional + license_notes: "Kepubify is MIT-licensed; preserve the upstream license when redistributing it." + distribution_notes: "Optional EPUB-to-KEPUB provider; Renderflow invokes it with direct argv and explicit output paths." + + - id: tool.epubcheck + name: EPUBCheck + discovery: + kind: executable + candidates: [epubcheck] + version_args: [--version] + version: + min_inclusive: "5.0.0" + operating_systems: [linux, macos, windows] + capabilities: + - ebook.validate.epub + input_media_types: [application/epub+zip] + output_media_types: [application/json] + determinism: deterministic + locality: local + fidelity: lossless + support_tier: optional + license_notes: "EPUBCheck is available under the BSD 3-Clause license." + distribution_notes: "Optional authoritative EPUB conformance provider; native structural inspection remains available without Java." + - id: tool.upscayl-ncnn name: Upscayl NCNN diff --git a/crates/renderflow-core/src/adapters/strategy.rs b/crates/renderflow-core/src/adapters/strategy.rs index e95374e..e9625ff 100644 --- a/crates/renderflow-core/src/adapters/strategy.rs +++ b/crates/renderflow-core/src/adapters/strategy.rs @@ -92,6 +92,13 @@ impl StrategyArtifactTransform { })?; Ok(path) } + + fn requires_text_preparation(&self) -> bool { + matches!( + self.from, + Format::Markdown | Format::Html | Format::Rst | Format::Latex + ) + } } impl ArtifactTransform for StrategyArtifactTransform { @@ -120,12 +127,17 @@ impl ArtifactTransform for StrategyArtifactTransform { let work_dir = tempfile::tempdir_in(store.temporary_directory()) .context("failed to create strategy adapter work directory")?; let document_input = document_input_format(self.from); - let input_path = if document_input.is_some() { + let input_path = if self.requires_text_preparation() { self.prepare_document_input(input, store, work_dir.path())? } else { store.payload_path(input)? }; - let output_path = work_dir.path().join(format!("output.{}", self.to)); + let output_name = if self.to == Format::Kepub { + "output.kepub.epub".to_string() + } else { + format!("output.{}", self.to) + }; + let output_path = work_dir.path().join(output_name); let strategy = select_strategy( &self.output_type, self.template.as_deref(), @@ -176,6 +188,7 @@ pub fn document_input_format(format: Format) -> Option { Format::Docx => Some(InputFormat::Docx), Format::Html => Some(InputFormat::Html), Format::Epub => Some(InputFormat::Epub), + Format::Kepub => Some(InputFormat::Epub), Format::Rst => Some(InputFormat::Rst), Format::Latex => Some(InputFormat::Latex), _ => None, @@ -187,6 +200,8 @@ pub fn output_type_for_format(format: Format) -> Option { Format::Html => Some(OutputType::Html), Format::Pdf => Some(OutputType::Pdf), Format::Docx => Some(OutputType::Docx), + Format::Epub => Some(OutputType::Epub), + Format::Kepub => Some(OutputType::Kepub), _ => { let value = format.to_string(); if let Ok(audio) = value.parse::() { diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index 5cd15bb..298b4c1 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -3,7 +3,8 @@ use clap::Parser; use tracing::info; use crate::cli::{ - AiCommands, Cli, Commands, GraphCommands, PluginCommands, SpecCommands, ToolCommands, + AiCommands, Cli, Commands, EbookCommands, GraphCommands, PluginCommands, SpecCommands, + ToolCommands, }; use crate::{commands, transforms}; @@ -190,6 +191,14 @@ pub fn run_cli(cli: Cli) -> Result<()> { format, } => commands::tools::run_variants(&id, models_dir.as_deref(), &format)?, }, + Some(Commands::Ebook { subcommand }) => match subcommand { + EbookCommands::Inspect { + input, + format, + epubcheck, + } => commands::ebook::run_inspect(&input, &format, epubcheck)?, + EbookCommands::Capabilities { format } => commands::ebook::run_capabilities(&format)?, + }, Some(Commands::Capabilities { format, transforms, diff --git a/crates/renderflow-core/src/artifact/model.rs b/crates/renderflow-core/src/artifact/model.rs index c625b6e..38b1eb2 100644 --- a/crates/renderflow-core/src/artifact/model.rs +++ b/crates/renderflow-core/src/artifact/model.rs @@ -163,6 +163,7 @@ impl MediaType { "application/vnd.openxmlformats-officedocument.wordprocessingml.document" } Format::Epub => "application/epub+zip", + Format::Kepub => "application/epub+zip", Format::Rst => "text/x-rst", Format::Latex => "application/x-latex", Format::Fountain => "text/plain", diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index 60127a5..32ab900 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -264,6 +264,13 @@ pub enum Commands { subcommand: ToolCommands, }, + /// Inspect and validate EPUB/KEPUB publication derivatives. + #[command(subcommand_required = true, arg_required_else_help = true)] + Ebook { + #[command(subcommand)] + subcommand: EbookCommands, + }, + /// List stable provider capability IDs and their implementations. Capabilities { /// Output format: text (default), json, or yaml. @@ -298,6 +305,26 @@ pub enum Commands { }, } +/// Subcommands for provider-neutral e-book derivatives. +#[derive(Subcommand)] +pub enum EbookCommands { + /// Inspect EPUB 3 structure, metadata, navigation, layout, and accessibility evidence. + Inspect { + #[arg(long, value_name = "FILE")] + input: String, + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + /// Run the optional local EPUBCheck provider and include its report. + #[arg(long)] + epubcheck: bool, + }, + /// Print the honest built-in EPUB/KEPUB capability contract. + Capabilities { + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + }, +} + /// Subcommands for `renderflow spec`. #[derive(Subcommand)] pub enum SpecCommands { diff --git a/crates/renderflow-core/src/commands/ebook.rs b/crates/renderflow-core/src/commands/ebook.rs new file mode 100644 index 0000000..4a89dee --- /dev/null +++ b/crates/renderflow-core/src/commands/ebook.rs @@ -0,0 +1,35 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::ebook::{inspect_ebook, EbookCapabilityContract}; + +pub fn run_inspect(input: &str, format: &str, epubcheck: bool) -> Result<()> { + let inspection = inspect_ebook(Path::new(input), epubcheck)?; + emit(&inspection, format)?; + if !inspection.valid + || inspection.epubcheck.as_ref().and_then(|item| item.passed) == Some(false) + { + anyhow::bail!("e-book validation failed; see emitted evidence"); + } + Ok(()) +} + +pub fn run_capabilities(format: &str) -> Result<()> { + emit(&EbookCapabilityContract::builtin(), format) +} + +fn emit(value: &T, format: &str) -> Result<()> { + match format.to_ascii_lowercase().as_str() { + "json" => println!("{}", serde_json::to_string_pretty(value)?), + "yaml" | "yml" => print!("{}", serde_yaml_ng::to_string(value)?), + "text" => { + let value = serde_yaml_ng::to_string(value)?; + print!("{value}"); + } + other => { + anyhow::bail!("unknown e-book output format '{other}'; supported: text, json, yaml") + } + } + Ok(()) +} diff --git a/crates/renderflow-core/src/commands/mod.rs b/crates/renderflow-core/src/commands/mod.rs index b6fade6..601d44e 100644 --- a/crates/renderflow-core/src/commands/mod.rs +++ b/crates/renderflow-core/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod ai; pub mod audit; pub mod build; +pub mod ebook; pub mod graph; pub mod inspect; pub mod plugin; diff --git a/crates/renderflow-core/src/compat.rs b/crates/renderflow-core/src/compat.rs index 7e8e480..dfd0414 100644 --- a/crates/renderflow-core/src/compat.rs +++ b/crates/renderflow-core/src/compat.rs @@ -8,9 +8,9 @@ use crate::input_format::InputFormat; /// tested and validated. /// /// ```text -/// Markdown → html, pdf, docx -/// Rst → html, pdf, docx -/// Html → html, pdf, docx +/// Markdown → html, pdf, docx, epub, kepub +/// Rst → html, pdf, docx, epub, kepub +/// Html → html, pdf, docx, epub, kepub /// Docx → html, pdf /// Epub → html, pdf (epub → docx is not yet supported) /// Latex → html, pdf (latex → docx is not yet supported) @@ -20,26 +20,40 @@ pub fn is_supported(input: &InputFormat, output: &OutputType) -> bool { // Markdown (InputFormat::Markdown, OutputType::Html) | (InputFormat::Markdown, OutputType::Pdf) - | (InputFormat::Markdown, OutputType::Docx) => true, + | (InputFormat::Markdown, OutputType::Docx) + | (InputFormat::Markdown, OutputType::Epub) + | (InputFormat::Markdown, OutputType::Kepub) => true, // reStructuredText (InputFormat::Rst, OutputType::Html) | (InputFormat::Rst, OutputType::Pdf) - | (InputFormat::Rst, OutputType::Docx) => true, + | (InputFormat::Rst, OutputType::Docx) + | (InputFormat::Rst, OutputType::Epub) + | (InputFormat::Rst, OutputType::Kepub) => true, // HTML (pass-through or convert) (InputFormat::Html, OutputType::Html) | (InputFormat::Html, OutputType::Pdf) - | (InputFormat::Html, OutputType::Docx) => true, + | (InputFormat::Html, OutputType::Docx) + | (InputFormat::Html, OutputType::Epub) + | (InputFormat::Html, OutputType::Kepub) => true, // Docx (reading a Word document) - (InputFormat::Docx, OutputType::Html) | (InputFormat::Docx, OutputType::Pdf) => true, + (InputFormat::Docx, OutputType::Html) + | (InputFormat::Docx, OutputType::Pdf) + | (InputFormat::Docx, OutputType::Epub) + | (InputFormat::Docx, OutputType::Kepub) => true, // Epub - (InputFormat::Epub, OutputType::Html) | (InputFormat::Epub, OutputType::Pdf) => true, + (InputFormat::Epub, OutputType::Html) + | (InputFormat::Epub, OutputType::Pdf) + | (InputFormat::Epub, OutputType::Kepub) => true, // LaTeX - (InputFormat::Latex, OutputType::Html) | (InputFormat::Latex, OutputType::Pdf) => true, + (InputFormat::Latex, OutputType::Html) + | (InputFormat::Latex, OutputType::Pdf) + | (InputFormat::Latex, OutputType::Epub) + | (InputFormat::Latex, OutputType::Kepub) => true, // Everything else — including Unsupported output types — is not supported. _ => false, @@ -48,10 +62,16 @@ pub fn is_supported(input: &InputFormat, output: &OutputType) -> bool { /// Return the output types that are supported for a given input format. pub fn supported_outputs_for(input: &InputFormat) -> Vec { - [OutputType::Html, OutputType::Pdf, OutputType::Docx] - .into_iter() - .filter(|o| is_supported(input, o)) - .collect() + [ + OutputType::Html, + OutputType::Pdf, + OutputType::Docx, + OutputType::Epub, + OutputType::Kepub, + ] + .into_iter() + .filter(|o| is_supported(input, o)) + .collect() } /// Build a user-facing error message for an unsupported input → output pair. diff --git a/crates/renderflow-core/src/config.rs b/crates/renderflow-core/src/config.rs index a418775..b08d4ff 100644 --- a/crates/renderflow-core/src/config.rs +++ b/crates/renderflow-core/src/config.rs @@ -19,6 +19,8 @@ pub enum OutputType { Html, Pdf, Docx, + Epub, + Kepub, /// Any audio format, handled by FFmpeg via [`crate::audio::AudioStrategy`]. Audio(AudioFormat), /// Any image format, handled by FFmpeg via [`crate::image::ImageStrategy`]. @@ -40,6 +42,8 @@ impl<'de> Deserialize<'de> for OutputType { "html" => Ok(OutputType::Html), "pdf" => Ok(OutputType::Pdf), "docx" => Ok(OutputType::Docx), + "epub" => Ok(OutputType::Epub), + "kepub" | "kepub.epub" => Ok(OutputType::Kepub), other => { // Try audio format first, then image format, then fall back to Unsupported. if let Ok(audio_fmt) = other.parse::() { @@ -60,6 +64,8 @@ impl fmt::Display for OutputType { OutputType::Html => write!(f, "html"), OutputType::Pdf => write!(f, "pdf"), OutputType::Docx => write!(f, "docx"), + OutputType::Epub => write!(f, "epub"), + OutputType::Kepub => write!(f, "kepub"), OutputType::Audio(fmt) => write!(f, "{}", fmt), OutputType::Image(fmt) => write!(f, "{}", fmt), OutputType::Unsupported(s) => write!(f, "{}", s), @@ -74,7 +80,7 @@ impl fmt::Display for OutputType { pub fn unsupported_type_message(type_str: &str) -> String { format!( "'{}' is not a valid output type. \ - Supported document types are: html, pdf, docx. \ + Supported document types are: html, pdf, docx, epub, kepub. \ Supported audio types are: wav, aif, aiff, bwf, pcm, flac, m4a, m4a_alac, \ wv, ape, tta, mp3, aac, ogg, opus, wma, amr, mp2, ac3, ec3, dts, mid, midi. \ Supported image types are: jpeg, jpg, png, webp, avif, gif, bmp, tiff, tif, \ diff --git a/crates/renderflow-core/src/ebook.rs b/crates/renderflow-core/src/ebook.rs new file mode 100644 index 0000000..a99ac3c --- /dev/null +++ b/crates/renderflow-core/src/ebook.rs @@ -0,0 +1,524 @@ +//! Provider-neutral EPUB/KEPUB contracts, structural inspection, and EPUBCheck evidence. + +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use zip::ZipArchive; + +use crate::process::{ + ProcessExecutor, ProcessNetworkPolicy, ProcessRequest, ToolProbeStatus, + DEFAULT_CAPTURE_LIMIT_BYTES, +}; + +pub const EBOOK_EVIDENCE_SCHEMA_V1: &str = "renderflow.ebook-evidence/v1"; +const MAX_INSPECTION_MEMBER_BYTES: u64 = 8 * 1024 * 1024; +const MAX_TOTAL_XHTML_INSPECTION_BYTES: usize = 64 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EbookVariant { + Epub, + Kepub, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EbookLayout { + Reflowable, + PrePaginated, + Mixed, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EbookDiagnosticSeverity { + Info, + Warning, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EbookDiagnostic { + pub severity: EbookDiagnosticSeverity, + pub code: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EbookMetadataEvidence { + pub title: bool, + pub creator: bool, + pub language: bool, + pub identifier: bool, + pub rights: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EbookAccessibilityEvidence { + pub access_modes: usize, + pub accessibility_features: usize, + pub accessibility_hazards: usize, + pub accessibility_summary: bool, + pub conforms_to: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EpubCheckEvidence { + pub provider_id: String, + pub available: bool, + pub version: Option, + pub passed: Option, + pub duration_ms: Option, + pub report: Option, + pub diagnostic: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EbookInspection { + pub schema: String, + pub source: String, + pub source_sha256: String, + pub variant: EbookVariant, + pub valid: bool, + pub epub_version: Option, + pub package_document: Option, + pub layout: EbookLayout, + pub xhtml_documents: usize, + pub spine_items: usize, + pub navigation: bool, + pub page_list: bool, + pub metadata: EbookMetadataEvidence, + pub accessibility: EbookAccessibilityEvidence, + pub retailer_acceptance: String, + pub diagnostics: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub epubcheck: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EbookCapabilityContract { + pub schema: String, + pub epub_reflow_generation: bool, + pub epub_fixed_layout_generation: bool, + pub kepub_reflow_generation: bool, + pub kepub_fixed_layout_generation: bool, + pub structural_inspection: bool, + pub page_list_evidence: bool, + pub accessibility_evidence: bool, + pub retailer_acceptance_requires_provider_profile: bool, +} + +impl EbookCapabilityContract { + pub fn builtin() -> Self { + Self { + schema: EBOOK_EVIDENCE_SCHEMA_V1.to_string(), + epub_reflow_generation: true, + epub_fixed_layout_generation: false, + kepub_reflow_generation: true, + kepub_fixed_layout_generation: false, + structural_inspection: true, + page_list_evidence: true, + accessibility_evidence: true, + retailer_acceptance_requires_provider_profile: true, + } + } +} + +pub fn inspect_ebook(path: &Path, run_epubcheck: bool) -> Result { + let digest = sha256_file(path)?; + let file = File::open(path) + .with_context(|| format!("failed to open e-book source '{}'", path.display()))?; + let mut archive = ZipArchive::new(file) + .with_context(|| format!("'{}' is not a readable EPUB ZIP container", path.display()))?; + let mimetype_envelope = archive.by_index(0).ok().is_some_and(|entry| { + entry.name() == "mimetype" && entry.compression() == zip::CompressionMethod::Stored + }); + let names = (0..archive.len()) + .filter_map(|index| { + archive + .by_index(index) + .ok() + .map(|entry| entry.name().to_string()) + }) + .collect::>(); + let mimetype = read_member(&mut archive, "mimetype").unwrap_or_default(); + let container = read_member(&mut archive, "META-INF/container.xml").unwrap_or_default(); + let package_document = attribute_value(&container, "full-path"); + let opf = package_document + .as_deref() + .and_then(|member| read_member(&mut archive, member)); + let opf_text = opf.as_deref().unwrap_or_default(); + let epub_version = + tag_fragment(opf_text, "package").and_then(|tag| attribute_value(tag, "version")); + let xhtml_names = names + .iter() + .filter(|name| name.ends_with(".xhtml") || name.ends_with(".html")) + .cloned() + .collect::>(); + let mut xhtml_members = Vec::new(); + let mut inspected_xhtml_bytes = 0_usize; + let mut xhtml_scan_truncated = false; + for name in &xhtml_names { + let Some(member) = read_member(&mut archive, name) else { + continue; + }; + inspected_xhtml_bytes = inspected_xhtml_bytes.saturating_add(member.len()); + if inspected_xhtml_bytes > MAX_TOTAL_XHTML_INSPECTION_BYTES { + xhtml_scan_truncated = true; + break; + } + xhtml_members.push(member); + } + let xhtml_documents = xhtml_members + .iter() + .filter(|member| member.contains(" 0, + "ebook.xhtml", + "No XHTML/HTML content documents were found", + ); + require( + &mut diagnostics, + spine_items > 0, + "ebook.spine", + "The package has no ordered spine items", + ); + require( + &mut diagnostics, + navigation, + "ebook.navigation", + "No EPUB 3 navigation document was declared", + ); + if !page_list { + diagnostics.push(warning( + "ebook.page_list.absent", + "No page-list navigation was found; downstream print-page correlation is unavailable", + )); + } + if xhtml_scan_truncated { + diagnostics.push(warning( + "ebook.xhtml.scan_bounded", + "XHTML marker inspection stopped at the 64 MiB safety bound", + )); + } + if accessibility.access_modes == 0 + && accessibility.accessibility_features == 0 + && !accessibility.accessibility_summary + { + diagnostics.push(warning( + "ebook.accessibility.sparse", + "No accessibility metadata was found in the package document", + )); + } + if variant == EbookVariant::Kepub && !kepub_marker { + diagnostics.push(warning( + "ebook.kepub.marker", + "The filename implies KEPUB, but no Kepubify content marker was found", + )); + } + if layout == EbookLayout::PrePaginated { + diagnostics.push(info( + "ebook.fixed_layout", + "Fixed-layout is declared; retailer/channel acceptance requires a provider profile", + )); + } + let valid = !diagnostics + .iter() + .any(|item| item.severity == EbookDiagnosticSeverity::Error); + let epubcheck = run_epubcheck + .then(|| run_epubcheck_provider(path)) + .transpose()?; + Ok(EbookInspection { + schema: EBOOK_EVIDENCE_SCHEMA_V1.to_string(), + source: path.display().to_string(), + source_sha256: digest, + variant, + valid, + epub_version, + package_document, + layout, + xhtml_documents, + spine_items, + navigation, + page_list, + metadata, + accessibility, + retailer_acceptance: "requires_provider_profile".to_string(), + diagnostics, + epubcheck, + }) +} + +pub fn run_epubcheck_provider(path: &Path) -> Result { + let executor = ProcessExecutor::new(); + let probe = executor.probe_version("epubcheck"); + if probe.status != ToolProbeStatus::Available { + return Ok(EpubCheckEvidence { + provider_id: "tool.epubcheck".to_string(), + available: false, + version: probe.version_line, + passed: None, + duration_ms: Some(probe.duration_ms), + report: None, + diagnostic: probe.diagnostic, + }); + } + let directory = tempfile::tempdir().context("failed to create EPUBCheck evidence directory")?; + let report_path = directory.path().join("epubcheck.json"); + let result = executor.execute( + ProcessRequest::direct("epubcheck") + .arg(path.to_string_lossy().into_owned()) + .arg("--json") + .arg(report_path.to_string_lossy().into_owned()) + .timeout(Duration::from_secs(5 * 60)) + .capture_limit(DEFAULT_CAPTURE_LIMIT_BYTES) + .network_policy(ProcessNetworkPolicy::Deny), + )?; + let report = std::fs::read(&report_path) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()); + let diagnostic = (!result.is_success()).then(|| { + let stderr = result.stderr().redacted_text().trim(); + if stderr.is_empty() { + "EPUBCheck reported conformance errors".to_string() + } else { + stderr.to_string() + } + }); + Ok(EpubCheckEvidence { + provider_id: "tool.epubcheck".to_string(), + available: true, + version: probe.version_line, + passed: Some(result.is_success()), + duration_ms: Some(result.duration_ms()), + report, + diagnostic, + }) +} + +fn read_member( + archive: &mut ZipArchive, + name: &str, +) -> Option { + let mut member = archive.by_name(name).ok()?; + if member.size() > MAX_INSPECTION_MEMBER_BYTES { + return None; + } + let mut text = String::new(); + member.read_to_string(&mut text).ok()?; + Some(text) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path) + .with_context(|| format!("failed to read e-book source '{}'", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn attribute_value(text: &str, name: &str) -> Option { + for quote in ['"', '\''] { + let needle = format!("{name}={quote}"); + if let Some(start) = text.find(&needle) { + let value = &text[start + needle.len()..]; + if let Some(end) = value.find(quote) { + return Some(value[..end].to_string()); + } + } + } + None +} + +fn manifest_href_with_property(opf: &str, property: &str) -> Option { + opf.split('<').find_map(|fragment| { + if !fragment.starts_with("item ") { + return None; + } + let properties = attribute_value(fragment, "properties")?; + properties + .split_ascii_whitespace() + .any(|candidate| candidate == property) + .then(|| attribute_value(fragment, "href")) + .flatten() + }) +} + +fn tag_fragment<'a>(text: &'a str, tag: &str) -> Option<&'a str> { + text.split('<').find(|fragment| { + fragment.starts_with(tag) + && fragment[tag.len()..] + .chars() + .next() + .is_some_and(|character| character.is_ascii_whitespace() || character == '>') + }) +} + +fn resolve_member_path(package: &str, href: &str) -> String { + let mut path = PathBuf::from(package); + path.pop(); + path.push(href.split('#').next().unwrap_or(href)); + path.components() + .filter_map(|component| match component { + std::path::Component::Normal(value) => Some(value.to_string_lossy()), + _ => None, + }) + .collect::>() + .join("/") +} + +fn inspect_layout(opf: &str) -> EbookLayout { + let fixed = contains_token(opf, "pre-paginated"); + let reflow = contains_token(opf, "reflowable"); + match (fixed, reflow) { + (true, true) => EbookLayout::Mixed, + (true, false) => EbookLayout::PrePaginated, + (false, true) => EbookLayout::Reflowable, + (false, false) if !opf.is_empty() => EbookLayout::Reflowable, + _ => EbookLayout::Unknown, + } +} + +fn contains_element(text: &str, element: &str) -> bool { + text.contains(&format!("<{element}")) +} + +fn contains_token(text: &str, token: &str) -> bool { + text.contains(token) +} + +fn count_token(text: &str, token: &str) -> usize { + text.match_indices(token).count() +} + +fn require(diagnostics: &mut Vec, condition: bool, code: &str, message: &str) { + if !condition { + diagnostics.push(EbookDiagnostic { + severity: EbookDiagnosticSeverity::Error, + code: code.to_string(), + message: message.to_string(), + }); + } +} + +fn warning(code: &str, message: &str) -> EbookDiagnostic { + EbookDiagnostic { + severity: EbookDiagnosticSeverity::Warning, + code: code.to_string(), + message: message.to_string(), + } +} + +fn info(code: &str, message: &str) -> EbookDiagnostic { + EbookDiagnostic { + severity: EbookDiagnosticSeverity::Info, + code: code.to_string(), + message: message.to_string(), + } +} diff --git a/crates/renderflow-core/src/graph/capability.rs b/crates/renderflow-core/src/graph/capability.rs index 778eae9..cbda217 100644 --- a/crates/renderflow-core/src/graph/capability.rs +++ b/crates/renderflow-core/src/graph/capability.rs @@ -167,6 +167,8 @@ pub enum ExternalTool { Tectonic, /// FFmpeg multimedia framework (audio, video, image conversion). Ffmpeg, + /// Kepubify EPUB-to-KEPUB converter. + Kepubify, } impl ExternalTool { @@ -176,6 +178,7 @@ impl ExternalTool { Self::Pandoc => "pandoc", Self::Tectonic => "tectonic", Self::Ffmpeg => "ffmpeg", + Self::Kepubify => "kepubify", } } @@ -190,6 +193,7 @@ impl ExternalTool { Self::Pandoc => "tool.pandoc", Self::Tectonic => "tool.tectonic", Self::Ffmpeg => "tool.ffmpeg", + Self::Kepubify => "tool.kepubify", } } } @@ -414,7 +418,7 @@ impl FormatCapabilityRegistry { extensions: &["epub"], media_types: &["application/epub+zip"], families: &[Document, Archive], - capabilities: vec![Detect, Inspect, Extract, Convert], + capabilities: vec![Detect, Inspect, Extract, Convert, Generate], // EPUB is a ZIP file — matches ZIP magic bytes. magic_signatures: vec![MagicSignature::at_start(b"PK\x03\x04")], loss_profile: PartialLoss, @@ -422,6 +426,21 @@ impl FormatCapabilityRegistry { }, ); + self.register( + Format::Kepub, + FormatDescriptor { + id: "kepub", + name: "Kobo Enhanced Electronic Publication", + extensions: &["kepub", "kepub.epub"], + media_types: &["application/epub+zip"], + families: &[Document, Archive], + capabilities: vec![Detect, Inspect, Extract, Convert, Generate], + magic_signatures: vec![MagicSignature::at_start(b"PK\x03\x04")], + loss_profile: PartialLoss, + external_requirements: vec![ExternalTool::Kepubify], + }, + ); + self.register( Format::Rst, FormatDescriptor { diff --git a/crates/renderflow-core/src/graph/format.rs b/crates/renderflow-core/src/graph/format.rs index 6e79042..e7a3de8 100644 --- a/crates/renderflow-core/src/graph/format.rs +++ b/crates/renderflow-core/src/graph/format.rs @@ -21,6 +21,8 @@ pub enum Format { Pdf, Docx, Epub, + /// Kobo-enhanced EPUB derivative (`.kepub.epub`). + Kepub, Rst, Latex, /// Fountain screenplay plain-text format. @@ -155,6 +157,7 @@ impl fmt::Display for Format { Format::Pdf => "pdf", Format::Docx => "docx", Format::Epub => "epub", + Format::Kepub => "kepub", Format::Rst => "rst", Format::Latex => "latex", Format::Fountain => "fountain", @@ -241,6 +244,7 @@ impl FromStr for Format { "pdf" => Ok(Format::Pdf), "docx" => Ok(Format::Docx), "epub" => Ok(Format::Epub), + "kepub" | "kepub.epub" => Ok(Format::Kepub), "rst" => Ok(Format::Rst), "latex" | "tex" => Ok(Format::Latex), "fountain" => Ok(Format::Fountain), @@ -306,7 +310,7 @@ impl FromStr for Format { "vtt" | "webvtt" => Ok(Format::WebVtt), _ => anyhow::bail!( "'{}' is not a known format. \ - Documents: markdown, html, pdf, docx, epub, rst, latex, fountain. \ + Documents: markdown, html, pdf, docx, epub, kepub, rst, latex, fountain. \ Images: jpeg, png, tiff, webp, gif, bmp, avif, svg, cbz. \ Audio: wav, aiff, bwf, pcm, flac, m4a_alac, wv, ape, tta, dsf, dff, shn, \ mp3, m4a, aac, ogg, opus, wma, amr, mp2, ra, oma, \ diff --git a/crates/renderflow-core/src/intake.rs b/crates/renderflow-core/src/intake.rs index a91f9cf..6f4c235 100644 --- a/crates/renderflow-core/src/intake.rs +++ b/crates/renderflow-core/src/intake.rs @@ -1114,7 +1114,7 @@ fn resolve_child_format(name: &str, bytes: &[u8]) -> Option { .filter(|format| { matches!( format, - Format::Zip | Format::Docx | Format::Epub | Format::Cbz + Format::Zip | Format::Docx | Format::Epub | Format::Kepub | Format::Cbz ) }) .or(Some(Format::Zip)); @@ -1123,8 +1123,10 @@ fn resolve_child_format(name: &str, bytes: &[u8]) -> Option { } fn is_zip_artifact(artifact: &Artifact, bytes: &[u8]) -> bool { - matches!(artifact.format().as_str(), "zip" | "epub" | "docx" | "cbz") - || bytes.starts_with(b"PK\x03\x04") + matches!( + artifact.format().as_str(), + "zip" | "epub" | "kepub" | "docx" | "cbz" + ) || bytes.starts_with(b"PK\x03\x04") } fn safe_archive_path(path: &Path) -> bool { diff --git a/crates/renderflow-core/src/lib.rs b/crates/renderflow-core/src/lib.rs index 85487c1..49c0196 100644 --- a/crates/renderflow-core/src/lib.rs +++ b/crates/renderflow-core/src/lib.rs @@ -18,6 +18,7 @@ mod commands; mod compat; mod config; pub mod detect; +pub mod ebook; pub mod error; pub mod evidence; pub mod graph; diff --git a/crates/renderflow-core/src/planning.rs b/crates/renderflow-core/src/planning.rs index 511c642..81f3120 100644 --- a/crates/renderflow-core/src/planning.rs +++ b/crates/renderflow-core/src/planning.rs @@ -38,7 +38,7 @@ use crate::spec::{ }; use crate::super_resolution::{select_upscayl_variants, UpscaylModelCatalog}; use crate::toolchain::{ - transform_capability_id, ToolDeterminism, ToolId, ToolLocality, ToolRegistry, + transform_capability_id, CapabilityId, ToolDeterminism, ToolId, ToolLocality, ToolRegistry, ToolRuntimeContext, ToolchainSnapshot, }; use crate::transforms::yaml_loader::build_graph_executor_and_tools_from_yaml; @@ -1585,10 +1585,11 @@ fn register_builtin_strategy_edges( Format::Html, Format::Docx, Format::Epub, + Format::Kepub, Format::Rst, Format::Latex, ]; - let document_outputs = [Format::Html, Format::Pdf, Format::Docx]; + let document_outputs = [Format::Html, Format::Pdf, Format::Docx, Format::Epub]; for from in document_inputs { for to in document_outputs { if from == to { @@ -1609,6 +1610,16 @@ fn register_builtin_strategy_edges( graph.add_transform(edge); } } + let kepub_capability = CapabilityId::new("ebook.convert.kepub")?; + let kepubify = ToolId::new("tool.kepubify")?; + tools.add_capability(&kepubify, kepub_capability.clone())?; + graph.add_transform( + TransformEdge::new(Format::Epub, Format::Kepub, 0.25, 0.98) + .with_provider(kepubify.to_string(), kepub_capability.to_string()) + .with_evidence("adapter", BUILTIN_ADAPTER_EVIDENCE) + .with_evidence("family", "ebooks") + .with_evidence("transform_id", "builtin.epub.kepub"), + ); let image_formats = [ Format::Jpeg, @@ -2164,7 +2175,17 @@ fn register_builtin_strategy_executors( if edge.evidence.get("adapter").map(String::as_str) != Some(BUILTIN_ADAPTER_EVIDENCE) { continue; } - let target = targets.iter().find(|target| target.format == edge.to); + let target = targets + .iter() + .find(|target| target.format == edge.to) + .or_else(|| { + (edge.to == Format::Epub + && dag.all_edges().iter().any(|candidate| { + candidate.from == Format::Epub && candidate.to == Format::Kepub + })) + .then(|| targets.iter().find(|target| target.format == Format::Kepub)) + .flatten() + }); let template = target.and_then(|target| target.template.clone()); let profile = target.and_then(|target| target.preset.clone()); let asset_root = if edge.from == source_format && document_input_format(edge.from).is_some() @@ -2286,7 +2307,10 @@ fn render_output_paths(resolved: &ResolvedExecution) -> Result> { let mut seen = HashMap::::new(); for target in &resolved.targets { let relative = if resolved.source_version == SourceSpecVersion::V1 { - PathBuf::from(format!("{source_stem}.{}", target.format)) + PathBuf::from(format!( + "{source_stem}.{}", + artifact_extension(target.format) + )) } else { let format_string = target.format.to_string(); let target_role = target.role.as_deref().unwrap_or(format_string.as_str()); @@ -2302,7 +2326,7 @@ fn render_output_paths(resolved: &ResolvedExecution) -> Result> { rendered = rendered.replace("{target.id}", target_id); rendered = rendered.replace("{target.role}", target_role); rendered = rendered.replace("{target.format}", &format_string); - rendered = rendered.replace("{ext}", &format_string); + rendered = rendered.replace("{ext}", &artifact_extension(target.format)); let path = PathBuf::from(rendered); validate_relative_output_path(&path)?; path @@ -2327,6 +2351,13 @@ fn render_output_paths(resolved: &ResolvedExecution) -> Result> { Ok(paths) } +fn artifact_extension(format: Format) -> String { + match format { + Format::Kepub => "kepub.epub".to_string(), + _ => format.to_string(), + } +} + fn validate_relative_output_path(path: &Path) -> Result<()> { if path.is_absolute() || path.components().any(|component| { diff --git a/crates/renderflow-core/src/strategies/ebook.rs b/crates/renderflow-core/src/strategies/ebook.rs new file mode 100644 index 0000000..e9d9f7e --- /dev/null +++ b/crates/renderflow-core/src/strategies/ebook.rs @@ -0,0 +1,157 @@ +use std::collections::HashMap; +use std::path::Path; + +use anyhow::{Context, Result}; +use tracing::info; + +use crate::process::{ + ProcessExecutor, ProcessExpectedOutput, ProcessNetworkPolicy, ProcessRequest, + DEFAULT_CAPTURE_LIMIT_BYTES, DEFAULT_PROCESS_TIMEOUT, +}; +use crate::strategies::{OutputStrategy, RenderContext}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EbookOutput { + Epub, + Kepub, +} + +/// Provider-backed e-book output strategy. +/// +/// EPUB is generated as EPUB 3 through Pandoc. KEPUB is an explicit second +/// stage from an EPUB input through Kepubify; the planner never hides that +/// provider substitution. +pub struct EbookStrategy { + output: EbookOutput, + metadata_file: Option, + template_dir: String, +} + +impl EbookStrategy { + pub fn epub(metadata_file: Option, template_dir: String) -> Self { + Self { + output: EbookOutput::Epub, + metadata_file, + template_dir, + } + } + + pub fn kepub() -> Self { + Self { + output: EbookOutput::Kepub, + metadata_file: None, + template_dir: String::new(), + } + } + + fn metadata_path(&self) -> Result> { + let Some(name) = &self.metadata_file else { + return Ok(None); + }; + let path = Path::new(&self.template_dir).join(name); + if !path.is_file() { + anyhow::bail!( + "EPUB metadata file not found: '{}'. Supply an OPF metadata fragment through the output template field.", + path.display() + ); + } + Ok(Some(path.to_string_lossy().into_owned())) + } + + fn epub_args(&self, ctx: &RenderContext) -> Result> { + if ctx + .variables + .get("epub.layout") + .is_some_and(|value| value.eq_ignore_ascii_case("fixed")) + { + anyhow::bail!( + "the Pandoc EPUB adapter only advertises reflow generation; fixed-layout EPUB must be supplied by a dedicated provider and can still be inspected and validated" + ); + } + let mut args = vec![ + "--from".to_string(), + ctx.input_format.as_pandoc_format().to_string(), + "--to".to_string(), + "epub3".to_string(), + "--table-of-contents".to_string(), + ctx.input_path.to_string(), + "--output".to_string(), + ctx.output_path.to_string(), + ]; + if let Some(path) = self.metadata_path()? { + args.push("--epub-metadata".to_string()); + args.push(path); + } + append_metadata(&mut args, ctx.variables); + Ok(args) + } +} + +impl OutputStrategy for EbookStrategy { + fn render(&self, ctx: &RenderContext) -> Result<()> { + if ctx.dry_run { + info!(input = %ctx.input_path, output = %ctx.output_path, "[dry-run] Would render e-book derivative"); + return Ok(()); + } + let (program, args) = match self.output { + EbookOutput::Epub => ("pandoc", self.epub_args(ctx)?), + EbookOutput::Kepub => ( + "kepubify", + vec![ + "--output".to_string(), + ctx.output_path.to_string(), + ctx.input_path.to_string(), + ], + ), + }; + let request = ProcessRequest::direct(program) + .args(args) + .timeout(DEFAULT_PROCESS_TIMEOUT) + .capture_limit(DEFAULT_CAPTURE_LIMIT_BYTES) + .network_policy(ProcessNetworkPolicy::Deny) + .expect_output( + ProcessExpectedOutput::file(ctx.output_path) + .require_non_empty() + .require_change(), + ); + ProcessExecutor::new() + .execute_checked(request) + .with_context(|| { + format!( + "{} failed to produce e-book output '{}' from '{}'", + program, ctx.output_path, ctx.input_path + ) + })?; + Ok(()) + } +} + +fn append_metadata(args: &mut Vec, variables: &HashMap) { + const FIELDS: &[(&str, &str)] = &[ + ("title", "title"), + ("subtitle", "subtitle"), + ("author", "author"), + ("creator", "author"), + ("language", "lang"), + ("lang", "lang"), + ("identifier", "identifier"), + ("date", "date"), + ("rights", "rights"), + ("description", "description"), + ]; + let mut metadata = FIELDS + .iter() + .filter_map(|(source, target)| { + variables + .get(*source) + .or_else(|| variables.get(&format!("epub.{source}"))) + .map(|value| ((*target).to_string(), value.clone())) + }) + .collect::>(); + metadata.sort(); + metadata.dedup(); + for (key, value) in metadata { + args.push("--metadata".to_string()); + args.push(format!("{key}={value}")); + } +} diff --git a/crates/renderflow-core/src/strategies/mod.rs b/crates/renderflow-core/src/strategies/mod.rs index 7105804..2b9fb46 100644 --- a/crates/renderflow-core/src/strategies/mod.rs +++ b/crates/renderflow-core/src/strategies/mod.rs @@ -1,4 +1,5 @@ pub mod docx; +pub mod ebook; pub mod html; pub mod pandoc_args; pub mod pdf; @@ -7,6 +8,7 @@ pub mod strategy; #[allow(unused_imports)] pub use docx::DocxStrategy; +pub use ebook::EbookStrategy; #[allow(unused_imports)] pub use html::HtmlStrategy; #[allow(unused_imports)] diff --git a/crates/renderflow-core/src/strategies/selector.rs b/crates/renderflow-core/src/strategies/selector.rs index 2f9b16d..1cf745a 100644 --- a/crates/renderflow-core/src/strategies/selector.rs +++ b/crates/renderflow-core/src/strategies/selector.rs @@ -3,7 +3,7 @@ use anyhow::Result; use crate::audio::AudioStrategy; use crate::config::{unsupported_type_message, OutputType}; use crate::image::ImageStrategy; -use crate::strategies::{DocxStrategy, HtmlStrategy, OutputStrategy, PdfStrategy}; +use crate::strategies::{DocxStrategy, EbookStrategy, HtmlStrategy, OutputStrategy, PdfStrategy}; /// Select an output strategy based on the given output type. /// @@ -32,6 +32,11 @@ pub fn select_strategy( template.map(str::to_owned), template_dir.to_owned(), ))), + OutputType::Epub => Ok(Box::new(EbookStrategy::epub( + template.map(str::to_owned), + template_dir.to_owned(), + ))), + OutputType::Kepub => Ok(Box::new(EbookStrategy::kepub())), OutputType::Audio(fmt) => Ok(Box::new(AudioStrategy::new( *fmt, profile.map(str::to_owned), diff --git a/crates/renderflow-core/src/validation.rs b/crates/renderflow-core/src/validation.rs index 539bae3..706917f 100644 --- a/crates/renderflow-core/src/validation.rs +++ b/crates/renderflow-core/src/validation.rs @@ -120,6 +120,7 @@ impl ValidationRegistry { registry.register(Arc::new(PngValidator)); registry.register(Arc::new(JpegValidator)); registry.register(Arc::new(ZipValidator)); + registry.register(Arc::new(EpubValidator)); registry.register(Arc::new(RiffWaveValidator)); registry.register(Arc::new(DeclaredSignatureValidator)); registry @@ -240,7 +241,8 @@ impl ValidationRegistry { Format::Pdf => Some("validator.core.pdf"), Format::Png => Some("validator.core.png"), Format::Jpeg => Some("validator.core.jpeg"), - Format::Zip | Format::Docx | Format::Epub | Format::Cbz => Some("validator.core.zip"), + Format::Epub | Format::Kepub => Some("validator.core.epub"), + Format::Zip | Format::Docx | Format::Cbz => Some("validator.core.zip"), Format::Wav | Format::Bwf => Some("validator.core.riff_wave"), Format::Markdown | Format::Rst @@ -574,7 +576,7 @@ impl ArtifactValidator for ZipValidator { fn descriptor(&self) -> ValidatorDescriptor { descriptor( "validator.core.zip", - &[Format::Zip, Format::Docx, Format::Epub, Format::Cbz], + &[Format::Zip, Format::Docx, Format::Cbz], ValidatorSupportTier::Experimental, ) } @@ -598,6 +600,46 @@ impl ArtifactValidator for ZipValidator { } } +struct EpubValidator; + +impl ArtifactValidator for EpubValidator { + fn descriptor(&self) -> ValidatorDescriptor { + descriptor( + "validator.core.epub", + &[Format::Epub, Format::Kepub], + ValidatorSupportTier::Experimental, + ) + } + + fn validate(&self, artifact: &Artifact, store: &ArtifactStore) -> Result { + store.verify(artifact)?; + let path = store.payload_path(artifact)?; + let inspection = crate::ebook::inspect_ebook(&path, false)?; + let diagnostics = inspection + .diagnostics + .into_iter() + .map(|diagnostic| ValidationDiagnostic { + code: diagnostic.code, + message: diagnostic.message, + }) + .collect::>(); + if !inspection.valid { + return Ok(ValidationCheck { + state: ValidationState::Invalid, + diagnostics, + }); + } + Ok(ValidationCheck { + state: if diagnostics.is_empty() { + ValidationState::Valid + } else { + ValidationState::ValidWithWarnings + }, + diagnostics, + }) + } +} + struct RiffWaveValidator; impl ArtifactValidator for RiffWaveValidator { @@ -779,10 +821,9 @@ fn conformance_row( .map(|tool| tool.stable_id().to_string()) .collect(), validator_ids, - fixture_ids: if descriptor.id == "png" { - vec!["fixture.corrupt.png.truncated".to_string()] - } else { - Vec::new() + fixture_ids: match descriptor.id { + "png" => vec!["fixture.corrupt.png.truncated".to_string()], + _ => Vec::new(), }, supported_platforms: vec![ "linux".to_string(), diff --git a/docs/architecture/graph-engine.md b/docs/architecture/graph-engine.md index 75c1e31..32fd539 100644 --- a/docs/architecture/graph-engine.md +++ b/docs/architecture/graph-engine.md @@ -16,7 +16,7 @@ The graph engine lives in `src/graph/` and models conversions as a directed grap `Format` includes: -- document formats: `markdown`, `html`, `pdf`, `docx`, `epub`, `rst`, `latex`, `fountain` +- document formats: `markdown`, `html`, `pdf`, `docx`, `epub`, `kepub`, `rst`, `latex`, `fountain` - image formats such as `jpeg`, `png`, `tiff`, `cbz` - many audio formats such as `wav`, `flac`, `mp3`, `ogg`, `opus`, `ac3`, `dts`, `midi` diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index bd1400a..0c7a1a9 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -137,8 +137,8 @@ Each output item maps to the v1 `OutputConfig` compatibility model. | Key | Required | Notes | |---|---|---| -| `type` | Yes | `html`, `pdf`, `docx`, supported audio formats, or supported image formats | -| `template` | No | Template name looked up in `templates/` | +| `type` | Yes | `html`, `pdf`, `docx`, `epub`, `kepub`, supported audio formats, or supported image formats | +| `template` | No | Template/reference file looked up in `templates/`; for EPUB this is an OPF metadata fragment | | `profile` | No | Audio quality profile for audio outputs only | ## V1 validation rules @@ -180,6 +180,8 @@ Document outputs are first-class: - `html` - `pdf` - `docx` +- `epub` +- `kepub` Audio and image outputs are broader. Representative examples include: diff --git a/docs/user-guide/ebook-derivatives.md b/docs/user-guide/ebook-derivatives.md new file mode 100644 index 0000000..b947b79 --- /dev/null +++ b/docs/user-guide/ebook-derivatives.md @@ -0,0 +1,82 @@ +# 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. + +## Generate derivatives + +Legacy configuration accepts both document outputs: + +```yaml +input: manuscript.md +variables: + title: Example publication + author: Example Author + language: en + identifier: urn:uuid:replace-me + rights: Copyright holder and license statement +outputs: + - type: epub + - 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. + +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`. +The `title`, `subtitle`, `author`/`creator`, `language`/`lang`, `identifier`, +`date`, `rights`, and `description` variables are also mapped to internal EPUB +metadata. Canonical source metadata remains preferable. + +## Inspect and validate + +```bash +renderflow ebook inspect --input dist/book.epub --format json +renderflow ebook inspect --input dist/book.epub --format json --epubcheck +renderflow ebook capabilities --format yaml +``` + +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, +and KEPUB markers. Evidence is versioned as `renderflow.ebook-evidence/v1` and +includes the immutable source SHA-256 digest. + +When requested, the optional EPUBCheck provider runs locally through +Renderflow's bounded process service and embeds its JSON report. Missing +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. + +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. + +Retailer acceptance is deliberately outside this generic format capability. +Every inspection reports `requires_provider_profile`; Lulu, Kobo, or another +channel pack must evaluate its own current restrictions before calling an +artifact upload-ready. + +## Provider decision + +Kepubify is adopted as the focused KEPUB provider: it is standalone, +cross-platform, local, and MIT-licensed. Calibre remains deferred as a broader +fallback because its larger conversion surface and output variation need a +separate adapter and conformance case. EPUBCheck is adopted as the authoritative +external conformance provider alongside Renderflow's deterministic native +structural inspection. + +## References + +- [EPUB 3.3](https://www.w3.org/TR/epub-33/) +- [EPUBCheck](https://github.com/w3c/epubcheck) +- [Kepubify documentation](https://pgaskin.net/kepubify/docs/) diff --git a/docs/user-guide/supported-formats.md b/docs/user-guide/supported-formats.md index 34c052b..d167fc7 100644 --- a/docs/user-guide/supported-formats.md +++ b/docs/user-guide/supported-formats.md @@ -39,6 +39,7 @@ These are the canonical node names used in transform YAML files and graph output | `pdf` | | `docx` | | `epub` | +| `kepub` | | `rst` | | `latex` | | `fountain` | diff --git a/docs/user-guide/tool-registry.md b/docs/user-guide/tool-registry.md index 41a610f..57de46c 100644 --- a/docs/user-guide/tool-registry.md +++ b/docs/user-guide/tool-registry.md @@ -17,11 +17,13 @@ without editing this built-in catalog. | Provider ID | Name | Discovery | Tier | Determinism | Locality | | --- | --- | --- | --- | --- | --- | +| `tool.epubcheck` | EPUBCheck | executable: `epubcheck` | optional | deterministic | local | | `tool.ffmpeg` | FFmpeg | executable: `ffmpeg` | optional | configuration_dependent | local | | `tool.ghostscript` | Ghostscript | executable: `gs` | experimental | configuration_dependent | local | | `tool.imagemagick` | ImageMagick | executable: `magick` | experimental | configuration_dependent | local | | `tool.img2pdf` | img2pdf | executable: `img2pdf` | experimental | deterministic | local | | `tool.jq` | jq | executable: `jq` | experimental | deterministic | local | +| `tool.kepubify` | Kepubify | executable: `kepubify` | optional | configuration_dependent | local | | `tool.pandoc` | Pandoc | executable: `pandoc` | required | configuration_dependent | local | | `tool.tectonic` | Tectonic | executable: `tectonic` | optional | configuration_dependent | network_optional | | `tool.tesseract` | Tesseract OCR | executable: `tesseract` | experimental | configuration_dependent | local | @@ -33,6 +35,7 @@ without editing this built-in catalog. | Capability ID | Provider ID | | --- | --- | +| `ebook.validate.epub` | `tool.epubcheck` | | `audio.convert` | `tool.ffmpeg` | | `image.convert` | `tool.ffmpeg` | | `media.convert` | `tool.ffmpeg` | @@ -42,6 +45,7 @@ without editing this built-in catalog. | `image.convert` | `tool.imagemagick` | | `image.aggregate.pdf` | `tool.img2pdf` | | `data.json.transform` | `tool.jq` | +| `ebook.convert.kepub` | `tool.kepubify` | | `document.convert` | `tool.pandoc` | | `document.generate` | `tool.pandoc` | | `latex.compile` | `tool.tectonic` | diff --git a/mkdocs.yml b/mkdocs.yml index 1f1b8d1..64501c8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,6 +70,7 @@ nav: - Supported Formats: user-guide/supported-formats.md - Tool Registry: user-guide/tool-registry.md - Adapter Ecosystem: user-guide/adapter-ecosystem.md + - EPUB and KEPUB Derivatives: user-guide/ebook-derivatives.md - Super Resolution: user-guide/super-resolution.md - Upscayl Models: user-guide/upscayl-models.md - Pipelines: user-guide/pipelines.md diff --git a/schemas/renderflow-ebook-evidence-v1.schema.json b/schemas/renderflow-ebook-evidence-v1.schema.json new file mode 100644 index 0000000..bfdaa2d --- /dev/null +++ b/schemas/renderflow-ebook-evidence-v1.schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://renderflow.dev/schemas/renderflow-ebook-evidence-v1.schema.json", + "title": "Renderflow e-book inspection evidence v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "source", + "source_sha256", + "variant", + "valid", + "layout", + "xhtml_documents", + "spine_items", + "navigation", + "page_list", + "metadata", + "accessibility", + "retailer_acceptance", + "diagnostics" + ], + "properties": { + "schema": { "const": "renderflow.ebook-evidence/v1" }, + "source": { "type": "string" }, + "source_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "variant": { "enum": ["epub", "kepub"] }, + "valid": { "type": "boolean" }, + "epub_version": { "type": ["string", "null"] }, + "package_document": { "type": ["string", "null"] }, + "layout": { "enum": ["reflowable", "pre_paginated", "mixed", "unknown"] }, + "xhtml_documents": { "type": "integer", "minimum": 0 }, + "spine_items": { "type": "integer", "minimum": 0 }, + "navigation": { "type": "boolean" }, + "page_list": { "type": "boolean" }, + "metadata": { + "type": "object", + "additionalProperties": false, + "required": ["title", "creator", "language", "identifier", "rights"], + "properties": { + "title": { "type": "boolean" }, + "creator": { "type": "boolean" }, + "language": { "type": "boolean" }, + "identifier": { "type": "boolean" }, + "rights": { "type": "boolean" } + } + }, + "accessibility": { + "type": "object", + "additionalProperties": false, + "required": ["access_modes", "accessibility_features", "accessibility_hazards", "accessibility_summary", "conforms_to"], + "properties": { + "access_modes": { "type": "integer", "minimum": 0 }, + "accessibility_features": { "type": "integer", "minimum": 0 }, + "accessibility_hazards": { "type": "integer", "minimum": 0 }, + "accessibility_summary": { "type": "boolean" }, + "conforms_to": { "type": "boolean" } + } + }, + "retailer_acceptance": { "const": "requires_provider_profile" }, + "diagnostics": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["severity", "code", "message"], + "properties": { + "severity": { "enum": ["info", "warning", "error"] }, + "code": { "type": "string" }, + "message": { "type": "string" } + } + } + }, + "epubcheck": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["provider_id", "available"], + "properties": { + "provider_id": { "const": "tool.epubcheck" }, + "available": { "type": "boolean" }, + "version": { "type": ["string", "null"] }, + "passed": { "type": ["boolean", "null"] }, + "duration_ms": { "type": ["integer", "null"], "minimum": 0 }, + "report": {}, + "diagnostic": { "type": ["string", "null"] } + } + } + } +}