diff --git a/crates/renderflow-core/data/profiles/coloring-book-v1.yaml b/crates/renderflow-core/data/profiles/coloring-book-v1.yaml new file mode 100644 index 0000000..59da70a --- /dev/null +++ b/crates/renderflow-core/data/profiles/coloring-book-v1.yaml @@ -0,0 +1,29 @@ +schema: renderflow.profile/v1 +description: Versioned rights-aware coloring-book release bundle +intermediates: cache_only +targets: + - id: coloring-book.print.book + role: print/book + format: pdf + - id: coloring-book.print.proof + role: print/proof + format: pdf + - id: coloring-book.pages.raster + role: pages/raster + format: png + requirement: optional + - id: coloring-book.pages.vector + role: pages/vector + format: svg + requirement: optional + - id: coloring-book.accessible.web + role: accessible/web + format: html + requirement: optional +policy: + validation: + required: true + failure_mode: branch_local + allow_unavailable: false + network: deny + ai: deny diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index c54b338..39cc473 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -283,6 +283,17 @@ pub fn run_cli(cli: Cli) -> Result<()> { EbookCommands::Capabilities { format } => commands::ebook::run_capabilities(&format)?, }, Some(Commands::Publication { subcommand }) => match subcommand { + PublicationCommands::ColoringBookPreflight { + contract, + output, + format, + allow_remote, + } => commands::publication::run_coloring_book_preflight( + &contract, + output.as_deref(), + &format, + allow_remote, + )?, PublicationCommands::MagazineCandidates { config, asset_role, diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index 6c4e54a..d9be103 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -63,7 +63,9 @@ pub enum Commands { renderflow build --target pdf Build only the PDF output via graph resolution\n \ renderflow build --profile everything Build the maximal available artifact forest \ - renderflow build --profile magazine Build a versioned magazine release bundle")] + renderflow build --profile magazine Build a versioned magazine release bundle + \ + renderflow build --profile coloring-book Build a rights-aware coloring-book bundle")] Build { /// Path to the renderflow configuration file #[arg(long, default_value = "renderflow.yaml", value_name = "FILE")] @@ -90,7 +92,7 @@ pub enum Commands { #[arg(long, value_name = "FORMAT", conflicts_with_all = ["all", "profile"])] target: Option, - /// Build a named, versioned derivative profile. `everything` and `magazine` are bundled. + /// Build a named, versioned derivative profile. `everything`, `magazine`, and `coloring-book` are bundled. #[arg(long, value_name = "PROFILE", conflicts_with_all = ["target", "all"])] profile: Option, @@ -424,6 +426,21 @@ pub enum VideoCommands { #[derive(Subcommand)] pub enum PublicationCommands { + /// Validate a rights-aware coloring-book contract entirely offline. + ColoringBookPreflight { + /// Coloring-book source contract in YAML or JSON. + #[arg(long, value_name = "FILE")] + contract: String, + /// Optional validation report output file. + #[arg(long, value_name = "FILE")] + output: Option, + /// Output format: text (default), json, or yaml. + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + /// Acknowledge review of provenance from remote providers. No provider is invoked. + #[arg(long)] + allow_remote: bool, + }, /// Produce candidate-only magazine briefs and metadata from Artifact DNA. MagazineCandidates { /// Renderflow v2 publication specification. @@ -876,7 +893,7 @@ pub enum GraphCommands { #[arg(long, value_name = "FORMAT", conflicts_with = "profile")] target: Option, - /// Resolve a named, versioned derivative profile. `everything` and `magazine` are bundled. + /// Resolve a named, versioned derivative profile. `everything`, `magazine`, and `coloring-book` are bundled. #[arg(long, value_name = "PROFILE", conflicts_with = "target")] profile: Option, diff --git a/crates/renderflow-core/src/commands/publication.rs b/crates/renderflow-core/src/commands/publication.rs index b3c9af0..9f4e28c 100644 --- a/crates/renderflow-core/src/commands/publication.rs +++ b/crates/renderflow-core/src/commands/publication.rs @@ -11,6 +11,7 @@ use crate::ai::{ }; use crate::artifact::ArtifactStore; use crate::dna::ArtifactDna; +use crate::publication::coloring_book::{evaluate_coloring_book, ColoringBookValidationReport}; use crate::publication::lulu::{ evaluate_request, LuluConformanceReport, LuluEligibility, LuluRulePack, }; @@ -19,6 +20,45 @@ use crate::publication::magazine::{ }; use crate::spec::load_spec; +pub fn run_coloring_book_preflight( + contract: &str, + output: Option<&str>, + format: &str, + allow_remote: bool, +) -> Result<()> { + let report = evaluate_coloring_book(Path::new(contract), allow_remote)?; + emit_coloring_book_report(&report, format, output)?; + if !report.is_valid() { + anyhow::bail!("coloring-book contract is not release-ready; see report") + } + Ok(()) +} + +fn emit_coloring_book_report( + report: &ColoringBookValidationReport, + format: &str, + output: Option<&str>, +) -> Result<()> { + if format.eq_ignore_ascii_case("text") { + let mut text = format!( + "Coloring-book preflight\nStatus: {:?}\nRelease eligible: {}\nOffline: yes\nRemote provenance opt-in: {}\n\nFindings:\n", + report.status, report.release_eligible, report.policy.remote_provider_opt_in + ); + if report.findings.is_empty() { + text.push_str(" none\n"); + } + for finding in &report.findings { + text.push_str(&format!( + " [{:?}] {} at {}: {}\n", + finding.severity, finding.code, finding.path, finding.message + )); + } + write(&text, output) + } else { + emit(report, format, output) + } +} + #[allow(clippy::too_many_arguments)] pub fn run_magazine_candidates( config: &str, diff --git a/crates/renderflow-core/src/lib.rs b/crates/renderflow-core/src/lib.rs index 326c469..9ee2eb4 100644 --- a/crates/renderflow-core/src/lib.rs +++ b/crates/renderflow-core/src/lib.rs @@ -68,6 +68,10 @@ pub use intake::{ IntakeSignalKind, ProvenanceValue, ProviderInspection, ResolvedArtifactProfile, INTAKE_SCHEMA_V1, }; +pub use publication::coloring_book::{ + evaluate_coloring_book, validate_coloring_book, ColoringBookContract, + ColoringBookValidationReport, COLORING_BOOK_REPORT_SCHEMA_V1, COLORING_BOOK_SCHEMA_V1, +}; pub use publication::magazine::{ build_magazine_candidates, create_magazine_ai_request, MagazineAiCandidate, MagazineAiStatus, MagazineCandidateEnvelope, MagazineCandidatePolicy, MAGAZINE_CANDIDATE_SCHEMA_V1, diff --git a/crates/renderflow-core/src/planning.rs b/crates/renderflow-core/src/planning.rs index 91ba1c4..ede477e 100644 --- a/crates/renderflow-core/src/planning.rs +++ b/crates/renderflow-core/src/planning.rs @@ -1402,14 +1402,20 @@ fn apply_request_overrides(spec: &mut SpecV2, request: &PlanningRequest) -> Resu ..TargetSelection::default() }; } else if let Some(profile) = &request.profile { - if matches!(profile.as_str(), "everything" | "magazine") - && !spec.profiles.contains_key(profile) + if matches!( + profile.as_str(), + "everything" | "magazine" | "coloring-book" + ) && !spec.profiles.contains_key(profile) { let (source, label) = match profile.as_str() { "magazine" => ( include_str!("../data/profiles/magazine-v1.yaml"), "magazine", ), + "coloring-book" => ( + include_str!("../data/profiles/coloring-book-v1.yaml"), + "coloring-book", + ), _ => ( include_str!("../data/profiles/everything-v1.yaml"), "everything", diff --git a/crates/renderflow-core/src/publication.rs b/crates/renderflow-core/src/publication.rs index eeac7bc..1acc5ea 100644 --- a/crates/renderflow-core/src/publication.rs +++ b/crates/renderflow-core/src/publication.rs @@ -1,5 +1,6 @@ //! Provider-neutral publication contracts and deterministic release metadata. +pub mod coloring_book; pub mod lulu; pub mod magazine; diff --git a/crates/renderflow-core/src/publication/coloring_book.rs b/crates/renderflow-core/src/publication/coloring_book.rs new file mode 100644 index 0000000..37790b4 --- /dev/null +++ b/crates/renderflow-core/src/publication/coloring_book.rs @@ -0,0 +1,1094 @@ +//! Deterministic, provider-neutral coloring-book source and preflight contracts. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::evidence::DigestEvidence; + +pub const COLORING_BOOK_SCHEMA_V1: &str = "renderflow.coloring-book/v1"; +pub const COLORING_BOOK_REPORT_SCHEMA_V1: &str = "renderflow.coloring-book-validation/v1"; +pub const COLORING_BOOK_VALIDATOR_ID: &str = "renderflow.builtin.coloring-book-preflight"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ColoringBookUse { + Private, + NonCommercial, + Public, + Commercial, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PageKind { + FrontCover, + Interior, + IntentionalBlank, + BackMatter, + BackCover, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ArtworkOrigin { + ReviewedSource, + GeneratedCandidate, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CandidateState { + Candidate, + Approved, + Rejected, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderLocality { + Local, + Remote, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ColoringBookAudience { + pub label: String, + pub complexity: String, + #[serde(default)] + pub minimum_age: Option, + #[serde(default)] + pub maximum_age: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ColoringBookGeometry { + pub width_mm: f64, + pub height_mm: f64, + pub margin_mm: f64, + pub bleed_mm: f64, + pub safe_area_mm: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LineArtPolicy { + pub minimum_stroke_pt: f64, + pub minimum_contrast_ratio: f64, + pub minimum_dpi: u32, + pub foreground: String, + pub background: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PaginationPolicy { + pub expected_page_count: u32, + pub first_interior_side: String, + pub binding: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ColoringBookPrintMetadata { + pub color_space: String, + pub interior_color: String, + pub paper: String, + pub duplex: bool, + pub embedded_fonts_required: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReviewedSourceRef { + pub path: String, + pub sha256: String, + pub approval_reference: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RightsEvidence { + pub license: String, + pub rights_holder: String, + pub source: String, + pub approval_reference: String, + pub reviewed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalEvidence { + pub state: CandidateState, + #[serde(default)] + pub reference: Option, + #[serde(default)] + pub approved_sha256: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GenerationEvidence { + pub candidate_id: String, + pub provider_id: String, + pub provider_locality: ProviderLocality, + pub model_id: String, + pub skill_id: String, + pub skill_version: String, + pub settings_sha256: String, + pub prompt_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LineArtInspection { + pub effective_dpi: u32, + pub contrast_ratio: f64, + pub minimum_stroke_pt: f64, + pub trim_verified: bool, + pub bleed_verified: bool, + pub safe_area_verified: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HygieneReview { + pub privacy_reviewed: bool, + pub copyright_reviewed: bool, + pub protected_references_reviewed: bool, + pub prompt_hygiene_reviewed: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ColoringBookArtwork { + pub path: String, + pub sha256: String, + pub origin: ArtworkOrigin, + pub approval: ApprovalEvidence, + pub rights: RightsEvidence, + pub inspection: LineArtInspection, + #[serde(default)] + pub generation: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AccessibleEquivalent { + pub alt_text: String, + pub caption: String, + #[serde(default)] + pub source_links: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ColoringBookPage { + pub id: String, + pub order: u32, + pub kind: PageKind, + #[serde(default)] + pub teaching_objective: Option, + #[serde(default)] + pub reviewed_text: Option, + #[serde(default)] + pub artwork: Option, + #[serde(default)] + pub accessibility: Option, + #[serde(default)] + pub continuity_references: Vec, + #[serde(default)] + pub blank_intent: Option, + #[serde(default)] + pub allow_duplicate_artwork: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FontAssetRef { + pub path: String, + pub sha256: String, + pub license: String, + pub embedding_approved: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ColoringBookContract { + pub schema: String, + pub book_id: String, + pub version: String, + pub title: String, + pub language: String, + pub intended_use: ColoringBookUse, + pub audience: ColoringBookAudience, + pub geometry: ColoringBookGeometry, + pub line_art: LineArtPolicy, + pub pagination: PaginationPolicy, + pub print: ColoringBookPrintMetadata, + pub hygiene: HygieneReview, + #[serde(default)] + pub fonts: Vec, + pub pages: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ColoringFindingSeverity { + Warning, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ColoringFinding { + pub severity: ColoringFindingSeverity, + pub code: String, + pub path: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ColoringArtifactEvidence { + pub page_id: String, + pub role: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reviewed_text: Option, + pub locator: String, + pub digest: DigestEvidence, + pub origin: ArtworkOrigin, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, + pub candidate_state: CandidateState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_reference: Option, + pub rights: RightsEvidence, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ColoringReviewedTextEvidence { + pub locator: String, + pub digest: DigestEvidence, + pub approval_reference: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ColoringPolicyEvidence { + pub deterministic_offline: bool, + pub network_accessed: bool, + pub ai_invoked: bool, + pub remote_provider_opt_in: bool, + pub candidate_output_authoritative: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ColoringValidationStatus { + Valid, + Invalid, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ColoringBookValidationReport { + pub schema: String, + pub validator_id: String, + pub validator_version: String, + pub contract_digest: DigestEvidence, + pub settings_digest: DigestEvidence, + pub policy: ColoringPolicyEvidence, + pub status: ColoringValidationStatus, + pub release_eligible: bool, + pub artifacts: Vec, + pub findings: Vec, +} + +impl ColoringBookValidationReport { + pub fn is_valid(&self) -> bool { + self.status == ColoringValidationStatus::Valid + } +} + +pub fn evaluate_coloring_book( + contract_path: &Path, + allow_remote: bool, +) -> Result { + let bytes = fs::read(contract_path).with_context(|| { + format!( + "failed to read coloring-book contract '{}'", + contract_path.display() + ) + })?; + let contract: ColoringBookContract = serde_yaml_ng::from_slice(&bytes).with_context(|| { + format!( + "invalid coloring-book contract '{}'", + contract_path.display() + ) + })?; + let root = contract_path.parent().unwrap_or_else(|| Path::new(".")); + validate_coloring_book(&contract, root, allow_remote) +} + +pub fn validate_coloring_book( + contract: &ColoringBookContract, + root: &Path, + allow_remote: bool, +) -> Result { + let mut findings = Vec::new(); + let mut artifacts = Vec::new(); + let mut page_ids = BTreeSet::new(); + let mut orders = BTreeSet::new(); + let mut artwork_digests: BTreeMap = BTreeMap::new(); + + if contract.schema != COLORING_BOOK_SCHEMA_V1 { + error( + &mut findings, + "coloring.schema.unsupported", + "$.schema", + "supported schema is renderflow.coloring-book/v1", + ); + } + required( + &mut findings, + &contract.book_id, + "$.book_id", + "coloring.book_id.empty", + ); + required( + &mut findings, + &contract.version, + "$.version", + "coloring.version.empty", + ); + required( + &mut findings, + &contract.title, + "$.title", + "coloring.title.empty", + ); + required( + &mut findings, + &contract.language, + "$.language", + "coloring.language.empty", + ); + required( + &mut findings, + &contract.audience.label, + "$.audience.label", + "coloring.audience.label_empty", + ); + required( + &mut findings, + &contract.audience.complexity, + "$.audience.complexity", + "coloring.audience.complexity_empty", + ); + if contract + .audience + .minimum_age + .zip(contract.audience.maximum_age) + .is_some_and(|(minimum, maximum)| minimum > maximum) + { + error( + &mut findings, + "coloring.audience.age_range", + "$.audience", + "minimum_age must not exceed maximum_age", + ); + } + validate_geometry(contract, &mut findings); + required( + &mut findings, + &contract.print.color_space, + "$.print.color_space", + "coloring.print.color_space_empty", + ); + required( + &mut findings, + &contract.print.interior_color, + "$.print.interior_color", + "coloring.print.interior_color_empty", + ); + required( + &mut findings, + &contract.print.paper, + "$.print.paper", + "coloring.print.paper_empty", + ); + if contract.pagination.expected_page_count as usize != contract.pages.len() { + error( + &mut findings, + "coloring.pagination.count", + "$.pagination.expected_page_count", + "expected_page_count must equal the number of declared pages", + ); + } + + for (index, font) in contract.fonts.iter().enumerate() { + let path = format!("$.fonts[{index}]"); + validate_file_ref(root, &font.path, &font.sha256, &path, &mut findings)?; + required( + &mut findings, + &font.license, + &format!("{path}.license"), + "coloring.font.license_missing", + ); + if !font.embedding_approved { + error( + &mut findings, + "coloring.font.embedding_unapproved", + &format!("{path}.embedding_approved"), + "font embedding must be explicitly approved", + ); + } + } + + for (index, page) in contract.pages.iter().enumerate() { + let path = format!("$.pages[{index}]"); + required( + &mut findings, + &page.id, + &format!("{path}.id"), + "coloring.page.id_empty", + ); + if !page_ids.insert(page.id.clone()) { + error( + &mut findings, + "coloring.page.id_duplicate", + &format!("{path}.id"), + "page IDs must be unique", + ); + } + if !orders.insert(page.order) { + error( + &mut findings, + "coloring.page.order_duplicate", + &format!("{path}.order"), + "page order values must be unique", + ); + } + if page.order != index as u32 + 1 { + error( + &mut findings, + "coloring.page.order_noncontiguous", + &format!("{path}.order"), + "pages must be listed in contiguous one-based order", + ); + } + + if page.kind == PageKind::IntentionalBlank { + required_option( + &mut findings, + page.blank_intent.as_deref(), + &format!("{path}.blank_intent"), + "coloring.page.blank_intent_missing", + ); + if page.reviewed_text.is_some() || page.artwork.is_some() { + error( + &mut findings, + "coloring.page.blank_has_content", + &path, + "an intentional blank must not declare reviewed text or artwork", + ); + } + continue; + } + + required_option( + &mut findings, + page.teaching_objective.as_deref(), + &format!("{path}.teaching_objective"), + "coloring.page.objective_missing", + ); + let reviewed_text = match &page.reviewed_text { + Some(source) => { + let digest = validate_file_ref( + root, + &source.path, + &source.sha256, + &format!("{path}.reviewed_text"), + &mut findings, + )?; + required( + &mut findings, + &source.approval_reference, + &format!("{path}.reviewed_text.approval_reference"), + "coloring.page.text_unapproved", + ); + digest.map(|digest| ColoringReviewedTextEvidence { + locator: source.path.clone(), + digest, + approval_reference: source.approval_reference.clone(), + }) + } + None => { + error( + &mut findings, + "coloring.page.text_missing", + &format!("{path}.reviewed_text"), + "non-blank pages require reviewed source text evidence", + ); + None + } + }; + match &page.accessibility { + Some(accessibility) => { + required( + &mut findings, + &accessibility.alt_text, + &format!("{path}.accessibility.alt_text"), + "coloring.accessibility.alt_text_missing", + ); + required( + &mut findings, + &accessibility.caption, + &format!("{path}.accessibility.caption"), + "coloring.accessibility.caption_missing", + ); + if accessibility.source_links.is_empty() { + error( + &mut findings, + "coloring.accessibility.source_link_missing", + &format!("{path}.accessibility.source_links"), + "non-blank pages require at least one source link", + ); + } + } + None => error( + &mut findings, + "coloring.accessibility.missing", + &format!("{path}.accessibility"), + "non-blank pages require an accessible equivalent", + ), + } + match &page.artwork { + Some(artwork) => validate_artwork( + contract, + page, + artwork, + root, + allow_remote, + &path, + reviewed_text, + &mut artwork_digests, + &mut artifacts, + &mut findings, + )?, + None => error( + &mut findings, + "coloring.page.artwork_missing", + &format!("{path}.artwork"), + "non-blank pages require reviewed or explicitly approved artwork", + ), + } + } + + findings.sort_by(|left, right| { + (&left.path, &left.code, &left.message).cmp(&(&right.path, &right.code, &right.message)) + }); + let invalid = findings + .iter() + .any(|finding| finding.severity == ColoringFindingSeverity::Error); + let contract_digest = digest_json(contract)?; + let settings_digest = digest_json(&serde_json::json!({ + "validator_id": COLORING_BOOK_VALIDATOR_ID, + "validator_version": env!("CARGO_PKG_VERSION"), + "schema": COLORING_BOOK_REPORT_SCHEMA_V1, + "allow_remote": allow_remote, + "geometry": contract.geometry, + "line_art": contract.line_art, + "pagination": contract.pagination, + "print": contract.print, + }))?; + Ok(ColoringBookValidationReport { + schema: COLORING_BOOK_REPORT_SCHEMA_V1.to_string(), + validator_id: COLORING_BOOK_VALIDATOR_ID.to_string(), + validator_version: env!("CARGO_PKG_VERSION").to_string(), + contract_digest, + settings_digest, + policy: ColoringPolicyEvidence { + deterministic_offline: true, + network_accessed: false, + ai_invoked: false, + remote_provider_opt_in: allow_remote, + candidate_output_authoritative: false, + }, + status: if invalid { + ColoringValidationStatus::Invalid + } else { + ColoringValidationStatus::Valid + }, + release_eligible: !invalid, + artifacts, + findings, + }) +} + +#[allow(clippy::too_many_arguments)] +fn validate_artwork( + contract: &ColoringBookContract, + page: &ColoringBookPage, + artwork: &ColoringBookArtwork, + root: &Path, + allow_remote: bool, + path: &str, + reviewed_text: Option, + artwork_digests: &mut BTreeMap, + artifacts: &mut Vec, + findings: &mut Vec, +) -> Result<()> { + let art_path = format!("{path}.artwork"); + let observed = validate_file_ref(root, &artwork.path, &artwork.sha256, &art_path, findings)?; + if let Some(first_page) = artwork_digests.insert(artwork.sha256.clone(), page.id.clone()) { + if !page.allow_duplicate_artwork { + error(findings, "coloring.page.artwork_duplicate", &art_path, &format!("artwork digest duplicates page '{first_page}'; set allow_duplicate_artwork only for an intentional reuse")); + } + } + required( + findings, + &artwork.rights.license, + &format!("{art_path}.rights.license"), + "coloring.rights.license_missing", + ); + required( + findings, + &artwork.rights.rights_holder, + &format!("{art_path}.rights.rights_holder"), + "coloring.rights.holder_missing", + ); + required( + findings, + &artwork.rights.source, + &format!("{art_path}.rights.source"), + "coloring.rights.source_missing", + ); + required( + findings, + &artwork.rights.approval_reference, + &format!("{art_path}.rights.approval_reference"), + "coloring.rights.approval_missing", + ); + if !artwork.rights.reviewed { + error( + findings, + "coloring.rights.unreviewed", + &format!("{art_path}.rights.reviewed"), + "artwork rights must be explicitly reviewed", + ); + } + if matches!( + contract.intended_use, + ColoringBookUse::Public | ColoringBookUse::Commercial + ) && [ + artwork.rights.license.as_str(), + artwork.rights.rights_holder.as_str(), + artwork.rights.source.as_str(), + artwork.rights.approval_reference.as_str(), + ] + .iter() + .any(|value| is_ambiguous(value)) + { + error( + findings, + "coloring.release.rights_ambiguous", + &format!("{art_path}.rights"), + "public and commercial release is blocked by placeholder or ambiguous rights evidence", + ); + } + if artwork.approval.state != CandidateState::Approved { + error( + findings, + "coloring.artwork.candidate_unapproved", + &format!("{art_path}.approval.state"), + "candidate artwork is never authoritative without explicit approval", + ); + } + required_option( + findings, + artwork.approval.reference.as_deref(), + &format!("{art_path}.approval.reference"), + "coloring.artwork.approval_missing", + ); + if artwork.approval.approved_sha256.as_deref() != Some(artwork.sha256.as_str()) { + error( + findings, + "coloring.artwork.approved_digest_mismatch", + &format!("{art_path}.approval.approved_sha256"), + "approval must bind the exact artwork digest", + ); + } + let inspection = &artwork.inspection; + if inspection.effective_dpi < contract.line_art.minimum_dpi { + error( + findings, + "coloring.line_art.dpi", + &format!("{art_path}.inspection.effective_dpi"), + "effective DPI is below the profile minimum", + ); + } + if inspection.contrast_ratio < contract.line_art.minimum_contrast_ratio { + error( + findings, + "coloring.line_art.contrast", + &format!("{art_path}.inspection.contrast_ratio"), + "contrast ratio is below the profile minimum", + ); + } + if inspection.minimum_stroke_pt < contract.line_art.minimum_stroke_pt { + error( + findings, + "coloring.line_art.stroke", + &format!("{art_path}.inspection.minimum_stroke_pt"), + "observed line weight is below the profile minimum", + ); + } + for (field, verified) in [ + ("trim_verified", inspection.trim_verified), + ("bleed_verified", inspection.bleed_verified), + ("safe_area_verified", inspection.safe_area_verified), + ] { + if !verified { + error( + findings, + "coloring.line_art.geometry_unverified", + &format!("{art_path}.inspection.{field}"), + "trim, bleed, and safe-area evidence must be explicit", + ); + } + } + + match artwork.origin { + ArtworkOrigin::ReviewedSource if artwork.generation.is_some() => error(findings, "coloring.artwork.unexpected_generation", &format!("{art_path}.generation"), "reviewed-source artwork must not claim generator provenance"), + ArtworkOrigin::ReviewedSource => {} + ArtworkOrigin::GeneratedCandidate => match &artwork.generation { + None => error(findings, "coloring.artwork.generation_missing", &format!("{art_path}.generation"), "generated candidates require provider, model, skill, prompt, and settings provenance"), + Some(generation) => { + for (field, value) in [("candidate_id", generation.candidate_id.as_str()), ("provider_id", generation.provider_id.as_str()), ("model_id", generation.model_id.as_str()), ("skill_id", generation.skill_id.as_str()), ("skill_version", generation.skill_version.as_str())] { + required(findings, value, &format!("{art_path}.generation.{field}"), "coloring.artwork.generation_incomplete"); + } + validate_sha256(findings, &generation.settings_sha256, &format!("{art_path}.generation.settings_sha256")); + validate_sha256(findings, &generation.prompt_sha256, &format!("{art_path}.generation.prompt_sha256")); + if generation.provider_locality == ProviderLocality::Remote && !allow_remote { + error(findings, "coloring.provider.remote_opt_in_required", &format!("{art_path}.generation.provider_locality"), "remote provider provenance requires the explicit --allow-remote review opt-in"); + } + let hygiene = &contract.hygiene; + for (field, reviewed) in [("privacy_reviewed", hygiene.privacy_reviewed), ("copyright_reviewed", hygiene.copyright_reviewed), ("protected_references_reviewed", hygiene.protected_references_reviewed), ("prompt_hygiene_reviewed", hygiene.prompt_hygiene_reviewed)] { + if !reviewed { + error(findings, "coloring.hygiene.review_required", &format!("$.hygiene.{field}"), "generated candidates require all privacy, copyright, protected-reference, and prompt-hygiene reviews"); + } + } + } + }, + } + + if matches!( + contract.intended_use, + ColoringBookUse::Public | ColoringBookUse::Commercial + ) && !artwork.rights.reviewed + { + error( + findings, + "coloring.release.rights_blocked", + &format!("{art_path}.rights"), + "public and commercial release is blocked without reviewed rights evidence", + ); + } + if let Some(digest) = observed { + artifacts.push(ColoringArtifactEvidence { + page_id: page.id.clone(), + role: format!("pages/{}/line-art", page.id), + reviewed_text, + locator: artwork.path.clone(), + digest, + origin: artwork.origin, + provider_id: artwork + .generation + .as_ref() + .map(|value| value.provider_id.clone()), + model_id: artwork + .generation + .as_ref() + .map(|value| value.model_id.clone()), + candidate_state: artwork.approval.state, + approval_reference: artwork.approval.reference.clone(), + rights: artwork.rights.clone(), + }); + } + Ok(()) +} + +fn validate_geometry(contract: &ColoringBookContract, findings: &mut Vec) { + let geometry = &contract.geometry; + if geometry.width_mm <= 0.0 || geometry.height_mm <= 0.0 { + error( + findings, + "coloring.geometry.trim", + "$.geometry", + "trim width and height must be greater than zero", + ); + } + if geometry.margin_mm < 0.0 || geometry.bleed_mm < 0.0 || geometry.safe_area_mm < 0.0 { + error( + findings, + "coloring.geometry.negative", + "$.geometry", + "margin, bleed, and safe area must not be negative", + ); + } + if geometry.margin_mm + geometry.safe_area_mm >= geometry.width_mm / 2.0 + || geometry.margin_mm + geometry.safe_area_mm >= geometry.height_mm / 2.0 + { + error( + findings, + "coloring.geometry.safe_area", + "$.geometry.safe_area_mm", + "margin plus safe area must leave a positive content region", + ); + } + if contract.line_art.minimum_dpi == 0 + || contract.line_art.minimum_stroke_pt <= 0.0 + || contract.line_art.minimum_contrast_ratio < 1.0 + { + error( + findings, + "coloring.line_art.policy_invalid", + "$.line_art", + "DPI and stroke must be positive and contrast ratio must be at least 1.0", + ); + } +} + +fn validate_file_ref( + root: &Path, + locator: &str, + expected: &str, + path: &str, + findings: &mut Vec, +) -> Result> { + validate_sha256(findings, expected, &format!("{path}.sha256")); + let relative = Path::new(locator); + if relative.is_absolute() + || relative.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + error( + findings, + "coloring.asset.path_unsafe", + &format!("{path}.path"), + "asset paths must be relative and contained by the contract directory", + ); + return Ok(None); + } + let resolved: PathBuf = root.join(relative); + let canonical_root = fs::canonicalize(root).with_context(|| { + format!( + "failed to resolve coloring-book contract directory '{}'", + root.display() + ) + })?; + let canonical_asset = match fs::canonicalize(&resolved) { + Ok(path) => path, + Err(_) => { + error( + findings, + "coloring.asset.missing", + &format!("{path}.path"), + &format!( + "referenced asset '{}' could not be read", + resolved.display() + ), + ); + return Ok(None); + } + }; + if !canonical_asset.starts_with(&canonical_root) { + error( + findings, + "coloring.asset.path_unsafe", + &format!("{path}.path"), + "asset symlinks must remain contained by the contract directory", + ); + return Ok(None); + } + let bytes = fs::read(&canonical_asset).with_context(|| { + format!( + "failed to read coloring-book asset '{}'", + canonical_asset.display() + ) + })?; + let observed = format!("{:x}", Sha256::digest(&bytes)); + if observed != expected { + error( + findings, + "coloring.asset.digest_mismatch", + &format!("{path}.sha256"), + "declared SHA-256 does not match the referenced bytes", + ); + } + Ok(Some(DigestEvidence { + algorithm: "sha256".to_string(), + value: observed, + })) +} + +fn validate_sha256(findings: &mut Vec, value: &str, path: &str) { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + error( + findings, + "coloring.digest.invalid", + path, + "digest must be 64 lowercase hexadecimal SHA-256 characters", + ); + } +} + +fn digest_json(value: &impl Serialize) -> Result { + Ok(DigestEvidence { + algorithm: "sha256".to_string(), + value: format!("{:x}", Sha256::digest(serde_json::to_vec(value)?)), + }) +} + +fn required(findings: &mut Vec, value: &str, path: &str, code: &str) { + if value.trim().is_empty() { + error(findings, code, path, "value must not be empty"); + } +} + +fn required_option( + findings: &mut Vec, + value: Option<&str>, + path: &str, + code: &str, +) { + if value.is_none_or(|value| value.trim().is_empty()) { + error(findings, code, path, "value must be present and non-empty"); + } +} + +fn is_ambiguous(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "unknown" | "tbd" | "unspecified" | "pending" | "none" + ) +} + +fn error(findings: &mut Vec, code: &str, path: &str, message: &str) { + findings.push(ColoringFinding { + severity: ColoringFindingSeverity::Error, + code: code.to_string(), + path: path.to_string(), + message: message.to_string(), + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture() -> (ColoringBookContract, PathBuf) { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/coloring-book/book.yaml"); + let contract = serde_yaml_ng::from_slice(&fs::read(&path).unwrap()).unwrap(); + (contract, path.parent().unwrap().to_path_buf()) + } + + #[test] + fn synthetic_local_fixture_is_valid_and_deterministic() { + let (contract, root) = fixture(); + let first = validate_coloring_book(&contract, &root, false).unwrap(); + let second = validate_coloring_book(&contract, &root, false).unwrap(); + assert!(first.is_valid(), "{:?}", first.findings); + assert!(first.release_eligible); + assert_eq!( + serde_json::to_vec(&first).unwrap(), + serde_json::to_vec(&second).unwrap() + ); + assert!(first.policy.deterministic_offline); + assert!(!first.policy.network_accessed); + assert!(!first.policy.ai_invoked); + assert!(!first.policy.candidate_output_authoritative); + } + + #[test] + fn bundled_profile_is_offline_and_exposes_release_roles() { + let profile: crate::spec::DerivativeProfile = + serde_yaml_ng::from_str(include_str!("../../data/profiles/coloring-book-v1.yaml")) + .unwrap(); + let roles = profile + .targets + .iter() + .filter_map(|target| target.role.as_deref()) + .collect::>(); + assert!(roles.contains("print/book")); + assert!(roles.contains("print/proof")); + assert!(roles.contains("pages/raster")); + assert!(roles.contains("pages/vector")); + assert_eq!( + profile.policy.network, + Some(crate::spec::NetworkPolicy::Deny) + ); + assert_eq!(profile.policy.ai, Some(crate::spec::AiPolicy::Deny)); + } + + #[test] + fn candidate_and_remote_provider_require_explicit_approval() { + let (mut contract, root) = fixture(); + let artwork = contract.pages[0].artwork.as_mut().unwrap(); + artwork.origin = ArtworkOrigin::GeneratedCandidate; + artwork.approval.state = CandidateState::Candidate; + artwork.generation = Some(GenerationEvidence { + candidate_id: "candidate-1".to_string(), + provider_id: "provider.example".to_string(), + provider_locality: ProviderLocality::Remote, + model_id: "model.example".to_string(), + skill_id: "skill.line-art".to_string(), + skill_version: "1.0.0".to_string(), + settings_sha256: "a".repeat(64), + prompt_sha256: "b".repeat(64), + }); + let report = validate_coloring_book(&contract, &root, false).unwrap(); + assert!(!report.is_valid()); + assert!(report + .findings + .iter() + .any(|finding| finding.code == "coloring.artwork.candidate_unapproved")); + assert!(report + .findings + .iter() + .any(|finding| finding.code == "coloring.provider.remote_opt_in_required")); + } + + #[test] + fn ambiguous_rights_block_public_release() { + let (mut contract, root) = fixture(); + contract.intended_use = ColoringBookUse::Public; + contract.pages[0].artwork.as_mut().unwrap().rights.license = "TBD".to_string(); + let report = validate_coloring_book(&contract, &root, false).unwrap(); + assert!(!report.release_eligible); + assert!(report + .findings + .iter() + .any(|finding| finding.code == "coloring.release.rights_ambiguous")); + } +} diff --git a/docs/cli-reference/publication.md b/docs/cli-reference/publication.md index be2e549..b3d077e 100644 --- a/docs/cli-reference/publication.md +++ b/docs/cli-reference/publication.md @@ -3,6 +3,26 @@ Inspect pinned provider rules and preflight local publication candidates. These commands never authenticate, upload, allocate an ISBN, order a proof, or publish. +## Coloring-book preflight + +```bash +renderflow publication coloring-book-preflight \ + --contract "tests/fixtures/coloring-book/book.yaml" \ + --format json \ + --output "coloring-book-validation.json" +``` + +The command validates `renderflow.coloring-book/v1` locally and emits +`renderflow.coloring-book-validation/v1`. It hashes reviewed source, artwork, +and font references; checks pagination, geometry evidence, resolution, contrast, +line weight, accessibility, duplicate intent, rights, approvals, and generator +provenance; and exits nonzero when release is blocked. + +Remote provider provenance is blocked unless `--allow-remote` is present. The +flag only records an explicit review decision: preflight never invokes a model +or contacts a provider. Local/open-model provenance needs no remote opt-in, and +all generated output remains a candidate until approval binds its exact digest. + ## Magazine candidates ```bash diff --git a/docs/user-guide/coloring-book-publications.md b/docs/user-guide/coloring-book-publications.md new file mode 100644 index 0000000..1105cc0 --- /dev/null +++ b/docs/user-guide/coloring-book-publications.md @@ -0,0 +1,109 @@ +# Coloring-book publication profile + +The bundled `coloring-book` profile and `renderflow.coloring-book/v1` source +contract provide a deterministic, provider-neutral line-art publication +foundation. Renderflow validates and packages reviewed inputs; it does not +write publication text, generate illustrations, make rights determinations, or +publish a book. + +## Authoring contract + +Start from `tests/fixtures/coloring-book/book.yaml`, a small CC0 synthetic +geometry fixture. The contract fixes audience and complexity, trim geometry, +margins, bleed and safe area, minimum line weight, contrast and resolution, +pagination, binding, and stable page order. + +Every non-blank page records: + +- a reviewed source-text path, SHA-256 digest, and approval reference; +- a reviewed illustration or generated candidate with an exact byte digest; +- license, rights holder, source, rights review, and approval evidence; +- measured DPI, contrast and line weight plus trim/bleed/safe-area evidence; +- alt text, caption, and source links; and +- stable continuity references for character or style constraints. + +Intentional blank pages carry an explicit reason and no hidden content. +Repeated artwork is rejected unless that page explicitly marks the reuse as +intentional. + +## Candidate review and optional generators + +Canonical source refers to approved page bytes, not a particular vendor. +Generator provenance is a replaceable evidence block containing candidate, +provider, locality, model, skill version, settings digest, and prompt digest. +This makes local/open models first-class and lets providers change without +changing reviewed source text or the publication graph. + +Use the provider-neutral AI skill runtime described in [AI](ai.md) to create an +optional line-art candidate. Keep its approval state as `candidate` while it is +being reviewed. Promotion requires a human approval reference and an +`approved_sha256` matching the exact selected bytes. Privacy, copyright, +protected-reference, and prompt-hygiene reviews are mandatory for generated +candidates. + +Remote provenance additionally requires an explicit preflight opt-in: + +```bash +renderflow publication coloring-book-preflight \ + --contract "book.yaml" \ + --allow-remote \ + --format json \ + --output "validation.json" +``` + +This does not contact a provider. Without the flag, remote-generated candidates +block release. Preflight itself never invokes AI, and its report always records +`candidate_output_authoritative: false`. + +## Local preflight + +Run the normal, offline path before planning derivatives: + +```bash +renderflow publication coloring-book-preflight \ + --contract "book.yaml" \ + --format json \ + --output "validation.json" +``` + +The report includes normalized contract and validator-settings digests, exact +artwork evidence, provider/model identity where applicable, approval and rights +state, deterministic policy evidence, and sorted findings. Identical approved +inputs produce byte-stable JSON output. + +## Preview and build + +The profile asks the standard graph for print book/proof PDFs and optional +raster, vector, and accessible-web derivatives. Adapter availability remains +visible; it is never replaced by an implicit network service. + +```bash +renderflow graph plan --config "renderflow.yaml" --profile "coloring-book" +renderflow build --config "renderflow.yaml" --profile "coloring-book" --dry-run +renderflow build --config "renderflow.yaml" --profile "coloring-book" +``` + +The bundled profile denies network and AI use and requires validation. Content +repositories remain responsible for mapping their reviewed source and approved +page assets into the normal Renderflow v2 source/transform graph. + +## Print proof and release + +Inspect the `print/proof` artifact before approving a release. Compare its page +order and blank intent with the coloring-book report, verify trim and bleed +against the selected printer template, and retain the run manifest, +publication metadata, validation report, and checksums. Public or commercial +release must not proceed while any rights field is missing, ambiguous, or +unreviewed. + +Renderflow does not upload files, allocate identifiers, order proofs, +auto-publish, or assert therapeutic outcomes. Provider-specific checks remain +separate preflight packs such as the Lulu integration. + +## Rollback + +Retain the canonical contract, approved inputs, validation report, run manifest, +and checksums for each release candidate. To roll back, restore that source +revision and rebuild with the recorded toolchain. Never relabel a rejected +candidate or failed run as approved; create a new approval bound to the exact +replacement digest. diff --git a/mkdocs.yml b/mkdocs.yml index 3f9d7b0..470ab02 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 + - Coloring-book Publications: user-guide/coloring-book-publications.md - LaTeX Components: user-guide/latex-components.md - Local Font Assets: user-guide/font-assets.md - Lulu Publication Pack: user-guide/lulu-publication-pack.md diff --git a/schemas/renderflow-coloring-book-v1.schema.json b/schemas/renderflow-coloring-book-v1.schema.json new file mode 100644 index 0000000..e60cdcf --- /dev/null +++ b/schemas/renderflow-coloring-book-v1.schema.json @@ -0,0 +1,207 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-coloring-book-v1.schema.json", + "title": "Renderflow coloring-book source contract v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "book_id", "version", "title", "language", "intended_use", "audience", "geometry", "line_art", "pagination", "print", "hygiene", "pages"], + "properties": { + "schema": { "const": "renderflow.coloring-book/v1" }, + "book_id": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "language": { "type": "string", "minLength": 1 }, + "intended_use": { "enum": ["private", "non_commercial", "public", "commercial"] }, + "audience": { "$ref": "#/$defs/audience" }, + "geometry": { "$ref": "#/$defs/geometry" }, + "line_art": { "$ref": "#/$defs/lineArt" }, + "pagination": { "$ref": "#/$defs/pagination" }, + "print": { "$ref": "#/$defs/print" }, + "hygiene": { "$ref": "#/$defs/hygiene" }, + "fonts": { "type": "array", "items": { "$ref": "#/$defs/font" }, "default": [] }, + "pages": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/page" } } + }, + "$defs": { + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "audience": { + "type": "object", + "additionalProperties": false, + "required": ["label", "complexity"], + "properties": { + "label": { "type": "string", "minLength": 1 }, + "complexity": { "type": "string", "minLength": 1 }, + "minimum_age": { "type": "integer", "minimum": 0, "maximum": 255 }, + "maximum_age": { "type": "integer", "minimum": 0, "maximum": 255 } + } + }, + "geometry": { + "type": "object", + "additionalProperties": false, + "required": ["width_mm", "height_mm", "margin_mm", "bleed_mm", "safe_area_mm"], + "properties": { + "width_mm": { "type": "number", "exclusiveMinimum": 0 }, + "height_mm": { "type": "number", "exclusiveMinimum": 0 }, + "margin_mm": { "type": "number", "minimum": 0 }, + "bleed_mm": { "type": "number", "minimum": 0 }, + "safe_area_mm": { "type": "number", "minimum": 0 } + } + }, + "lineArt": { + "type": "object", + "additionalProperties": false, + "required": ["minimum_stroke_pt", "minimum_contrast_ratio", "minimum_dpi", "foreground", "background"], + "properties": { + "minimum_stroke_pt": { "type": "number", "exclusiveMinimum": 0 }, + "minimum_contrast_ratio": { "type": "number", "minimum": 1 }, + "minimum_dpi": { "type": "integer", "minimum": 1 }, + "foreground": { "type": "string", "minLength": 1 }, + "background": { "type": "string", "minLength": 1 } + } + }, + "pagination": { + "type": "object", + "additionalProperties": false, + "required": ["expected_page_count", "first_interior_side", "binding"], + "properties": { + "expected_page_count": { "type": "integer", "minimum": 1 }, + "first_interior_side": { "type": "string", "minLength": 1 }, + "binding": { "type": "string", "minLength": 1 } + } + }, + "print": { + "type": "object", + "additionalProperties": false, + "required": ["color_space", "interior_color", "paper", "duplex", "embedded_fonts_required"], + "properties": { + "color_space": { "type": "string", "minLength": 1 }, + "interior_color": { "type": "string", "minLength": 1 }, + "paper": { "type": "string", "minLength": 1 }, + "duplex": { "type": "boolean" }, + "embedded_fonts_required": { "type": "boolean" } + } + }, + "hygiene": { + "type": "object", + "additionalProperties": false, + "required": ["privacy_reviewed", "copyright_reviewed", "protected_references_reviewed", "prompt_hygiene_reviewed"], + "properties": { + "privacy_reviewed": { "type": "boolean" }, + "copyright_reviewed": { "type": "boolean" }, + "protected_references_reviewed": { "type": "boolean" }, + "prompt_hygiene_reviewed": { "type": "boolean" } + } + }, + "fileRef": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "approval_reference"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "#/$defs/sha256" }, + "approval_reference": { "type": "string", "minLength": 1 } + } + }, + "rights": { + "type": "object", + "additionalProperties": false, + "required": ["license", "rights_holder", "source", "approval_reference", "reviewed"], + "properties": { + "license": { "type": "string", "minLength": 1 }, + "rights_holder": { "type": "string", "minLength": 1 }, + "source": { "type": "string", "minLength": 1 }, + "approval_reference": { "type": "string", "minLength": 1 }, + "reviewed": { "type": "boolean" } + } + }, + "approval": { + "type": "object", + "additionalProperties": false, + "required": ["state"], + "properties": { + "state": { "enum": ["candidate", "approved", "rejected"] }, + "reference": { "type": "string", "minLength": 1 }, + "approved_sha256": { "$ref": "#/$defs/sha256" } + } + }, + "generation": { + "type": "object", + "additionalProperties": false, + "required": ["candidate_id", "provider_id", "provider_locality", "model_id", "skill_id", "skill_version", "settings_sha256", "prompt_sha256"], + "properties": { + "candidate_id": { "type": "string", "minLength": 1 }, + "provider_id": { "type": "string", "minLength": 1 }, + "provider_locality": { "enum": ["local", "remote"] }, + "model_id": { "type": "string", "minLength": 1 }, + "skill_id": { "type": "string", "minLength": 1 }, + "skill_version": { "type": "string", "minLength": 1 }, + "settings_sha256": { "$ref": "#/$defs/sha256" }, + "prompt_sha256": { "$ref": "#/$defs/sha256" } + } + }, + "inspection": { + "type": "object", + "additionalProperties": false, + "required": ["effective_dpi", "contrast_ratio", "minimum_stroke_pt", "trim_verified", "bleed_verified", "safe_area_verified"], + "properties": { + "effective_dpi": { "type": "integer", "minimum": 1 }, + "contrast_ratio": { "type": "number", "minimum": 1 }, + "minimum_stroke_pt": { "type": "number", "exclusiveMinimum": 0 }, + "trim_verified": { "type": "boolean" }, + "bleed_verified": { "type": "boolean" }, + "safe_area_verified": { "type": "boolean" } + } + }, + "artwork": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "origin", "approval", "rights", "inspection"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "#/$defs/sha256" }, + "origin": { "enum": ["reviewed_source", "generated_candidate"] }, + "approval": { "$ref": "#/$defs/approval" }, + "rights": { "$ref": "#/$defs/rights" }, + "inspection": { "$ref": "#/$defs/inspection" }, + "generation": { "$ref": "#/$defs/generation" } + } + }, + "accessibility": { + "type": "object", + "additionalProperties": false, + "required": ["alt_text", "caption"], + "properties": { + "alt_text": { "type": "string", "minLength": 1 }, + "caption": { "type": "string", "minLength": 1 }, + "source_links": { "type": "array", "items": { "type": "string", "minLength": 1 }, "default": [] } + } + }, + "page": { + "type": "object", + "additionalProperties": false, + "required": ["id", "order", "kind"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "order": { "type": "integer", "minimum": 1 }, + "kind": { "enum": ["front_cover", "interior", "intentional_blank", "back_matter", "back_cover"] }, + "teaching_objective": { "type": "string", "minLength": 1 }, + "reviewed_text": { "$ref": "#/$defs/fileRef" }, + "artwork": { "$ref": "#/$defs/artwork" }, + "accessibility": { "$ref": "#/$defs/accessibility" }, + "continuity_references": { "type": "array", "items": { "type": "string", "minLength": 1 }, "default": [] }, + "blank_intent": { "type": "string", "minLength": 1 }, + "allow_duplicate_artwork": { "type": "boolean", "default": false } + } + }, + "font": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256", "license", "embedding_approved"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "sha256": { "$ref": "#/$defs/sha256" }, + "license": { "type": "string", "minLength": 1 }, + "embedding_approved": { "type": "boolean" } + } + } + } +} diff --git a/schemas/renderflow-coloring-book-validation-v1.schema.json b/schemas/renderflow-coloring-book-validation-v1.schema.json new file mode 100644 index 0000000..3ead65e --- /dev/null +++ b/schemas/renderflow-coloring-book-validation-v1.schema.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-coloring-book-validation-v1.schema.json", + "title": "Renderflow coloring-book validation report v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "validator_id", "validator_version", "contract_digest", "settings_digest", "policy", "status", "release_eligible", "artifacts", "findings"], + "properties": { + "schema": { "const": "renderflow.coloring-book-validation/v1" }, + "validator_id": { "const": "renderflow.builtin.coloring-book-preflight" }, + "validator_version": { "type": "string", "minLength": 1 }, + "contract_digest": { "$ref": "#/$defs/digest" }, + "settings_digest": { "$ref": "#/$defs/digest" }, + "policy": { + "type": "object", + "additionalProperties": false, + "required": ["deterministic_offline", "network_accessed", "ai_invoked", "remote_provider_opt_in", "candidate_output_authoritative"], + "properties": { + "deterministic_offline": { "const": true }, + "network_accessed": { "const": false }, + "ai_invoked": { "const": false }, + "remote_provider_opt_in": { "type": "boolean" }, + "candidate_output_authoritative": { "const": false } + } + }, + "status": { "enum": ["valid", "invalid"] }, + "release_eligible": { "type": "boolean" }, + "artifacts": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["page_id", "role", "locator", "digest", "origin", "candidate_state", "rights"], + "properties": { + "page_id": { "type": "string", "minLength": 1 }, + "role": { "type": "string", "minLength": 1 }, + "reviewed_text": { + "type": "object", + "additionalProperties": false, + "required": ["locator", "digest", "approval_reference"], + "properties": { + "locator": { "type": "string", "minLength": 1 }, + "digest": { "$ref": "#/$defs/digest" }, + "approval_reference": { "type": "string", "minLength": 1 } + } + }, + "locator": { "type": "string", "minLength": 1 }, + "digest": { "$ref": "#/$defs/digest" }, + "origin": { "enum": ["reviewed_source", "generated_candidate"] }, + "provider_id": { "type": "string", "minLength": 1 }, + "model_id": { "type": "string", "minLength": 1 }, + "candidate_state": { "enum": ["candidate", "approved", "rejected"] }, + "approval_reference": { "type": "string", "minLength": 1 }, + "rights": { + "type": "object", + "additionalProperties": false, + "required": ["license", "rights_holder", "source", "approval_reference", "reviewed"], + "properties": { + "license": { "type": "string" }, + "rights_holder": { "type": "string" }, + "source": { "type": "string" }, + "approval_reference": { "type": "string" }, + "reviewed": { "type": "boolean" } + } + } + } + } + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["severity", "code", "path", "message"], + "properties": { + "severity": { "enum": ["warning", "error"] }, + "code": { "type": "string", "minLength": 1 }, + "path": { "type": "string", "minLength": 1 }, + "message": { "type": "string", "minLength": 1 } + } + } + } + }, + "$defs": { + "digest": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "value"], + "properties": { + "algorithm": { "const": "sha256" }, + "value": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } + } + } +} diff --git a/tests/fixtures/coloring-book/README.md b/tests/fixtures/coloring-book/README.md new file mode 100644 index 0000000..c8387cd --- /dev/null +++ b/tests/fixtures/coloring-book/README.md @@ -0,0 +1,24 @@ +# Synthetic coloring-book fixture + +This is a CC0, non-commercial sample made only from basic geometric shapes. It +exists to exercise the coloring-book contract and derivative profile; it is not +publication or comic content. + +```bash +renderflow publication coloring-book-preflight \ + --contract "book.yaml" \ + --format json \ + --output "validation.json" + +renderflow graph plan \ + --config "renderflow.yaml" \ + --profile "coloring-book" + +renderflow build \ + --config "renderflow.yaml" \ + --profile "coloring-book" \ + --dry-run +``` + +The normal path is entirely local. The fixture's page bytes and approval +digests are intentionally small and inspectable. diff --git a/tests/fixtures/coloring-book/book.yaml b/tests/fixtures/coloring-book/book.yaml new file mode 100644 index 0000000..a7f5d83 --- /dev/null +++ b/tests/fixtures/coloring-book/book.yaml @@ -0,0 +1,114 @@ +schema: renderflow.coloring-book/v1 +book_id: synthetic-shapes +version: "1.0.0" +title: Synthetic shape fixture +language: en +intended_use: non_commercial +audience: + label: general synthetic fixture + complexity: simple + minimum_age: 6 +geometry: + width_mm: 215.9 + height_mm: 279.4 + margin_mm: 12.7 + bleed_mm: 3.175 + safe_area_mm: 6.35 +line_art: + minimum_stroke_pt: 1.0 + minimum_contrast_ratio: 7.0 + minimum_dpi: 300 + foreground: "#000000" + background: "#ffffff" +pagination: + expected_page_count: 3 + first_interior_side: recto + binding: perfect_bound +print: + color_space: grayscale + interior_color: black_and_white + paper: uncoated_white + duplex: true + embedded_fonts_required: true +hygiene: + privacy_reviewed: true + copyright_reviewed: true + protected_references_reviewed: true + prompt_hygiene_reviewed: true +pages: + - id: front-cover + order: 1 + kind: front_cover + teaching_objective: Identify a simple geometric outline + reviewed_text: + path: reviewed-source.md + sha256: cb2e352cbd1511aa4792273b1ae3dc674839cb70204600aa5017f5600fe81383 + approval_reference: fixture-review/text-v1 + artwork: + path: front-cover.svg + sha256: 5a8fcde3d6b8da73f25cb6fd74ff3e1cee27a9eaf526b164f82cd3fdf2dbd9d7 + origin: reviewed_source + approval: + state: approved + reference: fixture-review/art-v1 + approved_sha256: 5a8fcde3d6b8da73f25cb6fd74ff3e1cee27a9eaf526b164f82cd3fdf2dbd9d7 + rights: + license: CC0-1.0 + rights_holder: Renderflow contributors + source: local synthetic fixture + approval_reference: fixture-review/rights-v1 + reviewed: true + inspection: + effective_dpi: 300 + contrast_ratio: 21.0 + minimum_stroke_pt: 2.0 + trim_verified: true + bleed_verified: true + safe_area_verified: true + accessibility: + alt_text: A black circle outline on a white field + caption: Synthetic circle outline + source_links: + - reviewed-source.md + continuity_references: + - style.synthetic-monochrome-v1 + - id: interior-2 + order: 2 + kind: interior + teaching_objective: Identify a second geometric outline + reviewed_text: + path: reviewed-source.md + sha256: cb2e352cbd1511aa4792273b1ae3dc674839cb70204600aa5017f5600fe81383 + approval_reference: fixture-review/text-v1 + artwork: + path: page-2.svg + sha256: ca07e1a082bf0d14e953657c1aaae75152587b1cd5823b3190655c95173ac80e + origin: reviewed_source + approval: + state: approved + reference: fixture-review/art-v1 + approved_sha256: ca07e1a082bf0d14e953657c1aaae75152587b1cd5823b3190655c95173ac80e + rights: + license: CC0-1.0 + rights_holder: Renderflow contributors + source: local synthetic fixture + approval_reference: fixture-review/rights-v1 + reviewed: true + inspection: + effective_dpi: 300 + contrast_ratio: 21.0 + minimum_stroke_pt: 2.0 + trim_verified: true + bleed_verified: true + safe_area_verified: true + accessibility: + alt_text: A black triangle outline on a white field + caption: Synthetic triangle outline + source_links: + - reviewed-source.md + continuity_references: + - style.synthetic-monochrome-v1 + - id: intentional-blank-3 + order: 3 + kind: intentional_blank + blank_intent: Preserve duplex pagination before back matter diff --git a/tests/fixtures/coloring-book/front-cover.svg b/tests/fixtures/coloring-book/front-cover.svg new file mode 100644 index 0000000..e6ea932 --- /dev/null +++ b/tests/fixtures/coloring-book/front-cover.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tests/fixtures/coloring-book/page-2.svg b/tests/fixtures/coloring-book/page-2.svg new file mode 100644 index 0000000..8e1a400 --- /dev/null +++ b/tests/fixtures/coloring-book/page-2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tests/fixtures/coloring-book/renderflow.yaml b/tests/fixtures/coloring-book/renderflow.yaml new file mode 100644 index 0000000..029f137 --- /dev/null +++ b/tests/fixtures/coloring-book/renderflow.yaml @@ -0,0 +1,17 @@ +schema: renderflow/v2 +sources: + - id: synthetic-shapes + role: reviewed-source + path: reviewed-source.md + format: markdown +targets: + exact: + - id: default.preview + role: accessible/web + format: html +execution: + network: deny + ai: deny +output: + bundle_root: release + naming_template: "{target.role}.{ext}" diff --git a/tests/fixtures/coloring-book/reviewed-source.md b/tests/fixtures/coloring-book/reviewed-source.md new file mode 100644 index 0000000..02d2e35 --- /dev/null +++ b/tests/fixtures/coloring-book/reviewed-source.md @@ -0,0 +1,3 @@ +# Synthetic shape exercise + +This redistribution-safe fixture names a circle and a triangle for pipeline validation only.