From bb34bd2765586c02c77cc5f5828bb71c4c408385 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Fri, 11 Sep 2026 12:52:45 -0400 Subject: [PATCH] feat: add Lulu publication adapter pack --- .../renderflow-core/data/adapter-packs.yaml | 35 + .../publication/lulu-rules-2026-09-11.yaml | 72 + .../renderflow-core/data/tool-registry.yaml | 24 + .../renderflow-core/src/adapters/catalog.rs | 1 + crates/renderflow-core/src/app.rs | 22 +- crates/renderflow-core/src/cli.rs | 39 + crates/renderflow-core/src/commands/mod.rs | 1 + .../src/commands/publication.rs | 84 ++ crates/renderflow-core/src/publication.rs | 2 + .../renderflow-core/src/publication/lulu.rs | 1180 +++++++++++++++++ docs/cli-reference/index.md | 1 + docs/cli-reference/publication.md | 32 + docs/user-guide/lulu-publication-pack.md | 76 ++ .../lulu/44-page-color-amazon-failure.yaml | 21 + mkdocs.yml | 2 + .../renderflow-adapter-catalog-v1.schema.json | 3 +- ...renderflow-lulu-conformance-v1.schema.json | 22 + .../renderflow-lulu-request-v1.schema.json | 63 + 18 files changed, 1677 insertions(+), 3 deletions(-) create mode 100644 crates/renderflow-core/data/publication/lulu-rules-2026-09-11.yaml create mode 100644 crates/renderflow-core/src/commands/publication.rs create mode 100644 crates/renderflow-core/src/publication/lulu.rs create mode 100644 docs/cli-reference/publication.md create mode 100644 docs/user-guide/lulu-publication-pack.md create mode 100644 examples/lulu/44-page-color-amazon-failure.yaml create mode 100644 schemas/renderflow-lulu-conformance-v1.schema.json create mode 100644 schemas/renderflow-lulu-request-v1.schema.json diff --git a/crates/renderflow-core/data/adapter-packs.yaml b/crates/renderflow-core/data/adapter-packs.yaml index 2594c3b..608c3c8 100644 --- a/crates/renderflow-core/data/adapter-packs.yaml +++ b/crates/renderflow-core/data/adapter-packs.yaml @@ -1,6 +1,41 @@ schema: renderflow.adapter-catalog/v1 providers: + - id: adapter.publication.lulu + runtime_tool: tool.lulu-rules + name: Lulu publication candidate adapter + families: [publication, pdf, ebooks] + maturity: integrated + selection_priority: 10 + capabilities: + - publication.lulu.print-direct.preflight + - publication.lulu.bookstore.preflight + - publication.lulu.global-distribution.preflight + - publication.lulu.pdf-ebook.preflight + - publication.lulu.epub-distribution.preflight + input_media_types: [application/pdf, application/epub+zip, application/vnd.renderflow.publication+json] + output_media_types: [application/vnd.renderflow.lulu-conformance+json] + determinism: deterministic + locality: local + fidelity: lossless + execution: + service: renderflow.process/v1 + direct_argv: true + bounded_output: true + timeout: true + cancellation: true + network: false + side_effects: [reads_declared_candidates, writes_declared_report] + configuration_schema: + request: renderflow.lulu-request/v1 + rules: pinned_bundled_rule_pack + cover_templates: required_user_supplied_paths + attestations: explicit_reviewed_boolean_map + validation: [validator.core.pdf, validator.core.epub, validator.epubcheck, provider.lulu.rules] + provenance: [rule_pack_id, observation_date, official_source_urls, input_digests, cover_template_digests, publication_contract_digest] + upstream: https://help.lulu.com/ + rationale: Provider-specific eligibility remains in a dated, offline rule pack and reports candidates without upload side effects. + - id: adapter.documents.pandoc runtime_tool: tool.pandoc name: Pandoc document adapter diff --git a/crates/renderflow-core/data/publication/lulu-rules-2026-09-11.yaml b/crates/renderflow-core/data/publication/lulu-rules-2026-09-11.yaml new file mode 100644 index 0000000..2e109fb --- /dev/null +++ b/crates/renderflow-core/data/publication/lulu-rules-2026-09-11.yaml @@ -0,0 +1,72 @@ +schema: renderflow.lulu-rules/v1 +id: lulu-2026-09-11 +observed_on: "2026-09-11" +sources: + - url: https://help.lulu.com/en/support/solutions/articles/64000255462-mandatory-print-book-distribution-requirements + observed_modified: "2026-08-21" + - url: https://help.lulu.com/en/support/solutions/articles/64000255486-how-to-create-a-print-book + observed_modified: "2026-06-12" + - url: https://help.lulu.com/en/support/solutions/articles/64000255463-mandatory-ebook-distribution-requirements + observed_modified: "2026-08-07" + - url: https://help.lulu.com/en/support/solutions/articles/64000255592-publishing-an-ebook-for-global-distribution + observed_modified: "2026-08-07" + - url: https://help.lulu.com/en/support/solutions/articles/64000308545-learn-more-about-accessible-ebooks + observed_modified: "2025-05-30" + - url: https://help.lulu.com/en/support/solutions/articles/64000267552-global-distribution-print-exclusions + observed_modified: "2026-08-21" +print: + recommended_max_bytes: 524288000 + amazon_minimum_color_pages: 72 + spine_text_minimum_pages: 100 + maximum_consecutive_blank_pages: 2 + barcode: + width_mm: 50.8 + height_mm: 30.5 + edge_clearance_mm: 6.0 + black_on_white: true + required_distribution_attestations: + - interior_page_count_verified + - interior_geometry_verified + - fonts_embedded + - layers_flattened + - vectors_rasterized_where_required + - no_trim_or_bleed_marks + - page_order_reviewed + - blank_pages_reviewed + - images_high_resolution + - color_matches_metadata + - metadata_matches_files + - title_page_before_copyright_page + - barcode_identity_verified + - barcode_geometry_verified + - barcode_safe_placement_verified + amazon_excluded_formats: [comic_book] + ingram_excluded_formats: [small_square] + amazon_excluded_bindings: [linen_wrap, casewrap] + amazon_excluded_papers: [eighty_white, eighty_coated] + global_distribution_review_minimum_weeks: 12 +ebook: + epubcheck_major: 5 + epub_version: "3.3" + recommended_max_bytes: 52428800 + rejection_risk_bytes: 524288000 + global_distribution_languages: [en, en-US, en-GB] + cover_width_px: 612 + cover_height_px: 792 + cover_minimum_ppi: 72 + cover_maximum_ppi: 300 + apple_max_image_pixels: 5600000 + required_distribution_attestations: + - xhtml_content + - complete_ordered_navigation + - text_not_truncated + - images_rgb + - no_interactive_content + - no_fillable_content + - metadata_matches_files + - copyright_page_present + - retailer_cover_is_flat + - retailer_cover_dimensions_verified + - retailer_cover_resolution_verified + - apple_image_limit_verified + - accessibility_metadata_reviewed diff --git a/crates/renderflow-core/data/tool-registry.yaml b/crates/renderflow-core/data/tool-registry.yaml index 75ee2dd..a7cea5d 100644 --- a/crates/renderflow-core/data/tool-registry.yaml +++ b/crates/renderflow-core/data/tool-registry.yaml @@ -1,6 +1,30 @@ schema: renderflow.tool-registry/v1 tools: + - id: tool.lulu-rules + name: Pinned Lulu publication rule pack + discovery: + kind: virtual + operating_systems: [linux, macos, windows] + capabilities: + - publication.lulu.print-direct.preflight + - publication.lulu.bookstore.preflight + - publication.lulu.global-distribution.preflight + - publication.lulu.pdf-ebook.preflight + - publication.lulu.epub-distribution.preflight + input_media_types: + - application/pdf + - application/epub+zip + - application/vnd.renderflow.publication+json + output_media_types: + - application/vnd.renderflow.lulu-conformance+json + determinism: deterministic + locality: local + fidelity: lossless + support_tier: optional + license_notes: "Provider rules summarize dated public Lulu documentation; official terms remain authoritative." + distribution_notes: "Offline candidate validation only; never uploads, allocates ISBNs, orders proofs, or publishes." + - id: tool.pandoc name: Pandoc discovery: diff --git a/crates/renderflow-core/src/adapters/catalog.rs b/crates/renderflow-core/src/adapters/catalog.rs index 5a75e78..c699083 100644 --- a/crates/renderflow-core/src/adapters/catalog.rs +++ b/crates/renderflow-core/src/adapters/catalog.rs @@ -25,6 +25,7 @@ pub enum AdapterFamily { Data, Ocr, Subtitles, + Publication, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index 298b4c1..a702d91 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -3,8 +3,8 @@ use clap::Parser; use tracing::info; use crate::cli::{ - AiCommands, Cli, Commands, EbookCommands, GraphCommands, PluginCommands, SpecCommands, - ToolCommands, + AiCommands, Cli, Commands, EbookCommands, GraphCommands, LuluCommands, PluginCommands, + PublicationCommands, SpecCommands, ToolCommands, }; use crate::{commands, transforms}; @@ -199,6 +199,24 @@ pub fn run_cli(cli: Cli) -> Result<()> { } => commands::ebook::run_inspect(&input, &format, epubcheck)?, EbookCommands::Capabilities { format } => commands::ebook::run_capabilities(&format)?, }, + Some(Commands::Publication { subcommand }) => match subcommand { + PublicationCommands::Lulu { subcommand } => match subcommand { + LuluCommands::Rules { format, output } => { + commands::publication::run_lulu_rules(&format, output.as_deref())? + } + LuluCommands::Preflight { + request, + format, + output, + epubcheck, + } => commands::publication::run_lulu_preflight( + &request, + &format, + output.as_deref(), + epubcheck, + )?, + }, + }, Some(Commands::Capabilities { format, transforms, diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index 5587371..fa08174 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -273,6 +273,13 @@ pub enum Commands { subcommand: EbookCommands, }, + /// Inspect pinned commercial-publication provider rule packs and candidates. + #[command(subcommand_required = true, arg_required_else_help = true)] + Publication { + #[command(subcommand)] + subcommand: PublicationCommands, + }, + /// List stable provider capability IDs and their implementations. Capabilities { /// Output format: text (default), json, or yaml. @@ -307,6 +314,38 @@ pub enum Commands { }, } +#[derive(Subcommand)] +pub enum PublicationCommands { + /// Evaluate candidates with the bundled, offline Lulu provider pack. + Lulu { + #[command(subcommand)] + subcommand: LuluCommands, + }, +} + +#[derive(Subcommand)] +pub enum LuluCommands { + /// Emit the exact pinned Lulu rule pack and official source observations. + Rules { + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + #[arg(long, value_name = "FILE")] + output: Option, + }, + /// Validate local upload candidates without uploading or allocating identifiers. + Preflight { + #[arg(long, value_name = "FILE")] + request: String, + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + #[arg(long, value_name = "FILE")] + output: Option, + /// Run the optional local EPUBCheck v5 provider. + #[arg(long)] + epubcheck: bool, + }, +} + /// Subcommands for provider-neutral e-book derivatives. #[derive(Subcommand)] pub enum EbookCommands { diff --git a/crates/renderflow-core/src/commands/mod.rs b/crates/renderflow-core/src/commands/mod.rs index 601d44e..8a1c69d 100644 --- a/crates/renderflow-core/src/commands/mod.rs +++ b/crates/renderflow-core/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod ebook; pub mod graph; pub mod inspect; pub mod plugin; +pub mod publication; pub mod spec; pub mod system; pub mod tools; diff --git a/crates/renderflow-core/src/commands/publication.rs b/crates/renderflow-core/src/commands/publication.rs new file mode 100644 index 0000000..e668c87 --- /dev/null +++ b/crates/renderflow-core/src/commands/publication.rs @@ -0,0 +1,84 @@ +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::Serialize; + +use crate::publication::lulu::{ + evaluate_request, LuluConformanceReport, LuluEligibility, LuluRulePack, +}; + +pub fn run_lulu_rules(format: &str, output: Option<&str>) -> Result<()> { + emit(&LuluRulePack::builtin()?, format, output) +} + +pub fn run_lulu_preflight( + request: &str, + format: &str, + output: Option<&str>, + epubcheck: bool, +) -> Result<()> { + let report = evaluate_request(Path::new(request), epubcheck)?; + emit_report(&report, format, output)?; + if report.channels.iter().any(|channel| { + matches!( + channel.eligibility, + LuluEligibility::Ineligible | LuluEligibility::Unknown + ) + }) { + anyhow::bail!("one or more requested Lulu channels are not upload-ready; see report") + } + Ok(()) +} + +fn emit_report(report: &LuluConformanceReport, format: &str, output: Option<&str>) -> Result<()> { + if format.eq_ignore_ascii_case("text") { + let mut text = format!( + "Lulu candidate preflight\nRule pack: {} (observed {})\nUpload performed: no\n\nChannels:\n", + report.rule_pack, report.observed_on + ); + for channel in &report.channels { + text.push_str(&format!( + " {:?}: {:?}\n", + channel.channel, channel.eligibility + )); + for code in &channel.blocking_findings { + text.push_str(&format!(" BLOCK: {code}\n")); + } + for code in &channel.warning_findings { + text.push_str(&format!(" WARN: {code}\n")); + } + } + text.push_str("\nFindings:\n"); + for finding in &report.findings { + text.push_str(&format!( + " [{:?}] {}: {}\n", + finding.severity, finding.code, finding.message + )); + } + write(&text, output) + } else { + emit(report, format, output) + } +} + +fn emit(value: &T, format: &str, output: Option<&str>) -> Result<()> { + let content = match format.to_ascii_lowercase().as_str() { + "json" => format!("{}\n", serde_json::to_string_pretty(value)?), + "yaml" | "yml" | "text" => serde_yaml_ng::to_string(value)?, + other => anyhow::bail!( + "unknown publication output format '{other}'; supported: text, json, yaml" + ), + }; + write(&content, output) +} + +fn write(content: &str, output: Option<&str>) -> Result<()> { + if let Some(path) = output { + fs::write(path, content) + .with_context(|| format!("failed to write publication report '{path}'"))?; + } else { + print!("{content}"); + } + Ok(()) +} diff --git a/crates/renderflow-core/src/publication.rs b/crates/renderflow-core/src/publication.rs index 1ef9b6b..d161b4a 100644 --- a/crates/renderflow-core/src/publication.rs +++ b/crates/renderflow-core/src/publication.rs @@ -1,5 +1,7 @@ //! Provider-neutral publication contracts and deterministic release metadata. +pub mod lulu; + use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; diff --git a/crates/renderflow-core/src/publication/lulu.rs b/crates/renderflow-core/src/publication/lulu.rs new file mode 100644 index 0000000..f4d3154 --- /dev/null +++ b/crates/renderflow-core/src/publication/lulu.rs @@ -0,0 +1,1180 @@ +//! Pinned, offline Lulu candidate rules and provider-specific preflight evidence. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::ebook::{inspect_ebook, EbookDiagnosticSeverity, EbookLayout}; +use crate::publication::{PublicationContract, PUBLICATION_CONTRACT_V1}; +use crate::spec::load_spec; + +pub const LULU_RULES_SCHEMA_V1: &str = "renderflow.lulu-rules/v1"; +pub const LULU_REQUEST_SCHEMA_V1: &str = "renderflow.lulu-request/v1"; +pub const LULU_REPORT_SCHEMA_V1: &str = "renderflow.lulu-conformance/v1"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LuluRuleSource { + pub url: String, + pub observed_modified: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LuluBarcodeRules { + pub width_mm: f64, + pub height_mm: f64, + pub edge_clearance_mm: f64, + pub black_on_white: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LuluPrintRules { + pub recommended_max_bytes: u64, + pub amazon_minimum_color_pages: u32, + pub spine_text_minimum_pages: u32, + pub maximum_consecutive_blank_pages: u32, + pub barcode: LuluBarcodeRules, + pub required_distribution_attestations: Vec, + pub amazon_excluded_formats: Vec, + pub ingram_excluded_formats: Vec, + pub amazon_excluded_bindings: Vec, + pub amazon_excluded_papers: Vec, + pub global_distribution_review_minimum_weeks: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LuluEbookRules { + pub epubcheck_major: u32, + pub epub_version: String, + pub recommended_max_bytes: u64, + pub rejection_risk_bytes: u64, + pub global_distribution_languages: Vec, + pub cover_width_px: u32, + pub cover_height_px: u32, + pub cover_minimum_ppi: u32, + pub cover_maximum_ppi: u32, + pub apple_max_image_pixels: u64, + pub required_distribution_attestations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LuluRulePack { + pub schema: String, + pub id: String, + pub observed_on: String, + pub sources: Vec, + pub print: LuluPrintRules, + pub ebook: LuluEbookRules, +} + +impl LuluRulePack { + pub fn builtin() -> Result { + let rules: Self = serde_yaml_ng::from_str(include_str!( + "../../data/publication/lulu-rules-2026-09-11.yaml" + )) + .context("bundled Lulu rule pack is invalid")?; + if rules.schema != LULU_RULES_SCHEMA_V1 { + anyhow::bail!( + "unsupported Lulu rule schema '{}'; expected '{}'", + rules.schema, + LULU_RULES_SCHEMA_V1 + ); + } + Ok(rules) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LuluChannel { + PrintDirect, + LuluBookstore, + GlobalDistribution, + PdfEbook, + EpubDistribution, + Amazon, + Ingram, + BarnesAndNoble, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LuluEligibility { + Eligible, + Ineligible, + Unknown, + NotRequested, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LuluFindingSeverity { + Info, + Warning, + Error, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LuluFinding { + pub code: String, + pub severity: LuluFindingSeverity, + pub message: String, + pub channels: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LuluProductRequest { + pub format: String, + pub binding: String, + pub paper: String, + pub color: String, + pub page_count: u32, + pub trim_width_mm: f64, + pub trim_height_mm: f64, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LuluArtifactRequest { + #[serde(default)] + pub interior_pdf: Option, + #[serde(default)] + pub bookstore_cover_pdf: Option, + #[serde(default)] + pub distribution_cover_pdf: Option, + #[serde(default)] + pub bookstore_cover_template: Option, + #[serde(default)] + pub distribution_cover_template: Option, + #[serde(default)] + pub pdf_ebook: Option, + #[serde(default)] + pub epub: Option, + #[serde(default)] + pub ebook_cover: Option, + #[serde(default)] + pub print_proof_pdf: Option, + #[serde(default)] + pub proof_contact_sheet: Option, + #[serde(default)] + pub ace_report: Option, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LuluMetadataRequest { + #[serde(default)] + pub print_isbn: Option, + #[serde(default)] + pub ebook_isbn: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub keywords: Vec, + #[serde(default)] + pub proof_approved: bool, + #[serde(default)] + pub fixed_layout_compatibility_verified: bool, + #[serde(default)] + pub accessible_epub_claimed: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LuluPreflightRequest { + pub schema: String, + pub rule_pack: String, + pub publication_spec: String, + pub channels: Vec, + pub product: LuluProductRequest, + #[serde(default)] + pub artifacts: LuluArtifactRequest, + #[serde(default)] + pub metadata: LuluMetadataRequest, + #[serde(default)] + pub attestations: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LuluArtifactEvidence { + pub role: String, + pub path: String, + pub sha256: Option, + pub size_bytes: Option, + pub inspected: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LuluChannelResult { + pub channel: LuluChannel, + pub eligibility: LuluEligibility, + pub blocking_findings: Vec, + pub warning_findings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LuluConformanceReport { + pub schema: String, + pub provider_id: String, + pub rule_pack: String, + pub observed_on: String, + pub deterministic_offline_evaluation: bool, + pub publication_schema: String, + pub publication_spec_sha256: String, + pub artifacts: Vec, + pub channels: Vec, + pub findings: Vec, + pub sources: Vec, + pub upload_performed: bool, +} + +pub fn load_request(path: &Path) -> Result { + let bytes = fs::read(path) + .with_context(|| format!("failed to read Lulu request '{}'", path.display()))?; + let request: LuluPreflightRequest = serde_yaml_ng::from_slice(&bytes) + .with_context(|| format!("failed to parse Lulu request '{}'", path.display()))?; + if request.schema != LULU_REQUEST_SCHEMA_V1 { + anyhow::bail!( + "unsupported Lulu request schema '{}'; expected '{}'", + request.schema, + LULU_REQUEST_SCHEMA_V1 + ); + } + Ok(request) +} + +pub fn evaluate_request(request_path: &Path, run_epubcheck: bool) -> Result { + let rules = LuluRulePack::builtin()?; + let request = load_request(request_path)?; + if request.rule_pack != rules.id { + anyhow::bail!( + "Lulu request pins rule pack '{}' but this build provides '{}'; refusing to substitute changing rules", + request.rule_pack, + rules.id + ); + } + let root = request_path.parent().unwrap_or_else(|| Path::new(".")); + let publication_path = root.join(&request.publication_spec); + let publication_spec_sha256 = format!( + "{:x}", + Sha256::digest(fs::read(&publication_path).with_context(|| { + format!( + "failed to read publication spec '{}'", + publication_path.display() + ) + })?) + ); + let loaded = load_spec( + publication_path + .to_str() + .context("non-UTF8 publication path")?, + )?; + let publication = loaded + .spec + .publication + .context("the referenced Renderflow spec has no publication contract")?; + evaluate( + &rules, + &request, + &publication, + &publication_spec_sha256, + root, + run_epubcheck, + ) +} + +fn evaluate( + rules: &LuluRulePack, + request: &LuluPreflightRequest, + publication: &PublicationContract, + publication_spec_sha256: &str, + root: &Path, + run_epubcheck: bool, +) -> Result { + let requested = request.channels.iter().copied().collect::>(); + let mut findings = Vec::new(); + let mut artifacts = Vec::new(); + for (role, path) in artifact_paths(&request.artifacts) { + artifacts.push(inspect_artifact(role, path, root)); + } + + require( + publication.schema == PUBLICATION_CONTRACT_V1, + "lulu.publication.schema", + "The publication contract must use renderflow.publication/v1", + LuluFindingSeverity::Error, + all_channels(), + &mut findings, + ); + require( + request.product.page_count > 0, + "lulu.print.page_count", + "A positive interior page count is required", + LuluFindingSeverity::Error, + print_channels(), + &mut findings, + ); + validate_declared_geometry(request, publication, &mut findings); + validate_print(rules, request, root, &requested, &mut findings)?; + validate_ebook( + rules, + request, + publication, + root, + run_epubcheck, + &requested, + &mut findings, + )?; + validate_distribution_metadata(request, publication, &requested, &mut findings); + + let channels = all_channels() + .into_iter() + .map(|channel| channel_result(channel, requested.contains(&channel), &findings)) + .collect(); + Ok(LuluConformanceReport { + schema: LULU_REPORT_SCHEMA_V1.to_string(), + provider_id: "adapter.publication.lulu".to_string(), + rule_pack: rules.id.clone(), + observed_on: rules.observed_on.clone(), + deterministic_offline_evaluation: true, + publication_schema: publication.schema.clone(), + publication_spec_sha256: publication_spec_sha256.to_string(), + artifacts, + channels, + findings, + sources: rules.sources.clone(), + upload_performed: false, + }) +} + +fn validate_print( + rules: &LuluRulePack, + request: &LuluPreflightRequest, + root: &Path, + requested: &BTreeSet, + findings: &mut Vec, +) -> Result<()> { + if !requested + .iter() + .any(|channel| print_channels().contains(channel)) + { + return Ok(()); + } + require_path( + request.artifacts.interior_pdf.as_deref(), + root, + "lulu.print.interior_pdf", + "A single-page-layout interior PDF is required", + print_channels(), + findings, + ); + validate_pdf( + request.artifacts.interior_pdf.as_deref(), + root, + "interior", + print_channels(), + findings, + )?; + if let Some(path) = resolved_existing(request.artifacts.interior_pdf.as_deref(), root) { + let size = fs::metadata(path)?.len(); + if size >= rules.print.recommended_max_bytes { + finding( + "lulu.print.file_size.recommended", + LuluFindingSeverity::Warning, + "Interior PDF is at or above Lulu's pinned 500 MB recommendation", + print_channels(), + findings, + ); + } + } + if requested.contains(&LuluChannel::LuluBookstore) { + validate_cover_pair( + "bookstore", + request.artifacts.bookstore_cover_pdf.as_deref(), + request.artifacts.bookstore_cover_template.as_deref(), + root, + vec![LuluChannel::LuluBookstore], + findings, + )?; + } + if requested.contains(&LuluChannel::GlobalDistribution) + || requested.contains(&LuluChannel::Amazon) + || requested.contains(&LuluChannel::Ingram) + { + validate_cover_pair( + "distribution", + request.artifacts.distribution_cover_pdf.as_deref(), + request.artifacts.distribution_cover_template.as_deref(), + root, + vec![ + LuluChannel::GlobalDistribution, + LuluChannel::Amazon, + LuluChannel::Ingram, + ], + findings, + )?; + for attestation in &rules.print.required_distribution_attestations { + require_attestation( + attestation, + request, + print_distribution_channels(), + findings, + ); + } + require( + request.metadata.proof_approved, + "lulu.print.proof_approval", + "Global Distribution requires creator approval of a print proof", + LuluFindingSeverity::Error, + print_distribution_channels(), + findings, + ); + require_path( + request.artifacts.print_proof_pdf.as_deref(), + root, + "lulu.print.proof_pdf", + "A reviewed print proof PDF is required for distribution evidence", + print_distribution_channels(), + findings, + ); + require_path( + request.artifacts.proof_contact_sheet.as_deref(), + root, + "lulu.print.proof_contact_sheet", + "A proof contact sheet is required for distribution evidence", + print_distribution_channels(), + findings, + ); + finding( + "lulu.distribution.timeline", + LuluFindingSeverity::Info, + &format!( + "Pinned Lulu guidance says Global Distribution can take at least {} weeks", + rules.print.global_distribution_review_minimum_weeks + ), + print_distribution_channels(), + findings, + ); + } + let color = request.product.color != "black_and_white"; + if requested.contains(&LuluChannel::Amazon) + && color + && request.product.page_count < rules.print.amazon_minimum_color_pages + { + finding( + "lulu.amazon.color_page_minimum", + LuluFindingSeverity::Error, + &format!( + "Amazon requires at least {} pages for Standard or Premium Color; this request declares {}", + rules.print.amazon_minimum_color_pages, request.product.page_count + ), + vec![LuluChannel::Amazon], + findings, + ); + } + if requested.contains(&LuluChannel::Amazon) + && rules + .print + .amazon_excluded_formats + .contains(&request.product.format) + { + finding( + "lulu.amazon.product_format_excluded", + LuluFindingSeverity::Error, + "The selected product format is excluded from Amazon in the pinned Lulu table", + vec![LuluChannel::Amazon], + findings, + ); + } + if requested.contains(&LuluChannel::Amazon) + && rules + .print + .amazon_excluded_papers + .contains(&request.product.paper) + { + finding( + "lulu.amazon.paper_excluded", + LuluFindingSeverity::Error, + "The selected 80# paper is excluded from Amazon in the pinned Lulu table", + vec![LuluChannel::Amazon], + findings, + ); + } + if request.product.page_count < rules.print.spine_text_minimum_pages + && request.attestations.get("spine_text_present") == Some(&true) + { + finding( + "lulu.print.spine_text_below_minimum", + LuluFindingSeverity::Error, + &format!( + "Spine text is not permitted below {} pages in the pinned Lulu rules", + rules.print.spine_text_minimum_pages + ), + print_channels(), + findings, + ); + } + if requested.contains(&LuluChannel::LuluBookstore) + && requested.iter().any(|channel| { + matches!( + channel, + LuluChannel::GlobalDistribution | LuluChannel::Amazon | LuluChannel::Ingram + ) + }) + && request.artifacts.bookstore_cover_template + == request.artifacts.distribution_cover_template + { + finding( + "lulu.print.cover_templates.not_distinct", + LuluFindingSeverity::Unknown, + "Bookstore and distribution cover templates are identical; their spine geometries must be independently verified", + vec![ + LuluChannel::LuluBookstore, + LuluChannel::GlobalDistribution, + LuluChannel::Amazon, + LuluChannel::Ingram, + ], + findings, + ); + } + if requested.contains(&LuluChannel::Amazon) + && rules + .print + .amazon_excluded_bindings + .contains(&request.product.binding) + { + finding( + "lulu.amazon.binding_excluded", + LuluFindingSeverity::Error, + "The selected binding is excluded from Amazon in the pinned Lulu table", + vec![LuluChannel::Amazon], + findings, + ); + } + if requested.contains(&LuluChannel::Ingram) + && rules + .print + .ingram_excluded_formats + .contains(&request.product.format) + { + finding( + "lulu.ingram.product_format_excluded", + LuluFindingSeverity::Error, + "The selected product format is excluded from Ingram in the pinned Lulu table", + vec![LuluChannel::Ingram], + findings, + ); + } + if requested.contains(&LuluChannel::BarnesAndNoble) { + finding( + "lulu.barnes_and_noble.not_inferred", + LuluFindingSeverity::Unknown, + "Ingram availability does not establish Barnes & Noble listing or shelf placement", + vec![LuluChannel::BarnesAndNoble], + findings, + ); + } + Ok(()) +} + +fn validate_ebook( + rules: &LuluRulePack, + request: &LuluPreflightRequest, + publication: &PublicationContract, + root: &Path, + run_epubcheck: bool, + requested: &BTreeSet, + findings: &mut Vec, +) -> Result<()> { + if requested.contains(&LuluChannel::PdfEbook) { + require_path( + request.artifacts.pdf_ebook.as_deref(), + root, + "lulu.ebook.pdf", + "The Lulu PDF ebook channel requires a PDF candidate", + vec![LuluChannel::PdfEbook], + findings, + ); + validate_pdf( + request.artifacts.pdf_ebook.as_deref(), + root, + "ebook", + vec![LuluChannel::PdfEbook], + findings, + )?; + finding( + "lulu.ebook.pdf.accessibility_not_inferred", + LuluFindingSeverity::Info, + "A PDF ebook is not automatically treated as accessible", + vec![LuluChannel::PdfEbook], + findings, + ); + } + if !requested.contains(&LuluChannel::EpubDistribution) { + return Ok(()); + } + let channels = vec![LuluChannel::EpubDistribution]; + require_path( + request.artifacts.epub.as_deref(), + root, + "lulu.epub.file", + "EPUB Distribution requires an EPUB candidate", + channels.clone(), + findings, + ); + require_path( + request.artifacts.ebook_cover.as_deref(), + root, + "lulu.epub.cover", + "EPUB Distribution requires a separate flat retailer cover image", + channels.clone(), + findings, + ); + require( + rules + .ebook + .global_distribution_languages + .contains(&publication.language), + "lulu.epub.language", + "The pinned Lulu rule pack only verifies listed English language tags for EPUB distribution", + LuluFindingSeverity::Error, + channels.clone(), + findings, + ); + for attestation in &rules.ebook.required_distribution_attestations { + require_attestation(attestation, request, channels.clone(), findings); + } + if let Some(path) = resolved_existing(request.artifacts.epub.as_deref(), root) { + let inspection = inspect_ebook(&path, run_epubcheck)?; + for diagnostic in inspection.diagnostics { + finding( + &format!("lulu.{}", diagnostic.code), + match diagnostic.severity { + EbookDiagnosticSeverity::Info => LuluFindingSeverity::Info, + EbookDiagnosticSeverity::Warning => LuluFindingSeverity::Warning, + EbookDiagnosticSeverity::Error => LuluFindingSeverity::Error, + }, + &diagnostic.message, + channels.clone(), + findings, + ); + } + require( + inspection.epub_version.as_deref() == Some(rules.ebook.epub_version.as_str()), + "lulu.epub.version", + "EPUB Distribution requires the pinned EPUB 3.3 profile", + LuluFindingSeverity::Error, + channels.clone(), + findings, + ); + if inspection.layout == EbookLayout::PrePaginated + && !request.metadata.fixed_layout_compatibility_verified + { + finding( + "lulu.epub.fixed_layout_unverified", + LuluFindingSeverity::Unknown, + "Fixed-layout retailer compatibility was not explicitly verified", + channels.clone(), + findings, + ); + } + if run_epubcheck { + match inspection.epubcheck { + Some(check) + if check.passed == Some(true) + && check.version.as_deref().is_some_and(|version| { + version.contains(&format!("{}.", rules.ebook.epubcheck_major)) + }) => {} + Some(check) if check.passed == Some(true) => finding( + "lulu.epub.epubcheck_version", + LuluFindingSeverity::Unknown, + "EPUBCheck passed, but its reported version does not verify the pinned v5 requirement", + channels.clone(), + findings, + ), + Some(check) if !check.available => finding( + "lulu.epub.epubcheck_unavailable", + LuluFindingSeverity::Unknown, + "EPUBCheck v5 was requested but is unavailable", + channels.clone(), + findings, + ), + _ => finding( + "lulu.epub.epubcheck_failed", + LuluFindingSeverity::Error, + "EPUBCheck v5 did not pass", + channels.clone(), + findings, + ), + } + } else { + finding( + "lulu.epub.epubcheck_not_run", + LuluFindingSeverity::Unknown, + "EPUBCheck v5 evidence is required before upload-ready status", + channels.clone(), + findings, + ); + } + let size = fs::metadata(path)?.len(); + if size >= rules.ebook.rejection_risk_bytes { + finding( + "lulu.epub.file_size.rejection_risk", + LuluFindingSeverity::Error, + "EPUB is at or above the pinned 500 MB rejection-risk threshold", + channels.clone(), + findings, + ); + } else if size >= rules.ebook.recommended_max_bytes { + finding( + "lulu.epub.file_size.recommended", + LuluFindingSeverity::Warning, + "EPUB is at or above Lulu's pinned 50 MB recommendation", + channels.clone(), + findings, + ); + } + if request.metadata.accessible_epub_claimed + && inspection.accessibility.accessibility_features == 0 + && !inspection.accessibility.conforms_to + { + finding( + "lulu.epub.accessibility_unverified", + LuluFindingSeverity::Unknown, + "The EPUB is claimed accessible but structural accessibility evidence is sparse; attach Ace by DAISY evidence", + channels.clone(), + findings, + ); + } + if request.metadata.accessible_epub_claimed { + require_path( + request.artifacts.ace_report.as_deref(), + root, + "lulu.epub.accessibility.ace_report", + "An Ace by DAISY report is required to support the accessibility claim", + channels, + findings, + ); + } + } + Ok(()) +} + +fn validate_distribution_metadata( + request: &LuluPreflightRequest, + publication: &PublicationContract, + requested: &BTreeSet, + findings: &mut Vec, +) { + let print = vec![ + LuluChannel::GlobalDistribution, + LuluChannel::Amazon, + LuluChannel::Ingram, + ]; + if requested.iter().any(|channel| print.contains(channel)) { + require( + valid_isbn13(request.metadata.print_isbn.as_deref()), + "lulu.print.isbn", + "A valid 13-digit print ISBN is required for distribution", + LuluFindingSeverity::Error, + print.clone(), + findings, + ); + } + if requested.contains(&LuluChannel::EpubDistribution) { + require( + valid_undashed_isbn13(request.metadata.ebook_isbn.as_deref()), + "lulu.epub.isbn", + "A separate valid 13-digit ebook ISBN is required for distribution", + LuluFindingSeverity::Error, + vec![LuluChannel::EpubDistribution], + findings, + ); + } + let distribution_requested = requested.contains(&LuluChannel::GlobalDistribution) + || requested.contains(&LuluChannel::EpubDistribution); + if distribution_requested { + let description = request.metadata.description.as_deref().unwrap_or_default(); + require( + description + .chars() + .filter(|character| !character.is_whitespace()) + .count() + >= 50, + "lulu.metadata.description", + "Distribution description must contain at least 50 non-whitespace characters", + LuluFindingSeverity::Error, + vec![ + LuluChannel::GlobalDistribution, + LuluChannel::EpubDistribution, + ], + findings, + ); + require( + publication.title.len() + publication.subtitle.as_deref().map(str::len).unwrap_or(0) + <= 200, + "lulu.metadata.title_length", + "Combined title and subtitle must not exceed 200 characters", + LuluFindingSeverity::Error, + vec![ + LuluChannel::GlobalDistribution, + LuluChannel::EpubDistribution, + ], + findings, + ); + for keyword in &request.metadata.keywords { + if keyword.chars().count() >= 50 { + finding( + "lulu.metadata.keyword_length", + LuluFindingSeverity::Warning, + "A keyword is 50 characters or longer and may be removed by distribution partners", + vec![LuluChannel::GlobalDistribution, LuluChannel::EpubDistribution], + findings, + ); + } + } + } +} + +fn validate_cover_pair( + label: &str, + cover: Option<&str>, + template: Option<&str>, + root: &Path, + channels: Vec, + findings: &mut Vec, +) -> Result<()> { + require( + cover != template, + &format!("lulu.print.{label}_cover_template_distinct"), + "The cover candidate and user-supplied template must be separate files", + LuluFindingSeverity::Error, + channels.clone(), + findings, + ); + require_path( + cover, + root, + &format!("lulu.print.{label}_cover"), + "A separate one-piece cover PDF is required", + channels.clone(), + findings, + ); + validate_pdf( + cover, + root, + &format!("{label} cover"), + channels.clone(), + findings, + )?; + require_path( + template, + root, + &format!("lulu.print.{label}_template"), + "A current user-supplied Lulu cover template is required; geometry is never guessed", + channels, + findings, + ); + Ok(()) +} + +fn validate_pdf( + path: Option<&str>, + root: &Path, + label: &str, + channels: Vec, + findings: &mut Vec, +) -> Result<()> { + let Some(path) = resolved_existing(path, root) else { + return Ok(()); + }; + let bytes = fs::read(path)?; + require( + bytes.starts_with(b"%PDF-") + && bytes + .windows(5) + .rev() + .take(2048) + .any(|item| item == b"%%EOF"), + &format!("lulu.pdf.{label}.envelope"), + &format!("The {label} PDF envelope is invalid"), + LuluFindingSeverity::Error, + channels, + findings, + ); + Ok(()) +} + +fn require_attestation( + id: &str, + request: &LuluPreflightRequest, + channels: Vec, + findings: &mut Vec, +) { + require( + request.attestations.get(id) == Some(&true), + &format!("lulu.attestation.{id}"), + &format!("Required reviewed attestation '{id}' is missing or false"), + LuluFindingSeverity::Unknown, + channels, + findings, + ); +} + +fn require_path( + value: Option<&str>, + root: &Path, + code: &str, + message: &str, + channels: Vec, + findings: &mut Vec, +) { + require( + resolved_existing(value, root).is_some(), + code, + message, + LuluFindingSeverity::Error, + channels, + findings, + ); +} + +fn resolved_existing<'a>(value: Option<&'a str>, root: &'a Path) -> Option { + value + .map(|value| root.join(value)) + .filter(|path| path.is_file()) +} + +fn artifact_paths(artifacts: &LuluArtifactRequest) -> Vec<(&'static str, Option<&str>)> { + vec![ + ("interior_pdf", artifacts.interior_pdf.as_deref()), + ( + "bookstore_cover_pdf", + artifacts.bookstore_cover_pdf.as_deref(), + ), + ( + "distribution_cover_pdf", + artifacts.distribution_cover_pdf.as_deref(), + ), + ( + "bookstore_cover_template", + artifacts.bookstore_cover_template.as_deref(), + ), + ( + "distribution_cover_template", + artifacts.distribution_cover_template.as_deref(), + ), + ("pdf_ebook", artifacts.pdf_ebook.as_deref()), + ("epub", artifacts.epub.as_deref()), + ("ebook_cover", artifacts.ebook_cover.as_deref()), + ("print_proof_pdf", artifacts.print_proof_pdf.as_deref()), + ( + "proof_contact_sheet", + artifacts.proof_contact_sheet.as_deref(), + ), + ("ace_report", artifacts.ace_report.as_deref()), + ] +} + +fn inspect_artifact(role: &str, value: Option<&str>, root: &Path) -> LuluArtifactEvidence { + let Some(value) = value else { + return LuluArtifactEvidence { + role: role.to_string(), + path: String::new(), + sha256: None, + size_bytes: None, + inspected: false, + }; + }; + let path = root.join(value); + match fs::read(&path) { + Ok(bytes) => LuluArtifactEvidence { + role: role.to_string(), + path: path.display().to_string(), + sha256: Some(format!("{:x}", Sha256::digest(&bytes))), + size_bytes: Some(bytes.len() as u64), + inspected: true, + }, + Err(_) => LuluArtifactEvidence { + role: role.to_string(), + path: path.display().to_string(), + sha256: None, + size_bytes: None, + inspected: false, + }, + } +} + +fn channel_result( + channel: LuluChannel, + requested: bool, + findings: &[LuluFinding], +) -> LuluChannelResult { + if !requested { + return LuluChannelResult { + channel, + eligibility: LuluEligibility::NotRequested, + blocking_findings: Vec::new(), + warning_findings: Vec::new(), + }; + } + let relevant = findings + .iter() + .filter(|finding| finding.channels.contains(&channel)) + .collect::>(); + let blocking_findings = relevant + .iter() + .filter(|finding| finding.severity == LuluFindingSeverity::Error) + .map(|finding| finding.code.clone()) + .collect::>(); + let unknown = relevant + .iter() + .filter(|finding| finding.severity == LuluFindingSeverity::Unknown) + .map(|finding| finding.code.clone()) + .collect::>(); + let warning_findings = relevant + .iter() + .filter(|finding| finding.severity == LuluFindingSeverity::Warning) + .map(|finding| finding.code.clone()) + .collect::>(); + let eligibility = if !blocking_findings.is_empty() { + LuluEligibility::Ineligible + } else if !unknown.is_empty() { + LuluEligibility::Unknown + } else { + LuluEligibility::Eligible + }; + LuluChannelResult { + channel, + eligibility, + blocking_findings: blocking_findings.into_iter().chain(unknown).collect(), + warning_findings, + } +} + +fn valid_isbn13(value: Option<&str>) -> bool { + let Some(value) = value else { return false }; + let digits = value + .chars() + .filter(|character| character.is_ascii_digit()) + .collect::(); + if digits.len() != 13 { + return false; + } + let sum = digits + .bytes() + .take(12) + .enumerate() + .map(|(index, digit)| u32::from(digit - b'0') * if index % 2 == 0 { 1 } else { 3 }) + .sum::(); + let check = (10 - (sum % 10)) % 10; + u32::from(digits.as_bytes()[12] - b'0') == check +} + +fn valid_undashed_isbn13(value: Option<&str>) -> bool { + value.is_some_and(|value| { + value.len() == 13 + && value.chars().all(|character| character.is_ascii_digit()) + && valid_isbn13(Some(value)) + }) +} + +fn validate_declared_geometry( + request: &LuluPreflightRequest, + publication: &PublicationContract, + findings: &mut Vec, +) { + if publication.geometry.unit != "mm" { + finding( + "lulu.print.geometry.unit_unverified", + LuluFindingSeverity::Unknown, + "The provider pack only compares declared trim geometry expressed in millimetres", + print_channels(), + findings, + ); + return; + } + let tolerance_mm = 0.01; + require( + (publication.geometry.width - request.product.trim_width_mm).abs() <= tolerance_mm + && (publication.geometry.height - request.product.trim_height_mm).abs() <= tolerance_mm, + "lulu.print.geometry.contract_mismatch", + "Requested Lulu trim geometry does not match the reviewed publication contract", + LuluFindingSeverity::Error, + print_channels(), + findings, + ); +} + +fn require( + condition: bool, + code: &str, + message: &str, + severity: LuluFindingSeverity, + channels: Vec, + findings: &mut Vec, +) { + if !condition { + finding(code, severity, message, channels, findings); + } +} + +fn finding( + code: &str, + severity: LuluFindingSeverity, + message: &str, + channels: Vec, + findings: &mut Vec, +) { + findings.push(LuluFinding { + code: code.to_string(), + severity, + message: message.to_string(), + channels, + }); +} + +fn all_channels() -> Vec { + vec![ + LuluChannel::PrintDirect, + LuluChannel::LuluBookstore, + LuluChannel::GlobalDistribution, + LuluChannel::PdfEbook, + LuluChannel::EpubDistribution, + LuluChannel::Amazon, + LuluChannel::Ingram, + LuluChannel::BarnesAndNoble, + ] +} + +fn print_channels() -> Vec { + vec![ + LuluChannel::PrintDirect, + LuluChannel::LuluBookstore, + LuluChannel::GlobalDistribution, + LuluChannel::Amazon, + LuluChannel::Ingram, + ] +} + +fn print_distribution_channels() -> Vec { + vec![ + LuluChannel::GlobalDistribution, + LuluChannel::Amazon, + LuluChannel::Ingram, + ] +} diff --git a/docs/cli-reference/index.md b/docs/cli-reference/index.md index 7a56a1d..4b62062 100644 --- a/docs/cli-reference/index.md +++ b/docs/cli-reference/index.md @@ -11,6 +11,7 @@ This section documents command syntax, flags, and behavior. - [`graph`](graph.md) - [`plugin`](plugin.md) - [`ai`](ai.md) +- [`publication`](publication.md) ## Logging flags diff --git a/docs/cli-reference/publication.md b/docs/cli-reference/publication.md new file mode 100644 index 0000000..6e3a2aa --- /dev/null +++ b/docs/cli-reference/publication.md @@ -0,0 +1,32 @@ +# `renderflow publication` + +Inspect pinned provider rules and preflight local publication candidates. These +commands never authenticate, upload, allocate an ISBN, order a proof, or publish. + +## Lulu rules + +```bash +renderflow publication lulu rules --format yaml +renderflow publication lulu rules --format json --output lulu-rules.json +``` + +The output includes the exact rule-pack ID, observation date, thresholds, and +official source URLs bundled with this Renderflow build. + +## Lulu preflight + +```bash +renderflow publication lulu preflight \ + --request lulu-request.yaml \ + --format json \ + --output lulu-conformance.json \ + --epubcheck +``` + +`--request` accepts `renderflow.lulu-request/v1` YAML. `--epubcheck` asks the +optional local EPUBCheck executable for evidence. If it is missing or does not +identify a compatible v5 release, EPUB distribution remains `unknown`. + +The command exits nonzero when any requested channel is `ineligible` or +`unknown`, after writing the report. Omit `--output` for standard output. Output +formats are `text`, `json`, and `yaml`. diff --git a/docs/user-guide/lulu-publication-pack.md b/docs/user-guide/lulu-publication-pack.md new file mode 100644 index 0000000..9f4a893 --- /dev/null +++ b/docs/user-guide/lulu-publication-pack.md @@ -0,0 +1,76 @@ +# Lulu publication pack + +Renderflow's Lulu pack converts a reviewed provider-neutral publication contract +and local artifacts into deterministic candidate evidence. It is a versioned +adapter, not an uploader: it performs no login, upload, proof order, ISBN +allocation, retailer submission, or publishing action. + +## Model + +The request pins `lulu-2026-09-11`. Renderflow refuses to silently substitute a +newer rule pack. The report records that pack, its observation date and source +URLs, the publication-spec digest, every declared artifact and template digest, +and `upload_performed: false`. + +Eligibility is independent for `print_direct`, `lulu_bookstore`, +`global_distribution`, `pdf_ebook`, `epub_distribution`, `amazon`, `ingram`, and +`barnes_and_noble`. A pass for one channel never implies a pass for another. +Uninspected or missing required evidence produces `unknown` or `ineligible`, not +an optimistic pass. + +## Print evidence + +Print requests declare the product, page count, trim geometry, interior PDF, and +channel-specific one-piece covers. Bookstore and distribution covers each pair +with a current, user-supplied Lulu template; Renderflow records its digest and +never calculates or guesses a spine. Distribution also requires reviewed proof +and contact-sheet evidence. + +The pinned checks cover PDF envelopes and sizes, product exclusions, the Amazon +color-page minimum, 80# paper and hardcover exclusions, spine text below 100 +pages, ISBN checksum, metadata lengths, proof approval, and explicit reviewed +attestations for fonts, layers, vectors, resolution, page order, blanks, trim +marks, barcode identity/geometry/clearance, title and copyright ordering, and +metadata consistency. + +The synthetic request at +`examples/lulu/44-page-color-amazon-failure.yaml` deliberately demonstrates that +a 44-page color comic is rejected for Amazon while Ingram and Barnes & Noble are +reported independently. + +## EPUB evidence + +EPUB distribution requires an EPUB 3.3 candidate, an undashed 13-digit ebook +ISBN, a separate flat cover, English-language metadata supported by the pinned +profile, structural inspection, and explicit content attestations. Run with +`--epubcheck` for local EPUBCheck v5 evidence. A missing executable is honestly +reported as `unknown`, which keeps the candidate from upload-ready status. + +Accessibility claims use native package metadata as preliminary evidence. They +remain unknown when that evidence is sparse; attach an Ace by DAISY report to +the release evidence before making a reviewed accessibility claim. Fixed-layout +retailer compatibility is also an explicit review decision. + +## Portable operation + +The rule evaluator, PDF-envelope checks, EPUB structural inspection, digesting, +and report generation are native and offline on Linux, macOS, Windows, and +Termux/proot-style environments. EPUBCheck is optional. Its absence does not +break unrelated print or PDF-ebook channels. + +## Adding another provider + +Keep provider changes outside `renderflow.publication/v1`. Add a dated rule pack, +a provider request/report schema, a virtual tool capability, and an adapter entry +in `adapter-packs.yaml`. Preserve independent channel decisions, exact source +observations, artifact digests, fail-closed unknowns, and the no-side-effects +boundary. + +## Pinned official references + +- [Mandatory print distribution requirements](https://help.lulu.com/en/support/solutions/articles/64000255462-mandatory-print-book-distribution-requirements) +- [How to create a print book](https://help.lulu.com/en/support/solutions/articles/64000255486-how-to-create-a-print-book) +- [Global distribution print exclusions](https://help.lulu.com/en/support/solutions/articles/64000267552-global-distribution-print-exclusions) +- [Mandatory ebook distribution requirements](https://help.lulu.com/en/support/solutions/articles/64000255463-mandatory-ebook-distribution-requirements) +- [Publishing an ebook for global distribution](https://help.lulu.com/en/support/solutions/articles/64000255592-publishing-an-ebook-for-global-distribution) +- [Accessible ebooks](https://help.lulu.com/en/support/solutions/articles/64000308545-learn-more-about-accessible-ebooks) diff --git a/examples/lulu/44-page-color-amazon-failure.yaml b/examples/lulu/44-page-color-amazon-failure.yaml new file mode 100644 index 0000000..0f12a27 --- /dev/null +++ b/examples/lulu/44-page-color-amazon-failure.yaml @@ -0,0 +1,21 @@ +schema: renderflow.lulu-request/v1 +rule_pack: lulu-2026-09-11 +publication_spec: ../magazine/renderflow.yaml +channels: + - amazon + - ingram + - barnes_and_noble +product: + format: comic_book + binding: perfect_bound + paper: sixty_white + color: standard_color + page_count: 44 + trim_width_mm: 177.8 + trim_height_mm: 254.0 +artifacts: {} +metadata: + print_isbn: "9780306406157" + description: A synthetic comic fixture used to prove independent channel eligibility decisions without uploading anything. + proof_approved: false +attestations: {} diff --git a/mkdocs.yml b/mkdocs.yml index 86ed163..3571a16 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,6 +72,7 @@ nav: - Adapter Ecosystem: user-guide/adapter-ecosystem.md - EPUB and KEPUB Derivatives: user-guide/ebook-derivatives.md - Magazine Publications: user-guide/magazine-publications.md + - Lulu Publication Pack: user-guide/lulu-publication-pack.md - Super Resolution: user-guide/super-resolution.md - Upscayl Models: user-guide/upscayl-models.md - Pipelines: user-guide/pipelines.md @@ -130,6 +131,7 @@ nav: - plugin: cli-reference/plugin.md - ai: cli-reference/ai.md - tools & capabilities: cli-reference/tools.md + - publication: cli-reference/publication.md - spec: cli-reference/spec.md - Transform Reference: - Overview: transform-reference/index.md diff --git a/schemas/renderflow-adapter-catalog-v1.schema.json b/schemas/renderflow-adapter-catalog-v1.schema.json index e13d854..a15e181 100644 --- a/schemas/renderflow-adapter-catalog-v1.schema.json +++ b/schemas/renderflow-adapter-catalog-v1.schema.json @@ -33,7 +33,8 @@ "archives", "data", "ocr", - "subtitles" + "subtitles", + "publication" ] }, "execution": { diff --git a/schemas/renderflow-lulu-conformance-v1.schema.json b/schemas/renderflow-lulu-conformance-v1.schema.json new file mode 100644 index 0000000..8d22a50 --- /dev/null +++ b/schemas/renderflow-lulu-conformance-v1.schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-lulu-conformance-v1.schema.json", + "title": "Renderflow Lulu conformance report v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "provider_id", "rule_pack", "observed_on", "deterministic_offline_evaluation", "publication_schema", "publication_spec_sha256", "artifacts", "channels", "findings", "sources", "upload_performed"], + "properties": { + "schema": { "const": "renderflow.lulu-conformance/v1" }, + "provider_id": { "const": "adapter.publication.lulu" }, + "rule_pack": { "type": "string" }, + "observed_on": { "type": "string", "format": "date" }, + "deterministic_offline_evaluation": { "const": true }, + "publication_schema": { "const": "renderflow.publication/v1" }, + "publication_spec_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "artifacts": { "type": "array", "items": { "type": "object" } }, + "channels": { "type": "array", "items": { "type": "object" } }, + "findings": { "type": "array", "items": { "type": "object" } }, + "sources": { "type": "array", "items": { "type": "object" } }, + "upload_performed": { "const": false } + } +} diff --git a/schemas/renderflow-lulu-request-v1.schema.json b/schemas/renderflow-lulu-request-v1.schema.json new file mode 100644 index 0000000..6281d6c --- /dev/null +++ b/schemas/renderflow-lulu-request-v1.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-lulu-request-v1.schema.json", + "title": "Renderflow Lulu preflight request v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "rule_pack", "publication_spec", "channels", "product"], + "properties": { + "schema": { "const": "renderflow.lulu-request/v1" }, + "rule_pack": { "type": "string", "minLength": 1 }, + "publication_spec": { "type": "string", "minLength": 1 }, + "channels": { + "type": "array", + "uniqueItems": true, + "items": { "enum": ["print_direct", "lulu_bookstore", "global_distribution", "pdf_ebook", "epub_distribution", "amazon", "ingram", "barnes_and_noble"] } + }, + "product": { + "type": "object", + "additionalProperties": false, + "required": ["format", "binding", "paper", "color", "page_count", "trim_width_mm", "trim_height_mm"], + "properties": { + "format": { "type": "string", "minLength": 1 }, + "binding": { "type": "string", "minLength": 1 }, + "paper": { "type": "string", "minLength": 1 }, + "color": { "type": "string", "minLength": 1 }, + "page_count": { "type": "integer", "minimum": 1 }, + "trim_width_mm": { "type": "number", "exclusiveMinimum": 0 }, + "trim_height_mm": { "type": "number", "exclusiveMinimum": 0 } + } + }, + "artifacts": { + "type": "object", + "additionalProperties": false, + "properties": { + "interior_pdf": { "type": "string", "minLength": 1 }, + "bookstore_cover_pdf": { "type": "string", "minLength": 1 }, + "distribution_cover_pdf": { "type": "string", "minLength": 1 }, + "bookstore_cover_template": { "type": "string", "minLength": 1 }, + "distribution_cover_template": { "type": "string", "minLength": 1 }, + "pdf_ebook": { "type": "string", "minLength": 1 }, + "epub": { "type": "string", "minLength": 1 }, + "ebook_cover": { "type": "string", "minLength": 1 }, + "print_proof_pdf": { "type": "string", "minLength": 1 }, + "proof_contact_sheet": { "type": "string", "minLength": 1 }, + "ace_report": { "type": "string", "minLength": 1 } + } + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "print_isbn": { "type": "string" }, + "ebook_isbn": { "type": "string", "pattern": "^[0-9]{13}$" }, + "description": { "type": "string" }, + "keywords": { "type": "array", "items": { "type": "string" } }, + "proof_approved": { "type": "boolean" }, + "fixed_layout_compatibility_verified": { "type": "boolean" }, + "accessible_epub_claimed": { "type": "boolean" } + } + }, + "attestations": { "type": "object", "additionalProperties": { "type": "boolean" } } + } +}