diff --git a/README.md b/README.md index 8885caa..7652e00 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ At its core, Renderflow models every transformation as a **directed acyclic grap - 🔄 **Transform pipeline** — Pluggable in-memory content transforms (built-in and custom) - 🤖 **AI transforms** — Ollama and OpenAI-compatible LLM integration with local caching - 🧬 **Artifact DNA** — Optional, local-first visual/layout characteristics with strict provenance, hygiene, and similarity guidance +- 🔤 **Local font registries** — Pinned font bytes, semantic typography roles, licensing metadata, and observable deterministic fallbacks - 🖼️ **Image conversion** — FFmpeg-backed format conversion across 80+ image formats (JPEG, PNG, WebP, AVIF, HEIC, EXR, and more) - 🎵 **Audio conversion** — FFmpeg-backed format conversion across 40+ audio formats (WAV, FLAC, MP3, AAC, Opus, and more) - 🎬 **Whole-file video delivery** — Typed HandBrake presets with bounded execution, provenance, and explicit Aniflow temporal boundaries diff --git a/crates/renderflow-core/src/adapters/strategy.rs b/crates/renderflow-core/src/adapters/strategy.rs index e9625ff..7551456 100644 --- a/crates/renderflow-core/src/adapters/strategy.rs +++ b/crates/renderflow-core/src/adapters/strategy.rs @@ -9,6 +9,9 @@ use crate::artifact::{ }; use crate::assets::normalize_asset_paths; use crate::config::OutputType; +use crate::font::{ + resolve_from_variables, FontResolutionReport, FontTarget, FONT_REGISTRY_VARIABLE, +}; use crate::graph::Format; use crate::input_format::InputFormat; use crate::pipeline::Pipeline; @@ -25,6 +28,7 @@ pub struct StrategyArtifactTransform { profile: Option, variables: HashMap, source_asset_root: Option, + font_resolution: Option, cache_identity: String, } @@ -44,11 +48,29 @@ impl StrategyArtifactTransform { ) })?; let variables: HashMap = variables.into_iter().collect(); - let mut identity_variables: Vec<_> = variables.iter().collect(); + let font_target = match to { + Format::Html => Some(FontTarget::Html), + Format::Pdf => Some(FontTarget::Pdf), + Format::Epub => Some(FontTarget::Epub), + Format::Docx => Some(FontTarget::Docx), + _ => None, + }; + let font_resolution = font_target + .map(|target| resolve_from_variables(&variables, target)) + .transpose()? + .flatten(); + let font_identity = font_resolution + .as_ref() + .map(FontResolutionReport::fingerprint) + .transpose()?; + let mut identity_variables: Vec<_> = variables + .iter() + .filter(|(key, _)| key.as_str() != FONT_REGISTRY_VARIABLE) + .collect(); identity_variables.sort_by(|left, right| left.0.cmp(right.0)); let cache_identity = format!( - "renderflow.strategy-adapter/v1;from={from};to={to};template={template:?};profile={profile:?};source_root={:?};variables={identity_variables:?}", - source_asset_root + "renderflow.strategy-adapter/v1;from={from};to={to};template={template:?};profile={profile:?};source_root={:?};font_resolution={font_identity:?};variables={identity_variables:?}", + source_asset_root, ); Ok(Self { from, @@ -58,6 +80,7 @@ impl StrategyArtifactTransform { profile, variables, source_asset_root, + font_resolution, cache_identity, }) } @@ -171,14 +194,20 @@ impl ArtifactTransform for StrategyArtifactTransform { output_path.display() ); } - store.import_path( - &output_path, + let mut descriptor = ArtifactDescriptor::for_format(output_format, ArtifactStorageClass::Intermediate) .with_source(input.id().clone()) .with_metadata("renderflow.adapter", "builtin.strategy") .with_metadata("renderflow.from", self.from.to_string()) - .with_metadata("renderflow.to", self.to.to_string()), - ) + .with_metadata("renderflow.to", self.to.to_string()); + if let Some(fonts) = &self.font_resolution { + descriptor = descriptor + .with_metadata("renderflow.font.registry", fonts.registry_id.clone()) + .with_metadata("renderflow.font.target", fonts.target.to_string()) + .with_metadata("renderflow.font.resolution", fonts.fingerprint()?) + .with_metadata("renderflow.font.evidence", fonts.evidence()); + } + store.import_path(&output_path, descriptor) } } diff --git a/crates/renderflow-core/src/app.rs b/crates/renderflow-core/src/app.rs index 611254b..6ab558d 100644 --- a/crates/renderflow-core/src/app.rs +++ b/crates/renderflow-core/src/app.rs @@ -3,8 +3,9 @@ use clap::Parser; use tracing::info; use crate::cli::{ - AiCommands, AiSkillCommands, Cli, Commands, DnaCommands, EbookCommands, GraphCommands, - LuluCommands, PluginCommands, PublicationCommands, SpecCommands, ToolCommands, VideoCommands, + AiCommands, AiSkillCommands, Cli, Commands, DnaCommands, EbookCommands, FontCommands, + GraphCommands, LuluCommands, PluginCommands, PublicationCommands, SpecCommands, ToolCommands, + VideoCommands, }; use crate::video::HandBrakeLimits; use crate::{commands, transforms}; @@ -173,6 +174,20 @@ pub fn run_cli(cli: Cli) -> Result<()> { format, } => commands::dna::run_compare(&left, &right, output.as_deref(), &format)?, }, + Some(Commands::Font { subcommand }) => match subcommand { + FontCommands::Validate { registry, format } => { + commands::font::run_validate(®istry, &format)? + } + FontCommands::Resolve { + registry, + target, + output, + format, + } => commands::font::run_resolve(®istry, &target, output.as_deref(), &format)?, + FontCommands::Css { registry, output } => { + commands::font::run_css(®istry, output.as_deref())? + } + }, Some(Commands::Graph { subcommand }) => match subcommand { GraphCommands::Plan { config, diff --git a/crates/renderflow-core/src/cli.rs b/crates/renderflow-core/src/cli.rs index 432ab40..e6107bd 100644 --- a/crates/renderflow-core/src/cli.rs +++ b/crates/renderflow-core/src/cli.rs @@ -253,6 +253,20 @@ pub enum Commands { subcommand: DnaCommands, }, + /// Validate and resolve pinned local font assets by semantic role + #[command( + subcommand_required = true, + arg_required_else_help = true, + after_help = "Examples:\n \ + renderflow font validate --registry fonts.yaml\n \ + renderflow font resolve --registry fonts.yaml --target pdf\n \ + renderflow font css --registry fonts.yaml --output fonts.css" + )] + Font { + #[command(subcommand)] + subcommand: FontCommands, + }, + /// Inspect, visualize, and export the transformation execution plan /// /// These commands expose the canonical execution plan that the planner @@ -755,6 +769,44 @@ pub enum DnaCommands { }, } +/// Subcommands for versioned local font registries. +#[derive(Subcommand)] +pub enum FontCommands { + /// Validate registry structure, local bytes, digests, and license artifacts + Validate { + /// Font registry YAML or JSON file + #[arg(long, value_name = "FILE")] + registry: String, + /// Output format: text (default), json, or yaml + #[arg(long, default_value = "text", value_name = "FORMAT")] + format: String, + }, + /// Resolve every configured semantic role for one renderer target + Resolve { + /// Font registry YAML or JSON file + #[arg(long, value_name = "FILE")] + registry: String, + /// Renderer target: html, latex, pdf, epub, or docx + #[arg(long, value_name = "TARGET")] + target: String, + /// Optional report output file + #[arg(long, value_name = "FILE")] + output: Option, + /// Output format: json (default) or yaml + #[arg(long, default_value = "json", value_name = "FORMAT")] + format: String, + }, + /// Emit deterministic HTML/EPUB @font-face CSS for resolved local assets + Css { + /// Font registry YAML or JSON file + #[arg(long, value_name = "FILE")] + registry: String, + /// Optional CSS output file + #[arg(long, value_name = "FILE")] + output: Option, + }, +} + /// Subcommands for `renderflow graph`. #[derive(Subcommand)] pub enum GraphCommands { diff --git a/crates/renderflow-core/src/commands/font.rs b/crates/renderflow-core/src/commands/font.rs new file mode 100644 index 0000000..29b1206 --- /dev/null +++ b/crates/renderflow-core/src/commands/font.rs @@ -0,0 +1,93 @@ +//! Handlers for local font registry validation and semantic role resolution. + +use std::path::Path; + +use anyhow::Result; +use serde::Serialize; + +use crate::font::{FontTarget, LoadedFontRegistry}; + +pub fn run_validate(registry: &str, format: &str) -> Result<()> { + let loaded = LoadedFontRegistry::load(registry)?; + let report = loaded.validate(); + emit(&report, format)?; + if !report.valid { + anyhow::bail!("font registry validation failed"); + } + Ok(()) +} + +pub fn run_resolve(registry: &str, target: &str, output: Option<&str>, format: &str) -> Result<()> { + let target: FontTarget = target.parse()?; + let loaded = LoadedFontRegistry::load(registry)?; + let report = loaded.resolve(target)?; + let serialized = serialize(&report, format)?; + if let Some(output) = output { + std::fs::write(output, serialized)?; + println!("Font resolution written to {}", Path::new(output).display()); + } else { + print!("{serialized}"); + } + Ok(()) +} + +pub fn run_css(registry: &str, output: Option<&str>) -> Result<()> { + let loaded = LoadedFontRegistry::load(registry)?; + let report = loaded.resolve(FontTarget::Html)?; + let css = report.css(); + if let Some(output) = output { + std::fs::write(output, css)?; + println!("Local font CSS written to {}", Path::new(output).display()); + } else { + print!("{css}"); + } + Ok(()) +} + +fn emit(report: &crate::font::FontValidationReport, format: &str) -> Result<()> { + if format == "text" { + println!( + "{} {} ({})", + if report.valid { "valid" } else { "invalid" }, + report.registry_id, + report.registry_digest.value + ); + for diagnostic in &report.diagnostics { + println!( + "{:?} [{}]: {}", + diagnostic.severity, diagnostic.code, diagnostic.message + ); + } + return Ok(()); + } + print!("{}", serialize(report, format)?); + Ok(()) +} + +fn serialize(value: &impl Serialize, format: &str) -> Result { + match format { + "json" => Ok(format!("{}\n", serde_json::to_string_pretty(value)?)), + "yaml" => Ok(serde_yaml_ng::to_string(value)?), + "text" => anyhow::bail!("text output is only available for validation"), + _ => anyhow::bail!("unknown output format '{format}'; expected text, json, or yaml"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialization_rejects_unknown_format() { + let value = serde_json::json!({"valid": true}); + assert!(serialize(&value, "toml").is_err()); + } + + #[test] + fn severity_is_available_for_text_diagnostics() { + assert_eq!( + format!("{:?}", crate::font::FontDiagnosticSeverity::Warning), + "Warning" + ); + } +} diff --git a/crates/renderflow-core/src/commands/mod.rs b/crates/renderflow-core/src/commands/mod.rs index ca5b1f8..017d713 100644 --- a/crates/renderflow-core/src/commands/mod.rs +++ b/crates/renderflow-core/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod audit; pub mod build; pub mod dna; pub mod ebook; +pub mod font; pub mod graph; pub mod inspect; pub mod plugin; diff --git a/crates/renderflow-core/src/font.rs b/crates/renderflow-core/src/font.rs new file mode 100644 index 0000000..fac0ef4 --- /dev/null +++ b/crates/renderflow-core/src/font.rs @@ -0,0 +1,1054 @@ +//! Versioned, local-first font assets and semantic typography role resolution. +//! +//! Registries are inert declarations: loading or resolving one never performs +//! network access. Font acquisition is deliberately kept outside normal render +//! execution so canonical builds use reviewed, pinned local bytes. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use crate::evidence::DigestEvidence; + +pub const FONT_REGISTRY_SCHEMA_V1: &str = "renderflow.font-registry/v1"; +pub const FONT_RESOLUTION_SCHEMA_V1: &str = "renderflow.font-resolution/v1"; +pub const FONT_REGISTRY_VARIABLE: &str = "renderflow-font-registry"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FontFormat { + Ttf, + Otf, + Woff, + Woff2, +} + +impl FontFormat { + pub fn media_type(self) -> &'static str { + match self { + Self::Ttf => "font/ttf", + Self::Otf => "font/otf", + Self::Woff => "font/woff", + Self::Woff2 => "font/woff2", + } + } + + fn css_label(self) -> &'static str { + match self { + Self::Ttf => "truetype", + Self::Otf => "opentype", + Self::Woff => "woff", + Self::Woff2 => "woff2", + } + } + + fn has_valid_signature(self, bytes: &[u8]) -> bool { + match self { + Self::Ttf => bytes.starts_with(&[0x00, 0x01, 0x00, 0x00]) || bytes.starts_with(b"true"), + Self::Otf => bytes.starts_with(b"OTTO"), + Self::Woff => bytes.starts_with(b"wOFF"), + Self::Woff2 => bytes.starts_with(b"wOF2"), + } + } + + fn supports(self, target: FontTarget) -> bool { + match target { + FontTarget::Html | FontTarget::Epub => true, + FontTarget::Latex | FontTarget::Pdf => matches!(self, Self::Ttf | Self::Otf), + FontTarget::Docx => true, + } + } +} + +impl fmt::Display for FontFormat { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Ttf => "ttf", + Self::Otf => "otf", + Self::Woff => "woff", + Self::Woff2 => "woff2", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FontRole { + Body, + Heading, + Display, + Monospace, + Caption, + Math, +} + +impl fmt::Display for FontRole { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Body => "body", + Self::Heading => "heading", + Self::Display => "display", + Self::Monospace => "monospace", + Self::Caption => "caption", + Self::Math => "math", + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FontTarget { + Html, + Latex, + Pdf, + Epub, + Docx, +} + +impl fmt::Display for FontTarget { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Html => "html", + Self::Latex => "latex", + Self::Pdf => "pdf", + Self::Epub => "epub", + Self::Docx => "docx", + }) + } +} + +impl std::str::FromStr for FontTarget { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "html" => Ok(Self::Html), + "latex" | "tex" => Ok(Self::Latex), + "pdf" => Ok(Self::Pdf), + "epub" | "kepub" => Ok(Self::Epub), + "docx" => Ok(Self::Docx), + _ => anyhow::bail!( + "unknown font target '{value}'; expected html, latex, pdf, epub, or docx" + ), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FontEmbeddingPermission { + Allowed, + PrintOnly, + Prohibited, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FontRedistributionStatus { + Allowed, + Restricted, + Prohibited, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FontLicense { + pub spdx_id: String, + pub license_file: String, + pub redistribution: FontRedistributionStatus, + pub embedding: FontEmbeddingPermission, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FontProvenance { + pub source_url: String, + pub source_version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_url: Option, +} + +fn default_style() -> String { + "normal".to_string() +} + +fn default_weight() -> u16 { + 400 +} + +fn default_stretch() -> String { + "normal".to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FontAsset { + pub id: String, + pub family: String, + #[serde(default = "default_style")] + pub style: String, + #[serde(default = "default_weight")] + pub weight: u16, + #[serde(default = "default_stretch")] + pub stretch: String, + pub path: String, + pub format: FontFormat, + pub digest: DigestEvidence, + pub provenance: FontProvenance, + pub license: FontLicense, + #[serde(default)] + pub unicode_ranges: Vec, + #[serde(default)] + pub intended_roles: BTreeSet, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FontRoleBinding { + pub primary: String, + #[serde(default)] + pub fallbacks: Vec, + #[serde(default = "default_style")] + pub style: String, + #[serde(default = "default_weight")] + pub weight: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FontRegistry { + pub schema: String, + pub registry_id: String, + pub version: String, + pub assets: Vec, + pub roles: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FontDiagnosticSeverity { + Info, + Warning, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FontDiagnostic { + pub severity: FontDiagnosticSeverity, + pub code: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub role: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_id: Option, +} + +impl FontDiagnostic { + fn error(code: &str, message: impl Into, asset_id: Option<&str>) -> Self { + Self { + severity: FontDiagnosticSeverity::Error, + code: code.to_string(), + message: message.into(), + role: None, + asset_id: asset_id.map(str::to_string), + } + } + + fn rejected(role: FontRole, asset: &FontAsset, reason: impl Into) -> Self { + Self { + severity: FontDiagnosticSeverity::Warning, + code: "font.candidate.rejected".to_string(), + message: reason.into(), + role: Some(role), + asset_id: Some(asset.id.clone()), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FontValidationReport { + pub schema: String, + pub registry_id: String, + pub registry_digest: DigestEvidence, + pub valid: bool, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResolvedFont { + pub role: FontRole, + pub asset_id: String, + pub family: String, + pub style: String, + pub weight: u16, + pub format: FontFormat, + pub path: PathBuf, + pub digest: DigestEvidence, + pub fallback_index: usize, + pub license: FontLicense, + pub provenance: FontProvenance, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FontResolutionReport { + pub schema: String, + pub registry_id: String, + pub registry_version: String, + pub registry_digest: DigestEvidence, + pub target: FontTarget, + pub resolved: BTreeMap, + pub diagnostics: Vec, +} + +impl FontResolutionReport { + /// Path-independent evidence safe to attach to cache and artifact records. + pub fn evidence(&self) -> Value { + let resolved = self + .resolved + .iter() + .map(|(role, font)| { + ( + role.to_string(), + json!({ + "asset_id": font.asset_id, + "family": font.family, + "style": font.style, + "weight": font.weight, + "format": font.format, + "digest": font.digest, + "fallback_index": font.fallback_index, + "license": font.license, + "provenance": font.provenance, + }), + ) + }) + .collect::>(); + let diagnostics = self + .diagnostics + .iter() + .map(|item| { + json!({ + "severity": item.severity, + "code": item.code, + "role": item.role, + "asset_id": item.asset_id, + }) + }) + .collect::>(); + json!({ + "schema": self.schema, + "registry_id": self.registry_id, + "registry_version": self.registry_version, + "registry_digest": self.registry_digest, + "target": self.target, + "resolved": resolved, + "diagnostics": diagnostics, + }) + } + + pub fn fingerprint(&self) -> Result { + let bytes = serde_json::to_vec(&self.evidence())?; + Ok(sha256_hex(&bytes)) + } + + pub fn css(&self) -> String { + let mut css = + String::from("/* Generated by Renderflow from pinned local font assets. */\n"); + let mut emitted_assets = BTreeSet::new(); + for font in self.resolved.values() { + if !emitted_assets.insert(font.asset_id.as_str()) { + continue; + } + let source = file_url(&font.path); + css.push_str(&format!( + "@font-face {{ font-family: \"{}\"; src: url(\"{}\") format(\"{}\"); font-style: {}; font-weight: {}; font-display: swap; }}\n", + css_escape(&font.family), + css_escape(&source), + font.format.css_label(), + css_identifier(&font.style), + font.weight + )); + } + if let Some(font) = self.resolved.get(&FontRole::Body) { + css.push_str(&format!( + ":root {{ --renderflow-font-body: \"{}\"; }}\nbody {{ font-family: var(--renderflow-font-body); font-style: {}; font-weight: {}; }}\n", + css_escape(&font.family), + css_identifier(&font.style), + font.weight + )); + } + for (role, selector, variable) in [ + (FontRole::Heading, "h1, h2, h3, h4, h5, h6", "heading"), + (FontRole::Display, ".display, header", "display"), + (FontRole::Monospace, "code, pre, kbd, samp", "monospace"), + (FontRole::Caption, "figcaption, caption", "caption"), + (FontRole::Math, ".math", "math"), + ] { + if let Some(font) = self.resolved.get(&role) { + css.push_str(&format!( + ":root {{ --renderflow-font-{variable}: \"{}\"; }}\n{selector} {{ font-family: var(--renderflow-font-{variable}); font-style: {}; font-weight: {}; }}\n", + css_escape(&font.family), + css_identifier(&font.style), + font.weight + )); + } + } + css + } + + pub fn latex_variables(&self) -> BTreeMap { + let mut variables = BTreeMap::new(); + for (role, family_key, file_key) in [ + (FontRole::Body, "mainfont", "renderflow-main-font-file"), + (FontRole::Heading, "sansfont", "renderflow-sans-font-file"), + (FontRole::Monospace, "monofont", "renderflow-mono-font-file"), + ] { + if let Some(font) = self.resolved.get(&role) { + variables.insert(family_key.to_string(), font.family.clone()); + variables.insert( + file_key.to_string(), + font.path.to_string_lossy().into_owned(), + ); + } + } + variables + } + + pub fn embeddable_paths(&self) -> Vec { + self.resolved + .values() + .map(|font| font.path.clone()) + .collect::>() + .into_iter() + .collect() + } +} + +#[derive(Debug, Clone)] +pub struct LoadedFontRegistry { + pub registry: FontRegistry, + pub source_path: PathBuf, + pub registry_digest: DigestEvidence, + root: PathBuf, +} + +impl LoadedFontRegistry { + pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + let bytes = fs::read(path) + .with_context(|| format!("failed to read font registry '{}'", path.display()))?; + let registry = match path.extension().and_then(|value| value.to_str()) { + Some("json") => serde_json::from_slice(&bytes) + .with_context(|| format!("invalid JSON font registry '{}'", path.display()))?, + _ => serde_yaml_ng::from_slice(&bytes) + .with_context(|| format!("invalid YAML font registry '{}'", path.display()))?, + }; + let source_path = path.canonicalize().with_context(|| { + format!("failed to canonicalize font registry '{}'", path.display()) + })?; + let root = source_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(); + Ok(Self { + registry, + source_path, + registry_digest: digest(&bytes), + root, + }) + } + + pub fn validate(&self) -> FontValidationReport { + let mut diagnostics = Vec::new(); + if self.registry.schema != FONT_REGISTRY_SCHEMA_V1 { + diagnostics.push(FontDiagnostic::error( + "font.schema.unsupported", + format!( + "expected schema '{FONT_REGISTRY_SCHEMA_V1}', got '{}'", + self.registry.schema + ), + None, + )); + } + if !is_stable_id(&self.registry.registry_id) { + diagnostics.push(FontDiagnostic::error( + "font.registry_id.invalid", + "registry_id must use ASCII letters, digits, '.', '_', or '-'", + None, + )); + } + if self.registry.assets.is_empty() { + diagnostics.push(FontDiagnostic::error( + "font.assets.empty", + "at least one local font asset is required", + None, + )); + } + if self.registry.roles.is_empty() { + diagnostics.push(FontDiagnostic::error( + "font.roles.empty", + "at least one semantic typography role is required", + None, + )); + } + + let mut ids = BTreeSet::new(); + for asset in &self.registry.assets { + if !is_stable_id(&asset.id) { + diagnostics.push(FontDiagnostic::error( + "font.asset_id.invalid", + "font asset id must be stable", + Some(&asset.id), + )); + } + if !ids.insert(asset.id.as_str()) { + diagnostics.push(FontDiagnostic::error( + "font.asset_id.duplicate", + "font asset id is declared more than once", + Some(&asset.id), + )); + } + if asset.family.trim().is_empty() { + diagnostics.push(FontDiagnostic::error( + "font.family.empty", + "font family must not be empty", + Some(&asset.id), + )); + } + if !(1..=1000).contains(&asset.weight) { + diagnostics.push(FontDiagnostic::error( + "font.weight.invalid", + "font weight must be between 1 and 1000", + Some(&asset.id), + )); + } + if asset.digest.algorithm != "sha256" || asset.digest.value.len() != 64 { + diagnostics.push(FontDiagnostic::error( + "font.digest.invalid", + "font digest must be a 64-character sha256 value", + Some(&asset.id), + )); + } + for unicode_range in &asset.unicode_ranges { + if !is_unicode_range(unicode_range) { + diagnostics.push(FontDiagnostic::error( + "font.unicode_range.invalid", + format!("invalid Unicode range declaration '{unicode_range}'"), + Some(&asset.id), + )); + } + } + let path = self.asset_path(asset); + match fs::read(&path) { + Ok(bytes) => { + let actual = sha256_hex(&bytes); + if actual != asset.digest.value.to_ascii_lowercase() { + diagnostics.push(FontDiagnostic::error( + "font.digest.mismatch", + format!( + "font bytes at '{}' do not match the pinned digest", + path.display() + ), + Some(&asset.id), + )); + } + if !asset.format.has_valid_signature(&bytes) { + diagnostics.push(FontDiagnostic::error( + "font.format.signature_mismatch", + format!("font bytes do not have a valid {} signature", asset.format), + Some(&asset.id), + )); + } + } + Err(_) => diagnostics.push(FontDiagnostic::error( + "font.asset.missing", + format!("local font asset '{}' does not exist", path.display()), + Some(&asset.id), + )), + } + let license_path = self.root.join(&asset.license.license_file); + if !license_path.is_file() { + diagnostics.push(FontDiagnostic::error( + "font.license.missing", + format!( + "license artifact '{}' does not exist", + license_path.display() + ), + Some(&asset.id), + )); + } + } + + for (role, binding) in &self.registry.roles { + for id in std::iter::once(&binding.primary).chain(binding.fallbacks.iter()) { + if !ids.contains(id.as_str()) { + diagnostics.push(FontDiagnostic { + severity: FontDiagnosticSeverity::Error, + code: "font.role.asset_unknown".to_string(), + message: format!("role '{role}' references unknown font asset '{id}'"), + role: Some(*role), + asset_id: Some(id.clone()), + }); + } + } + } + + FontValidationReport { + schema: FONT_REGISTRY_SCHEMA_V1.to_string(), + registry_id: self.registry.registry_id.clone(), + registry_digest: self.registry_digest.clone(), + valid: !diagnostics + .iter() + .any(|item| item.severity == FontDiagnosticSeverity::Error), + diagnostics, + } + } + + pub fn resolve(&self, target: FontTarget) -> Result { + let validation = self.validate(); + let structural_errors = validation.diagnostics.iter().filter(|item| { + item.severity == FontDiagnosticSeverity::Error + && !matches!( + item.code.as_str(), + "font.asset.missing" + | "font.digest.mismatch" + | "font.format.signature_mismatch" + ) + }); + let structural_errors = structural_errors + .map(|item| item.message.clone()) + .collect::>(); + if !structural_errors.is_empty() { + anyhow::bail!("font registry is invalid: {}", structural_errors.join("; ")); + } + + let assets = self + .registry + .assets + .iter() + .map(|asset| (asset.id.as_str(), asset)) + .collect::>(); + let mut resolved = BTreeMap::new(); + let mut diagnostics = Vec::new(); + + for (role, binding) in &self.registry.roles { + let candidates = std::iter::once(&binding.primary).chain(binding.fallbacks.iter()); + let mut selected = None; + for (index, id) in candidates.enumerate() { + let asset = assets + .get(id.as_str()) + .expect("validated role references an existing asset"); + let path = self.asset_path(asset); + if !path.is_file() { + diagnostics.push(FontDiagnostic::rejected( + *role, + asset, + format!("local asset '{}' is missing", path.display()), + )); + continue; + } + let bytes = fs::read(&path)?; + if sha256_hex(&bytes) != asset.digest.value.to_ascii_lowercase() { + diagnostics.push(FontDiagnostic::rejected( + *role, + asset, + "local bytes do not match the pinned sha256 digest", + )); + continue; + } + if !asset.format.has_valid_signature(&bytes) { + diagnostics.push(FontDiagnostic::rejected( + *role, + asset, + format!("local bytes are not a valid {} container", asset.format), + )); + continue; + } + if !asset.format.supports(target) { + diagnostics.push(FontDiagnostic::rejected( + *role, + asset, + format!( + "{} fonts are unsupported by the {target} adapter", + asset.format + ), + )); + continue; + } + if asset.style != binding.style || asset.weight != binding.weight { + diagnostics.push(FontDiagnostic::rejected( + *role, + asset, + format!( + "style/weight {} {} does not satisfy requested {} {}", + asset.style, asset.weight, binding.style, binding.weight + ), + )); + continue; + } + if !embedding_permitted(asset, target) { + diagnostics.push(FontDiagnostic::rejected( + *role, + asset, + format!("license metadata does not permit embedding for {target}"), + )); + continue; + } + selected = Some(ResolvedFont { + role: *role, + asset_id: asset.id.clone(), + family: asset.family.clone(), + style: asset.style.clone(), + weight: asset.weight, + format: asset.format, + path: path.canonicalize().unwrap_or(path), + digest: asset.digest.clone(), + fallback_index: index, + license: asset.license.clone(), + provenance: asset.provenance.clone(), + }); + if index > 0 { + diagnostics.push(FontDiagnostic { + severity: FontDiagnosticSeverity::Warning, + code: "font.fallback.selected".to_string(), + message: format!( + "role '{role}' selected deterministic fallback '{}' at index {index}", + asset.id + ), + role: Some(*role), + asset_id: Some(asset.id.clone()), + }); + } + break; + } + let Some(font) = selected else { + anyhow::bail!( + "no usable local font remains for role '{role}' and target '{target}'" + ); + }; + resolved.insert(*role, font); + } + + Ok(FontResolutionReport { + schema: FONT_RESOLUTION_SCHEMA_V1.to_string(), + registry_id: self.registry.registry_id.clone(), + registry_version: self.registry.version.clone(), + registry_digest: self.registry_digest.clone(), + target, + resolved, + diagnostics, + }) + } + + fn asset_path(&self, asset: &FontAsset) -> PathBuf { + let path = Path::new(&asset.path); + if path.is_absolute() { + path.to_path_buf() + } else { + self.root.join(path) + } + } +} + +pub fn resolve_from_variables( + variables: &std::collections::HashMap, + target: FontTarget, +) -> Result> { + let Some(path) = variables.get(FONT_REGISTRY_VARIABLE) else { + return Ok(None); + }; + Ok(Some(LoadedFontRegistry::load(path)?.resolve(target)?)) +} + +fn embedding_permitted(asset: &FontAsset, target: FontTarget) -> bool { + let embedding = match asset.license.embedding { + FontEmbeddingPermission::Allowed => true, + FontEmbeddingPermission::PrintOnly => matches!(target, FontTarget::Latex | FontTarget::Pdf), + FontEmbeddingPermission::Prohibited | FontEmbeddingPermission::Unknown => false, + }; + let redistribution = match target { + FontTarget::Html | FontTarget::Epub => { + asset.license.redistribution == FontRedistributionStatus::Allowed + } + FontTarget::Latex | FontTarget::Pdf | FontTarget::Docx => { + asset.license.redistribution != FontRedistributionStatus::Prohibited + } + }; + embedding && redistribution +} + +fn digest(bytes: &[u8]) -> DigestEvidence { + DigestEvidence { + algorithm: "sha256".to_string(), + value: sha256_hex(bytes), + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn is_stable_id(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn is_unicode_range(value: &str) -> bool { + let Some(value) = value.strip_prefix("U+") else { + return false; + }; + let mut bounds = value.split('-'); + let valid_bound = |bound: &str| { + (1..=6).contains(&bound.len()) + && bound + .bytes() + .all(|byte| byte.is_ascii_hexdigit() || byte == b'?') + }; + let Some(start) = bounds.next() else { + return false; + }; + valid_bound(start) && bounds.next().is_none_or(valid_bound) && bounds.next().is_none() +} + +fn file_url(path: &Path) -> String { + format!("file://{}", path.to_string_lossy().replace('\\', "/")) +} + +fn css_escape(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn css_identifier(value: &str) -> String { + value + .chars() + .filter(|character| character.is_ascii_alphanumeric() || *character == '-') + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write_fixture(dir: &TempDir, name: &str, bytes: &[u8]) -> (String, String) { + let path = dir.path().join(name); + fs::write(&path, bytes).unwrap(); + (name.to_string(), sha256_hex(bytes)) + } + + fn fixture_registry(dir: &TempDir) -> LoadedFontRegistry { + let (missing_path, missing_digest) = ( + "Missing-Regular.otf".to_string(), + sha256_hex(b"OTTOmissing"), + ); + let (body_path, body_digest) = write_fixture(dir, "Fixture-Regular.otf", b"OTTOfixture"); + let (mono_path, mono_digest) = write_fixture(dir, "Fixture-Mono.ttf", &[0, 1, 0, 0, 1]); + fs::write( + dir.path().join("OFL.txt"), + "SIL Open Font License 1.1 fixture", + ) + .unwrap(); + let asset = + |id: &str, family: &str, path: String, format, digest: String, role| FontAsset { + id: id.to_string(), + family: family.to_string(), + style: "normal".to_string(), + weight: 400, + stretch: "normal".to_string(), + path, + format, + digest: DigestEvidence { + algorithm: "sha256".to_string(), + value: digest, + }, + provenance: FontProvenance { + source_url: "https://example.invalid/font".to_string(), + source_version: "fixture-v1".to_string(), + project_url: None, + }, + license: FontLicense { + spdx_id: "OFL-1.1".to_string(), + license_file: "OFL.txt".to_string(), + redistribution: FontRedistributionStatus::Allowed, + embedding: FontEmbeddingPermission::Allowed, + }, + unicode_ranges: vec!["U+0000-00FF".to_string()], + intended_roles: BTreeSet::from([role]), + }; + let registry = FontRegistry { + schema: FONT_REGISTRY_SCHEMA_V1.to_string(), + registry_id: "fixture.fonts".to_string(), + version: "1.0.0".to_string(), + assets: vec![ + asset( + "font.missing", + "Missing", + missing_path, + FontFormat::Otf, + missing_digest, + FontRole::Body, + ), + asset( + "font.body", + "Fixture Serif", + body_path, + FontFormat::Otf, + body_digest, + FontRole::Body, + ), + asset( + "font.mono", + "Fixture Mono", + mono_path, + FontFormat::Ttf, + mono_digest, + FontRole::Monospace, + ), + ], + roles: BTreeMap::from([ + ( + FontRole::Body, + FontRoleBinding { + primary: "font.missing".to_string(), + fallbacks: vec!["font.body".to_string()], + style: "normal".to_string(), + weight: 400, + }, + ), + ( + FontRole::Monospace, + FontRoleBinding { + primary: "font.mono".to_string(), + fallbacks: Vec::new(), + style: "normal".to_string(), + weight: 400, + }, + ), + ]), + }; + let registry_path = dir.path().join("registry.yaml"); + fs::write(®istry_path, serde_yaml_ng::to_string(®istry).unwrap()).unwrap(); + LoadedFontRegistry::load(registry_path).unwrap() + } + + #[test] + fn resolves_semantic_roles_with_observable_fallback() { + let dir = TempDir::new().unwrap(); + let report = fixture_registry(&dir).resolve(FontTarget::Pdf).unwrap(); + assert_eq!(report.resolved[&FontRole::Body].asset_id, "font.body"); + assert_eq!(report.resolved[&FontRole::Body].fallback_index, 1); + assert!(report + .diagnostics + .iter() + .any(|item| item.code == "font.fallback.selected")); + } + + #[test] + fn emits_html_font_face_and_semantic_roles() { + let dir = TempDir::new().unwrap(); + let report = fixture_registry(&dir).resolve(FontTarget::Html).unwrap(); + let css = report.css(); + assert!(css.contains("@font-face")); + assert!(css.contains("--renderflow-font-body")); + assert!(css.contains("file://")); + assert!(!css.contains("http://")); + assert!(!css.contains("https://")); + } + + #[test] + fn emits_latex_variables_with_local_files() { + let dir = TempDir::new().unwrap(); + let report = fixture_registry(&dir).resolve(FontTarget::Pdf).unwrap(); + let variables = report.latex_variables(); + assert_eq!(variables["mainfont"], "Fixture Serif"); + assert!(variables["renderflow-main-font-file"].ends_with("Fixture-Regular.otf")); + assert!(variables["renderflow-mono-font-file"].ends_with("Fixture-Mono.ttf")); + } + + #[test] + fn rejects_license_blocked_web_embedding() { + let dir = TempDir::new().unwrap(); + let mut loaded = fixture_registry(&dir); + for asset in &mut loaded.registry.assets { + asset.license.redistribution = FontRedistributionStatus::Prohibited; + } + let error = loaded.resolve(FontTarget::Html).unwrap_err().to_string(); + assert!(error.contains("no usable local font")); + } + + #[test] + fn registry_digest_and_asset_digest_are_deterministic() { + let first_dir = TempDir::new().unwrap(); + let second_dir = TempDir::new().unwrap(); + let first_registry = fixture_registry(&first_dir); + let second_registry = fixture_registry(&second_dir); + let first = first_registry + .resolve(FontTarget::Pdf) + .unwrap() + .fingerprint() + .unwrap(); + let second = second_registry + .resolve(FontTarget::Pdf) + .unwrap() + .fingerprint() + .unwrap(); + assert_eq!(first, second); + assert_eq!(first_registry.registry_digest.algorithm, "sha256"); + assert_eq!( + first_registry.registry_digest, + second_registry.registry_digest + ); + } + + #[test] + fn committed_redistribution_safe_fixture_validates_and_resolves_for_html() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/fonts/registry-v1.yaml"); + let loaded = LoadedFontRegistry::load(path).unwrap(); + let validation = loaded.validate(); + assert!(validation.valid, "{:?}", validation.diagnostics); + let report = loaded.resolve(FontTarget::Html).unwrap(); + assert_eq!(report.registry_id, "fixture.redistribution-safe"); + assert_eq!(report.resolved.len(), 4); + assert_eq!(report.css().matches("@font-face").count(), 1); + } + + #[test] + fn unicode_range_declarations_are_bounded() { + assert!(is_unicode_range("U+0000-00FF")); + assert!(is_unicode_range("U+4??")); + assert!(!is_unicode_range("0000-00FF")); + assert!(!is_unicode_range("U+0000000")); + assert!(!is_unicode_range("U+0000-00FF-extra")); + } + + #[test] + fn bundled_json_schema_is_well_formed() { + let schema: serde_json::Value = serde_json::from_str(include_str!( + "../../../schemas/renderflow-font-registry-v1.schema.json" + )) + .unwrap(); + assert_eq!( + schema["$schema"], + "https://json-schema.org/draft/2020-12/schema" + ); + assert_eq!( + schema["properties"]["schema"]["const"], + FONT_REGISTRY_SCHEMA_V1 + ); + } +} diff --git a/crates/renderflow-core/src/lib.rs b/crates/renderflow-core/src/lib.rs index 91bed0d..cea152c 100644 --- a/crates/renderflow-core/src/lib.rs +++ b/crates/renderflow-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod dna; pub mod ebook; pub mod error; pub mod evidence; +pub mod font; pub mod graph; pub mod hygiene; mod image; @@ -49,6 +50,12 @@ pub use dna::{ ARTIFACT_DNA_COMPARISON_SCHEMA_V1, ARTIFACT_DNA_SCHEMA_V1, }; pub use evidence::{ArtifactManifest, RunManifest}; +pub use font::{ + FontAsset, FontDiagnostic, FontDiagnosticSeverity, FontEmbeddingPermission, FontFormat, + FontLicense, FontProvenance, FontRedistributionStatus, FontRegistry, FontResolutionReport, + FontRole, FontRoleBinding, FontTarget, FontValidationReport, LoadedFontRegistry, + FONT_REGISTRY_SCHEMA_V1, FONT_REGISTRY_VARIABLE, FONT_RESOLUTION_SCHEMA_V1, +}; pub use hygiene::{ ContentRedactionProvider, HygieneEngine, HygieneEvidence, HygieneFinding, HygieneFindingKind, HygieneOutcome, HygieneStatus, RedactionEvidence, RedactionRequest, RedactionResult, diff --git a/crates/renderflow-core/src/planning.rs b/crates/renderflow-core/src/planning.rs index 8367279..91ba1c4 100644 --- a/crates/renderflow-core/src/planning.rs +++ b/crates/renderflow-core/src/planning.rs @@ -411,6 +411,7 @@ pub fn resolve(request: PlanningRequest) -> Result { &targets, source_format, &source_path, + &request.config_path, )?; let mut plan = ExecutionPlan::from_dag(&dag, source_format, &target_formats, optimization); @@ -2272,8 +2273,14 @@ fn register_builtin_strategy_executors( targets: &[ResolvedTarget], source_format: Format, source_path: &Path, + config_path: &Path, ) -> Result<()> { let source_root = source_path.parent().map(Path::to_path_buf); + let mut variables = spec.variables.clone(); + if let Some(registry) = variables.get_mut(crate::font::FONT_REGISTRY_VARIABLE) { + let resolved = resolve_path_relative_to_config(config_path, registry); + *registry = resolved.to_string_lossy().into_owned(); + } for edge in dag.all_edges() { if edge.evidence.get("adapter").map(String::as_str) != Some(BUILTIN_ADAPTER_EVIDENCE) { continue; @@ -2302,7 +2309,7 @@ fn register_builtin_strategy_executors( edge.to, template, profile, - spec.variables.clone(), + variables.clone(), asset_root, )?; executor.register_artifact(edge.from, edge.to, Arc::new(transform)); diff --git a/crates/renderflow-core/src/strategies/ebook.rs b/crates/renderflow-core/src/strategies/ebook.rs index e9d9f7e..36b6e07 100644 --- a/crates/renderflow-core/src/strategies/ebook.rs +++ b/crates/renderflow-core/src/strategies/ebook.rs @@ -2,8 +2,9 @@ use std::collections::HashMap; use std::path::Path; use anyhow::{Context, Result}; -use tracing::info; +use tracing::{info, warn}; +use crate::font::{resolve_from_variables, FontTarget}; use crate::process::{ ProcessExecutor, ProcessExpectedOutput, ProcessNetworkPolicy, ProcessRequest, DEFAULT_CAPTURE_LIMIT_BYTES, DEFAULT_PROCESS_TIMEOUT, @@ -93,8 +94,25 @@ impl OutputStrategy for EbookStrategy { info!(input = %ctx.input_path, output = %ctx.output_path, "[dry-run] Would render e-book derivative"); return Ok(()); } + let font_workspace = tempfile::tempdir().context("failed to stage local EPUB font CSS")?; let (program, args) = match self.output { - EbookOutput::Epub => ("pandoc", self.epub_args(ctx)?), + EbookOutput::Epub => { + let mut args = self.epub_args(ctx)?; + if let Some(report) = resolve_from_variables(ctx.variables, FontTarget::Epub)? { + for diagnostic in &report.diagnostics { + warn!(code = %diagnostic.code, message = %diagnostic.message, "Font resolution diagnostic"); + } + let css_path = font_workspace.path().join("renderflow-fonts.css"); + std::fs::write(&css_path, report.css())?; + args.push("--css".to_string()); + args.push(css_path.to_string_lossy().into_owned()); + for path in report.embeddable_paths() { + args.push("--epub-embed-font".to_string()); + args.push(path.to_string_lossy().into_owned()); + } + } + ("pandoc", args) + } EbookOutput::Kepub => ( "kepubify", vec![ diff --git a/crates/renderflow-core/src/strategies/html.rs b/crates/renderflow-core/src/strategies/html.rs index e0f160c..f07a775 100644 --- a/crates/renderflow-core/src/strategies/html.rs +++ b/crates/renderflow-core/src/strategies/html.rs @@ -1,8 +1,10 @@ use anyhow::{Context, Result}; +use std::fs; use std::path::Path; -use tracing::info; +use tracing::{info, warn}; use crate::adapters::command::run_command; +use crate::font::{resolve_from_variables, FontTarget}; use crate::strategies::{OutputStrategy, PandocArgs, RenderContext}; /// Renders a document to HTML format using pandoc. @@ -50,11 +52,23 @@ impl OutputStrategy for HtmlStrategy { None }; - let builder = PandocArgs::new( + let mut builder = PandocArgs::new( ctx.input_format.as_pandoc_format(), ctx.input_path, ctx.output_path, ); + let font_workspace = tempfile::tempdir().context("failed to stage local HTML font CSS")?; + if let Some(report) = resolve_from_variables(ctx.variables, FontTarget::Html)? { + for diagnostic in &report.diagnostics { + warn!(code = %diagnostic.code, message = %diagnostic.message, "Font resolution diagnostic"); + } + let css_path = font_workspace.path().join("renderflow-fonts.css"); + fs::write(&css_path, report.css())?; + builder = builder + .with_css(css_path.to_string_lossy().into_owned()) + .with_standalone() + .with_embed_resources(); + } let args = match template_path { Some(ref path) => builder.with_template(path.as_str()), None => builder, diff --git a/crates/renderflow-core/src/strategies/pandoc_args.rs b/crates/renderflow-core/src/strategies/pandoc_args.rs index 3143daa..0eb243c 100644 --- a/crates/renderflow-core/src/strategies/pandoc_args.rs +++ b/crates/renderflow-core/src/strategies/pandoc_args.rs @@ -21,6 +21,10 @@ pub struct PandocArgs { template: Option, pdf_engine: Option, reference_doc: Option, + css: Vec, + epub_embed_fonts: Vec, + standalone: bool, + embed_resources: bool, variables: Vec<(String, String)>, } @@ -38,6 +42,10 @@ impl PandocArgs { template: None, pdf_engine: None, reference_doc: None, + css: Vec::new(), + epub_embed_fonts: Vec::new(), + standalone: false, + embed_resources: false, variables: Vec::new(), } } @@ -60,6 +68,30 @@ impl PandocArgs { self } + /// Add a local stylesheet to HTML or EPUB output. + pub fn with_css(mut self, path: impl Into) -> Self { + self.css.push(path.into()); + self + } + + /// Ask Pandoc to emit a self-contained document envelope. + pub fn with_standalone(mut self) -> Self { + self.standalone = true; + self + } + + /// Embed linked local resources into standalone HTML output. + pub fn with_embed_resources(mut self) -> Self { + self.embed_resources = true; + self + } + + /// Embed one reviewed local font asset into EPUB output. + pub fn with_epub_embed_font(mut self, path: impl Into) -> Self { + self.epub_embed_fonts.push(path.into()); + self + } + /// Add `--variable key=value` arguments for each entry in `vars`. /// /// Variables are passed to pandoc in the order returned by the iterator. @@ -85,7 +117,7 @@ impl PandocArgs { "--from".to_owned(), self.input_format, self.input_path, - "-o".to_owned(), + "--output".to_owned(), self.output_path, ]; @@ -103,6 +135,21 @@ impl PandocArgs { args.push(reference_doc); } + if self.standalone { + args.push("--standalone".to_owned()); + } + if self.embed_resources { + args.push("--embed-resources".to_owned()); + } + for css in self.css { + args.push("--css".to_owned()); + args.push(css); + } + for font in self.epub_embed_fonts { + args.push("--epub-embed-font".to_owned()); + args.push(font); + } + for (key, value) in self.variables { args.push("--variable".to_owned()); args.push(format!("{key}={value}")); @@ -121,7 +168,7 @@ mod tests { let args = PandocArgs::new("markdown", "input.md", "output.html").build(); assert_eq!( args, - vec!["--from", "markdown", "input.md", "-o", "output.html"] + vec!["--from", "markdown", "input.md", "--output", "output.html"] ); } @@ -136,7 +183,7 @@ mod tests { "--from", "markdown", "input.md", - "-o", + "--output", "output.html", "--template", "/templates/default.html" @@ -155,7 +202,7 @@ mod tests { "--from", "markdown", "input.md", - "-o", + "--output", "output.pdf", "--pdf-engine=tectonic" ] @@ -174,7 +221,7 @@ mod tests { "--from", "markdown", "input.md", - "-o", + "--output", "output.pdf", "--pdf-engine=tectonic", "--template", @@ -194,7 +241,7 @@ mod tests { "--from", "markdown", "input.md", - "-o", + "--output", "output.docx", "--reference-doc", "/templates/reference.docx", @@ -281,4 +328,22 @@ mod tests { "empty variables should produce no --variable flags" ); } + + #[test] + fn test_build_with_local_font_assets() { + let args = PandocArgs::new("markdown", "input.md", "output.html") + .with_standalone() + .with_embed_resources() + .with_css("/tmp/fonts.css") + .with_epub_embed_font("/tmp/font.woff2") + .build(); + assert!(args.contains(&"--standalone".to_string())); + assert!(args.contains(&"--embed-resources".to_string())); + assert!(args + .windows(2) + .any(|pair| pair == ["--css", "/tmp/fonts.css"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--epub-embed-font", "/tmp/font.woff2"])); + } } diff --git a/crates/renderflow-core/src/strategies/pdf.rs b/crates/renderflow-core/src/strategies/pdf.rs index 61f11fd..b9ddded 100644 --- a/crates/renderflow-core/src/strategies/pdf.rs +++ b/crates/renderflow-core/src/strategies/pdf.rs @@ -1,9 +1,10 @@ use anyhow::{Context, Result}; use std::collections::HashMap; use std::path::Path; -use tracing::info; +use tracing::{info, warn}; use crate::adapters::command::run_command; +use crate::font::{resolve_from_variables, FontTarget}; use crate::strategies::{OutputStrategy, PandocArgs, RenderContext}; use crate::toolchain::ToolRegistry; @@ -60,6 +61,26 @@ impl PdfStrategy { resolved } + + fn font_variables( + &self, + variables: &HashMap, + ) -> Result> { + let mut resolved = self.template_variables(variables); + if let Some(report) = resolve_from_variables(variables, FontTarget::Pdf)? { + for diagnostic in &report.diagnostics { + warn!(code = %diagnostic.code, message = %diagnostic.message, "Font resolution diagnostic"); + } + for (key, value) in report.latex_variables() { + resolved.insert(key, value); + } + resolved.insert( + "renderflow-font-resolution".to_string(), + report.fingerprint()?, + ); + } + Ok(resolved) + } } impl OutputStrategy for PdfStrategy { @@ -92,7 +113,7 @@ impl OutputStrategy for PdfStrategy { None }; - let variables = self.template_variables(ctx.variables); + let variables = self.font_variables(ctx.variables)?; let builder = PandocArgs::new( ctx.input_format.as_pandoc_format(), ctx.input_path, diff --git a/docs/cli-reference/font.md b/docs/cli-reference/font.md new file mode 100644 index 0000000..1c38882 --- /dev/null +++ b/docs/cli-reference/font.md @@ -0,0 +1,41 @@ +# `renderflow font` + +Inspect versioned, local-first font registries. These commands never acquire +fonts or use the network. + +## Validate a registry + +```bash +renderflow font validate \ + --registry "assets/fonts/fonts.yaml" \ + --format "text" +``` + +Validation checks schema identity, local asset presence, file signatures, +pinned digests, role references, and license artifacts. + +## Resolve semantic roles + +```bash +renderflow font resolve \ + --registry "assets/fonts/fonts.yaml" \ + --target "epub" \ + --format "json" \ + --output "dist/font-resolution.json" +``` + +Targets are `html`, `latex`, `pdf`, `epub`, and `docx`. The report records the +selected asset and `fallback_index` for every role plus rejected-candidate and +fallback diagnostics. + +## Generate local CSS + +```bash +renderflow font css \ + --registry "assets/fonts/fonts.yaml" \ + --output "dist/fonts.css" +``` + +The CSS contains `@font-face` declarations for pinned local assets and semantic +role variables. HTML and EPUB adapters generate equivalent CSS automatically +when `renderflow-font-registry` is configured. diff --git a/docs/user-guide/font-assets.md b/docs/user-guide/font-assets.md new file mode 100644 index 0000000..516acba --- /dev/null +++ b/docs/user-guide/font-assets.md @@ -0,0 +1,151 @@ +# Local font assets + +Renderflow font registries make typography reproducible without relying on a +developer workstation, a globally installed family, or a font CDN. A normal +render only reads reviewed local files. It never downloads a font. + +## Registry contract + +A registry uses the `renderflow.font-registry/v1` schema and keeps three +concerns together: + +- immutable identity: stable asset IDs, exact source revisions, and SHA-256 + digests; +- rights: SPDX license identity, a retained license artifact, redistribution + status, and embedding permission; +- typography intent: semantic roles and deterministic ordered fallbacks. + +```yaml +schema: renderflow.font-registry/v1 +registry_id: publication.orientation-fonts +version: "1.0.0" +assets: + - id: font.atkinson.regular + family: Atkinson Hyperlegible + style: normal + weight: 400 + stretch: normal + path: assets/fonts/AtkinsonHyperlegible-Regular.ttf + format: ttf + digest: + algorithm: sha256 + value: "" + provenance: + source_url: "" + source_version: "" + project_url: https://fonts.google.com/specimen/Atkinson+Hyperlegible + license: + spdx_id: OFL-1.1 + license_file: assets/fonts/OFL.txt + redistribution: allowed + embedding: allowed + unicode_ranges: [U+0000-00FF] + intended_roles: [body, caption] +roles: + body: + primary: font.atkinson.regular + caption: + primary: font.atkinson.regular +``` + +Paths are relative to the registry file. Digests identify exact bytes, not just +a family name or upstream version label. The bundled JSON Schema is +`schemas/renderflow-font-registry-v1.schema.json`. + +## Explicit acquisition workflow + +For an open Google Fonts family or another external catalog: + +1. Review the upstream license and confirm the intended distribution and + embedding are permitted. +2. Download a specific release outside Renderflow's normal build. +3. Keep only the required weights and styles in a project-owned asset pack. +4. Retain the applicable license file beside the assets. +5. Calculate each file's SHA-256 digest and record its exact upstream source and + version in the registry. +6. Review and commit the registry and, when redistribution permits, the asset + pack. A private or separately distributed asset pack can use the same model. + +Renderflow intentionally has no `download` command. This prevents a routine +render from accepting changed upstream bytes, unreviewed licenses, or a live +network dependency. + +## Semantic roles and fallbacks + +Profiles select `body`, `heading`, `display`, `monospace`, `caption`, and `math` +roles. Each role names a primary asset followed by ordered fallback asset IDs. +Resolution rejects a candidate when its bytes are missing, its digest or format +signature is wrong, its style/weight does not match, its format is unsupported +by the target, or its license metadata blocks embedding. + +The first usable candidate wins. Selecting a fallback adds +`font.fallback.selected` to the resolution diagnostics and records a nonzero +`fallback_index`; fallback is never silent. + +`unicode_ranges` records reviewed coverage claims using CSS-style `U+` ranges. +It is useful for early policy checks, but it is not proof that every glyph is +present. Publication preflight should still inspect final PDF/EPUB artifacts +when exact script coverage matters. + +## Validate and inspect + +```bash +renderflow font validate --registry "fonts.yaml" +renderflow font resolve --registry "fonts.yaml" --target "pdf" --format "json" +renderflow font css --registry "fonts.yaml" --output "fonts.css" +``` + +Validation checks registry identity, role references, local files, font +container signatures, SHA-256 values, and retained license artifacts. Resolution +additionally applies renderer-format and embedding policy. + +## Use during rendering + +Reference the registry through the provider-neutral v2 variable: + +```yaml +schema: renderflow/v2 +sources: + - id: manuscript + path: issue.md + format: markdown +targets: + exact: + - id: web + role: digital/web + format: html + - id: print + role: print/press + format: pdf + template: research/research.tex +variables: + renderflow-font-registry: assets/fonts/fonts.yaml +execution: + requirements: + local_only: true + offline: true + network: deny +``` + +Renderflow resolves the registry relative to the specification before planning. +The complete resolution fingerprint participates in transform cache identity and +is attached to output artifact metadata. + +| Target | Adapter behavior | +| --- | --- | +| HTML | Generates local `@font-face` CSS and asks Pandoc to embed resources into standalone output. | +| EPUB | Supplies generated role CSS and explicit local `--epub-embed-font` assets. | +| PDF/LaTeX | Resolves TTF/OTF role files into `mainfont`, `sansfont`, `monofont`, and the shared Renderflow LaTeX variables. | +| DOCX | Validates and records the role resolution; a reference DOCX remains authoritative for actual Office theme/font expectations. | + +WOFF/WOFF2 are suitable for HTML and EPUB. TTF/OTF are required for the current +Tectonic/fontspec PDF path. Use separate assets under one family ID when a +publication needs both web and print targets; the deterministic fallback list +can select the first target-compatible file. + +## Packaging responsibility + +Renderflow ships the registry contract and a tiny synthetic contract fixture, +not an unbounded catalog of third-party fonts. Publication owners decide whether +licensed font bytes belong in the repository, a private asset pack, or another +content-addressed distribution. Normal rendering remains local in every case. diff --git a/docs/user-guide/latex-components.md b/docs/user-guide/latex-components.md index 438ef7f..30d0481 100644 --- a/docs/user-guide/latex-components.md +++ b/docs/user-guide/latex-components.md @@ -84,11 +84,15 @@ developer workstation font. When `templates/fonts/` exists, the PDF strategy also injects it as `renderflow-font-root`. A user-provided root remains authoritative. -Local font variables name files rather than host-installed families. If a -declared file cannot be found, the style layer emits a package warning and uses -the corresponding Latin Modern role. Renderflow does not ship third-party font -binaries; publication owners remain responsible for font licenses and for PDF -font-embedding validation required by their publication contract. +Local font variables name files rather than host-installed families. Paths may +be relative to `renderflow-font-root` or absolute paths produced by a validated +[`renderflow.font-registry/v1`](font-assets.md) resolution. If a manually +configured font file is missing, the style layer emits a warning and +deliberately falls back to the corresponding Latin Modern role. Registry +fallbacks are resolved earlier and recorded in structured diagnostics. +Renderflow does not ship a third-party font catalog; publication owners remain +responsible for font licenses and final PDF font-embedding validation required +by their publication contract. ## Pandoc and Tectonic compatibility diff --git a/mkdocs.yml b/mkdocs.yml index e2c6bc0..5c5072b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,6 +73,7 @@ nav: - EPUB and KEPUB Derivatives: user-guide/ebook-derivatives.md - Magazine Publications: user-guide/magazine-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 - Whole-file Video: handbrake-adapter.md - Super Resolution: user-guide/super-resolution.md @@ -134,6 +135,7 @@ nav: - plugin: cli-reference/plugin.md - ai: cli-reference/ai.md - dna: cli-reference/dna.md + - font: cli-reference/font.md - tools & capabilities: cli-reference/tools.md - publication: cli-reference/publication.md - spec: cli-reference/spec.md diff --git a/schemas/renderflow-font-registry-v1.schema.json b/schemas/renderflow-font-registry-v1.schema.json new file mode 100644 index 0000000..e01b63b --- /dev/null +++ b/schemas/renderflow-font-registry-v1.schema.json @@ -0,0 +1,120 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://egohygiene.github.io/renderflow/schemas/renderflow-font-registry-v1.schema.json", + "title": "Renderflow local font registry v1", + "type": "object", + "additionalProperties": false, + "required": ["schema", "registry_id", "version", "assets", "roles"], + "properties": { + "schema": { "const": "renderflow.font-registry/v1" }, + "registry_id": { "$ref": "#/$defs/stableId" }, + "version": { "type": "string", "minLength": 1 }, + "assets": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/fontAsset" } + }, + "roles": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "enum": ["body", "heading", "display", "monospace", "caption", "math"] + }, + "additionalProperties": { "$ref": "#/$defs/roleBinding" } + } + }, + "$defs": { + "stableId": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "digest": { + "type": "object", + "additionalProperties": false, + "required": ["algorithm", "value"], + "properties": { + "algorithm": { "const": "sha256" }, + "value": { "type": "string", "pattern": "^[A-Fa-f0-9]{64}$" } + } + }, + "license": { + "type": "object", + "additionalProperties": false, + "required": ["spdx_id", "license_file", "redistribution", "embedding"], + "properties": { + "spdx_id": { "type": "string", "minLength": 1 }, + "license_file": { "type": "string", "minLength": 1 }, + "redistribution": { + "enum": ["allowed", "restricted", "prohibited", "unknown"] + }, + "embedding": { + "enum": ["allowed", "print_only", "prohibited", "unknown"] + } + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["source_url", "source_version"], + "properties": { + "source_url": { "type": "string", "minLength": 1 }, + "source_version": { "type": "string", "minLength": 1 }, + "project_url": { "type": ["string", "null"] } + } + }, + "fontAsset": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "family", + "path", + "format", + "digest", + "provenance", + "license" + ], + "properties": { + "id": { "$ref": "#/$defs/stableId" }, + "family": { "type": "string", "minLength": 1 }, + "style": { "type": "string", "minLength": 1, "default": "normal" }, + "weight": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 400 }, + "stretch": { "type": "string", "minLength": 1, "default": "normal" }, + "path": { "type": "string", "minLength": 1 }, + "format": { "enum": ["ttf", "otf", "woff", "woff2"] }, + "digest": { "$ref": "#/$defs/digest" }, + "provenance": { "$ref": "#/$defs/provenance" }, + "license": { "$ref": "#/$defs/license" }, + "unicode_ranges": { + "type": "array", + "items": { "type": "string", "pattern": "^U\\+[A-Fa-f0-9?]+(?:-[A-Fa-f0-9?]+)?$" }, + "default": [] + }, + "intended_roles": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": ["body", "heading", "display", "monospace", "caption", "math"] + }, + "default": [] + } + } + }, + "roleBinding": { + "type": "object", + "additionalProperties": false, + "required": ["primary"], + "properties": { + "primary": { "$ref": "#/$defs/stableId" }, + "fallbacks": { + "type": "array", + "items": { "$ref": "#/$defs/stableId" }, + "default": [] + }, + "style": { "type": "string", "minLength": 1, "default": "normal" }, + "weight": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 400 } + } + } + } +} diff --git a/templates/latex/renderflow-typography.sty b/templates/latex/renderflow-typography.sty index 4c15029..d335a9d 100644 --- a/templates/latex/renderflow-typography.sty +++ b/templates/latex/renderflow-typography.sty @@ -22,31 +22,43 @@ \ifdefempty{\RenderflowMainFontFile}{ \setmainfont{\RenderflowMainFont} }{ - \IfFileExists{\RenderflowFontRoot/\RenderflowMainFontFile}{ - \setmainfont[Path=\RenderflowFontRoot/]{\RenderflowMainFontFile} + \IfFileExists{\RenderflowMainFontFile}{ + \setmainfont{\RenderflowMainFontFile} }{ - \PackageWarning{renderflow-typography}{Local main font missing; using deterministic fallback} - \setmainfont{\RenderflowMainFont} + \IfFileExists{\RenderflowFontRoot/\RenderflowMainFontFile}{ + \setmainfont[Path=\RenderflowFontRoot/]{\RenderflowMainFontFile} + }{ + \PackageWarning{renderflow-typography}{Local main font missing; using deterministic fallback} + \setmainfont{\RenderflowMainFont} + } } } \ifdefempty{\RenderflowSansFontFile}{ \setsansfont{\RenderflowSansFont} }{ - \IfFileExists{\RenderflowFontRoot/\RenderflowSansFontFile}{ - \setsansfont[Path=\RenderflowFontRoot/]{\RenderflowSansFontFile} + \IfFileExists{\RenderflowSansFontFile}{ + \setsansfont{\RenderflowSansFontFile} }{ - \PackageWarning{renderflow-typography}{Local sans font missing; using deterministic fallback} - \setsansfont{\RenderflowSansFont} + \IfFileExists{\RenderflowFontRoot/\RenderflowSansFontFile}{ + \setsansfont[Path=\RenderflowFontRoot/]{\RenderflowSansFontFile} + }{ + \PackageWarning{renderflow-typography}{Local sans font missing; using deterministic fallback} + \setsansfont{\RenderflowSansFont} + } } } \ifdefempty{\RenderflowMonoFontFile}{ \setmonofont{\RenderflowMonoFont} }{ - \IfFileExists{\RenderflowFontRoot/\RenderflowMonoFontFile}{ - \setmonofont[Path=\RenderflowFontRoot/]{\RenderflowMonoFontFile} + \IfFileExists{\RenderflowMonoFontFile}{ + \setmonofont{\RenderflowMonoFontFile} }{ - \PackageWarning{renderflow-typography}{Local mono font missing; using deterministic fallback} - \setmonofont{\RenderflowMonoFont} + \IfFileExists{\RenderflowFontRoot/\RenderflowMonoFontFile}{ + \setmonofont[Path=\RenderflowFontRoot/]{\RenderflowMonoFontFile} + }{ + \PackageWarning{renderflow-typography}{Local mono font missing; using deterministic fallback} + \setmonofont{\RenderflowMonoFont} + } } } \fi diff --git a/tests/fixtures/fonts/CC0-1.0.txt b/tests/fixtures/fonts/CC0-1.0.txt new file mode 100644 index 0000000..8040420 --- /dev/null +++ b/tests/fixtures/fonts/CC0-1.0.txt @@ -0,0 +1,3 @@ +The synthetic Renderflow font-container fixture in this directory is dedicated +to the public domain under CC0-1.0. It exists only to exercise registry, +integrity, role-resolution, and stylesheet contracts; it is not a usable font. diff --git a/tests/fixtures/fonts/Fixture-Regular.woff2 b/tests/fixtures/fonts/Fixture-Regular.woff2 new file mode 100644 index 0000000..6cb0d3f --- /dev/null +++ b/tests/fixtures/fonts/Fixture-Regular.woff2 @@ -0,0 +1 @@ +wOF2renderflow-synthetic-contract-fixture-v1 diff --git a/tests/fixtures/fonts/registry-v1.yaml b/tests/fixtures/fonts/registry-v1.yaml new file mode 100644 index 0000000..987a4a7 --- /dev/null +++ b/tests/fixtures/fonts/registry-v1.yaml @@ -0,0 +1,39 @@ +schema: renderflow.font-registry/v1 +registry_id: fixture.redistribution-safe +version: "1.0.0" +assets: + - id: font.fixture.regular + family: Renderflow Fixture + style: normal + weight: 400 + stretch: normal + path: Fixture-Regular.woff2 + format: woff2 + digest: + algorithm: sha256 + value: 3fc5d1a606780ef42ffabee11615c4d7d797031234ad130ad9b9dfea40dee1ec + provenance: + source_url: https://github.com/egohygiene/renderflow + source_version: synthetic-contract-fixture-v1 + project_url: https://github.com/egohygiene/renderflow + license: + spdx_id: CC0-1.0 + license_file: CC0-1.0.txt + redistribution: allowed + embedding: allowed + unicode_ranges: + - U+0000-007F + intended_roles: + - body + - heading + - display + - caption +roles: + body: + primary: font.fixture.regular + heading: + primary: font.fixture.regular + display: + primary: font.fixture.regular + caption: + primary: font.fixture.regular diff --git a/tests/fixtures/fonts/renderflow.yaml b/tests/fixtures/fonts/renderflow.yaml new file mode 100644 index 0000000..2f44dbb --- /dev/null +++ b/tests/fixtures/fonts/renderflow.yaml @@ -0,0 +1,22 @@ +schema: renderflow/v2 +sources: + - id: font-showcase + role: manuscript + path: showcase.md + format: markdown +targets: + exact: + - id: font-showcase-html + role: digital/web + format: html +variables: + renderflow-font-registry: registry-v1.yaml +execution: + requirements: + local_only: true + offline: true + network: deny + ai: deny +output: + bundle_root: output + naming_template: "{target.role}.{ext}" diff --git a/tests/fixtures/fonts/showcase.md b/tests/fixtures/fonts/showcase.md new file mode 100644 index 0000000..66767b8 --- /dev/null +++ b/tests/fixtures/fonts/showcase.md @@ -0,0 +1,4 @@ +# Local font registry fixture + +This redistribution-safe source exercises semantic role resolution during +canonical planning without requiring a network request or a system font.