From 9a9bd6c0f2dfab0ca54fe668f772cf2dce14da88 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Sun, 27 Sep 2026 22:31:27 -0400 Subject: [PATCH] test: add inspectable real artifact gallery (#433) Add synthetic Markdown and ordered PNG inputs with reviewed HTML and print-PDF oracles, a retained local CLI runner, and refusal evidence. Refs: #412 --- .gitignore | 1 + Cargo.lock | 1 + ROADMAP.md | 14 + crates/renderflow-cli/Cargo.toml | 5 + .../tests/golden_conformance.rs | 145 ++++- docs/artifact-gallery.md | 58 ++ docs/conformance-corpus.md | 5 + mkdocs.yml | 1 + .../renderflow-golden-corpus-v1.schema.json | 28 +- scripts/run-artifact-gallery.sh | 107 ++++ tests/artifact_gallery_cli.rs | 571 ++++++++++++++++++ .../fixtures/golden-artifacts/v1/corpus.json | 55 +- .../v1/expected/markdown.html | 2 + .../v1/expected/print-interior.json | 24 + 14 files changed, 1012 insertions(+), 5 deletions(-) create mode 100644 docs/artifact-gallery.md create mode 100755 scripts/run-artifact-gallery.sh create mode 100644 tests/artifact_gallery_cli.rs create mode 100644 tests/fixtures/golden-artifacts/v1/expected/markdown.html create mode 100644 tests/fixtures/golden-artifacts/v1/expected/print-interior.json diff --git a/.gitignore b/.gitignore index 8044b9f..abd0e29 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ Thumbs.db # Renderflow build outputs and cache dist/ +gallery-output/ # MkDocs build output site/ diff --git a/Cargo.lock b/Cargo.lock index 161dc5c..8397448 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1706,6 +1706,7 @@ dependencies = [ "anyhow", "renderflow", "serde_json", + "sha2 0.10.9", "tempfile", ] diff --git a/ROADMAP.md b/ROADMAP.md index d754121..8b525bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,6 +26,20 @@ supersedes: [] # Renderflow Roadmap +## 2026-09-28 artifact gallery review handoff + +[#433](https://github.com/egohygiene/renderflow/issues/433) is the first bounded +[#412](https://github.com/egohygiene/renderflow/issues/412) corpus checkpoint. +Its explicit local runner creates HTML from one synthetic Markdown input and a +two-page print PDF from two synthetic RGB PNG inputs through the public CLI and +real providers. It retains both builds and compares the outputs to reviewed +oracles, independent PDF structure evidence, and typed run manifests. The normal +workspace suite checks the corpus contract without treating missing external +providers as a pass. Later fixture expansion remains in #412. The independent +fixed-layout EPUB lane [#417](https://github.com/egohygiene/renderflow/issues/417) +remains the next production capability after this review; #433 does not claim +EPUB or physical print validation. + ## 2026-09-28 print-interior PDF review handoff Checkpoint #416 adds the exact `publication.generate.pdf.interior` capability diff --git a/crates/renderflow-cli/Cargo.toml b/crates/renderflow-cli/Cargo.toml index 392ea85..4b5d89b 100644 --- a/crates/renderflow-cli/Cargo.toml +++ b/crates/renderflow-cli/Cargo.toml @@ -22,6 +22,7 @@ renderflow = { path = "../renderflow-core", package = "renderflow" } [dev-dependencies] tempfile = "3" serde_json = "1" +sha2 = "0.10" [[test]] name = "cli_tests" @@ -31,6 +32,10 @@ path = "../../tests/cli_tests.rs" name = "ordered_collection_cli" path = "../../tests/ordered_collection_cli.rs" +[[test]] +name = "artifact_gallery_cli" +path = "../../tests/artifact_gallery_cli.rs" + [[test]] name = "graph_integration_test" path = "../../tests/graph_integration_test.rs" diff --git a/crates/renderflow-core/tests/golden_conformance.rs b/crates/renderflow-core/tests/golden_conformance.rs index 873df3b..41a727a 100644 --- a/crates/renderflow-core/tests/golden_conformance.rs +++ b/crates/renderflow-core/tests/golden_conformance.rs @@ -52,6 +52,7 @@ struct Fixture { filename: String, encoding: String, payload: String, + sha256: Option, expected_format: Option, expected_media_type: String, family: String, @@ -72,6 +73,21 @@ struct Scenario { id: String, tier: String, fixtures: Vec, + #[serde(default)] + expected_artifacts: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedArtifact { + case_id: String, + role: String, + format: String, + media_type: String, + provider_id: String, + provider_version: Option, + oracle_path: String, + source_ids: Vec, } #[derive(Debug, Serialize)] @@ -224,6 +240,21 @@ fn materialize_fixture(root: &Path, fixture: &Fixture) -> Result { "hex" => decode_hex(&fixture.payload)?, other => anyhow::bail!("unsupported fixture encoding '{other}'"), }; + if let Some(expected) = &fixture.sha256 { + anyhow::ensure!( + expected.len() == 64 + && expected + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "fixture '{}' has an invalid lowercase SHA-256 declaration", + fixture.id + ); + anyhow::ensure!( + format!("{:x}", Sha256::digest(&bytes)) == *expected, + "fixture '{}' differs from its declared SHA-256", + fixture.id + ); + } let path = root.join(&fixture.filename); fs::write(&path, bytes)?; Ok(path) @@ -306,8 +337,8 @@ fn golden_artifact_forest_conformance() -> Result<()> { .map(|fixture| fixture.id.as_str()) .collect::>(); assert_eq!(fixture_ids.len(), corpus.fixtures.len()); - assert_eq!(corpus.fixtures.len(), 12); - assert_eq!(corpus.collections.len(), 1); + assert_eq!(corpus.fixtures.len(), 14); + assert_eq!(corpus.collections.len(), 2); for collection in &corpus.collections { assert!(collection.id.starts_with("fixture.collection.")); assert!(collection @@ -325,7 +356,93 @@ fn golden_artifact_forest_conformance() -> Result<()> { fixture_ids.contains(fixture.as_str()) || corpus.collections.iter().any(|value| value.id == *fixture) })); + let mut included_sources = BTreeSet::new(); + for member in &declared.fixtures { + if let Some(collection) = corpus.collections.iter().find(|item| item.id == *member) { + included_sources.extend(collection.members.iter().map(String::as_str)); + } else { + included_sources.insert(member.as_str()); + } + } + let mut case_ids = BTreeSet::new(); + for artifact in &declared.expected_artifacts { + anyhow::ensure!( + case_ids.insert(artifact.case_id.as_str()), + "scenario '{}' repeats case '{}'", + declared.id, + artifact.case_id + ); + anyhow::ensure!( + !artifact.role.is_empty() + && !artifact.format.is_empty() + && artifact.media_type.contains('/') + && artifact.provider_id.starts_with("tool.") + && artifact + .provider_version + .as_deref() + .is_none_or(|value| !value.is_empty()), + "scenario '{}' has incomplete expected artifact '{}'", + declared.id, + artifact.case_id + ); + anyhow::ensure!( + !artifact.source_ids.is_empty() + && artifact + .source_ids + .iter() + .all(|source| included_sources.contains(source.as_str())), + "scenario '{}' case '{}' names a source outside the scenario", + declared.id, + artifact.case_id + ); + let relative = Path::new(&artifact.oracle_path); + anyhow::ensure!( + relative.starts_with("expected") + && relative + .components() + .all(|part| matches!(part, std::path::Component::Normal(_))), + "scenario '{}' has an unsafe oracle locator", + declared.id + ); + let oracle = corpus_path().parent().unwrap().join(relative); + let metadata = fs::symlink_metadata(&oracle).with_context(|| { + format!( + "scenario '{}' oracle '{}' is absent", + declared.id, + oracle.display() + ) + })?; + anyhow::ensure!( + metadata.file_type().is_file() + && metadata.len() > 0 + && metadata.len() <= 1024 * 1024, + "scenario '{}' oracle '{}' must be a nonempty regular file within 1 MiB", + declared.id, + oracle.display() + ); + } } + let gallery = corpus + .scenarios + .iter() + .find(|item| item.id == "real_artifact_gallery") + .context("real artifact gallery scenario missing")?; + assert_eq!(gallery.tier, "tool_backed"); + assert_eq!(gallery.fixtures.len(), 2); + assert_eq!(gallery.expected_artifacts.len(), 2); + let gallery_sources = gallery + .expected_artifacts + .iter() + .flat_map(|artifact| artifact.source_ids.iter().map(String::as_str)) + .collect::>(); + assert_eq!( + gallery_sources, + BTreeSet::from([ + "fixture.document.markdown", + "fixture.image.print-page001", + "fixture.image.print-page002", + ]) + ); let work = tempfile::tempdir()?; let fixture_root = work.path().join("fixtures"); @@ -953,6 +1070,28 @@ fn golden_artifact_forest_conformance() -> Result<()> { .then(|| "one or more optional tool providers were not installed".to_string()), }, )); + results.insert(( + "real_artifact_gallery".to_string(), + ScenarioEvidence { + id: "real_artifact_gallery".to_string(), + status: if requested_rank == 0 { + "excluded" + } else { + "unavailable" + } + .to_string(), + assertions: vec![ + "source digests and expected oracle files are checked in the fast tier; real CLI artifact generation requires the separate gallery runner".to_string(), + ], + providers: Vec::new(), + reason: Some(if requested_rank == 0 { + "tool-backed gallery execution was not requested" + } else { + "this API corpus does not execute the real artifact gallery; run its CLI harness for generation evidence" + } + .to_string()), + }, + )); results.insert(( "maximal_release_matrix".to_string(), ScenarioEvidence { @@ -995,7 +1134,7 @@ fn golden_artifact_forest_conformance() -> Result<()> { let encoded = serde_json::to_vec_pretty(&report)?; let decoded: serde_json::Value = serde_json::from_slice(&encoded)?; assert_eq!(decoded["schema_version"], REPORT_SCHEMA); - assert_eq!(decoded["fixtures"].as_array().map(Vec::len), Some(12)); + assert_eq!(decoded["fixtures"].as_array().map(Vec::len), Some(14)); if let Some(path) = std::env::var_os("RENDERFLOW_CONFORMANCE_REPORT") { fs::write(path, encoded)?; } diff --git a/docs/artifact-gallery.md b/docs/artifact-gallery.md new file mode 100644 index 0000000..8230f70 --- /dev/null +++ b/docs/artifact-gallery.md @@ -0,0 +1,58 @@ +# Inspectable artifact gallery + +The gallery is a small synthetic, source-to-output proof. Its first cases use +one Markdown document and two ordered image pages. They run the public +Renderflow CLI with the real Pandoc and `img2pdf` providers, check generated +artifacts and evidence, and leave the complete output tree available for you +to inspect. They never process publication or personal media. + +## Run locally + +From the repository root, provide a new or empty output directory: + +```bash +scripts/run-artifact-gallery.sh --output-dir "$PWD/gallery-output" +``` + +When `img2pdf` is installed outside `PATH`, specify its executable: + +```bash +scripts/run-artifact-gallery.sh \ + --output-dir "$PWD/gallery-output" \ + --img2pdf "/absolute/path/to/img2pdf" +``` + +The runner requires Pandoc 2.0.0 or newer, exactly `img2pdf` 0.6.3, and a +Rust toolchain compatible with this checkout. Missing or incompatible +providers are reported as **unavailable** and return a nonzero exit code; +they never count as a successful test. The runner does not install tools, +access the network, trigger hosted CI, replace a nonempty output directory, +or approve a changed expected result. Pick a new output directory for each +run to preserve earlier evidence. + +The reviewed HTML baseline was generated with Pandoc 3.1.3. Other Pandoc +versions are probed and reported, and an output difference fails for review; +the runner never rewrites the baseline to accommodate provider drift. + +The retained tree includes the synthetic inputs, the generated HTML and PDF, +the corresponding `renderflow-run.json` manifests, two synthetic refusal cases, +and `comparison.json` on success. Open the HTML and PDF, then review the report and run +manifests for provider versions, ordered sources, output digests, and validation +state. The PDF case verifies page order, boxes, embedded image streams, and +lineage; this is a synthetic capability proof, not a print approval. + +## Test tiers and expected changes + +Normal tests can run without the external providers and exercise fixtures, +planning, preflight, refusal, and validation behavior. The explicit runner +executes the ignored real-provider gallery test and keeps its output. Output +bytes may be compared exactly where a provider version and output contract +make them deterministic; otherwise tests compare the relevant structure and +semantics. A missing provider is neither a pass nor an expected-output update. + +Reviewed baselines and fixtures live in source control. A difference should +fail with enough evidence to diagnose it, and any update requires a deliberate +code review. We can add more source formats, target formats, configurations, +and failure cases as their capabilities graduate, while keeping this first +local run small and inspectable. The broader conformance tiers are described +in [Golden artifact conformance](conformance-corpus.md). diff --git a/docs/conformance-corpus.md b/docs/conformance-corpus.md index 08130ea..0ca4143 100644 --- a/docs/conformance-corpus.md +++ b/docs/conformance-corpus.md @@ -5,6 +5,11 @@ redistribution-safe acceptance harness. The corpus contains synthetic payloads only. It does not contain publication, comic, customer, or third-party content, and no fixture requires network or AI access. +For a small, retained source-to-output run through the public CLI and real +local providers, see the [inspectable artifact gallery](artifact-gallery.md). +The gallery is explicitly invoked on a local machine; it complements this +versioned conformance corpus and does not silently bless changed outputs. + ## Contracts The canonical manifest is diff --git a/mkdocs.yml b/mkdocs.yml index 9abaa9c..4ae33ed 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -127,6 +127,7 @@ nav: - Resumable Provider Contract: provider-contract.md - Artifact Validation: user-guide/artifact-validation.md - Golden Conformance Corpus: conformance-corpus.md + - Inspectable Artifact Gallery: artifact-gallery.md - Plugin Architecture: architecture/plugin-architecture.md - Execution Plans: architecture/execution-plans.md - CLI Reference: diff --git a/schemas/renderflow-golden-corpus-v1.schema.json b/schemas/renderflow-golden-corpus-v1.schema.json index afafd71..4e83a89 100644 --- a/schemas/renderflow-golden-corpus-v1.schema.json +++ b/schemas/renderflow-golden-corpus-v1.schema.json @@ -35,6 +35,7 @@ "filename": { "type": "string", "minLength": 1 }, "encoding": { "enum": ["utf8", "hex"] }, "payload": { "type": "string" }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "expected_format": { "type": "string", "minLength": 1 }, "expected_media_type": { "type": "string", "pattern": "^[^/]+/[^/]+$" }, "family": { "type": "string", "minLength": 1 }, @@ -58,7 +59,32 @@ "properties": { "id": { "type": "string", "pattern": "^[a-z0-9_]+$" }, "tier": { "enum": ["fast", "tool_backed", "maximal"] }, - "fixtures": { "type": "array", "minItems": 1, "items": { "type": "string" } } + "fixtures": { "type": "array", "minItems": 1, "items": { "type": "string" } }, + "expected_artifacts": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/expectedArtifact" } + } + } + }, + "expectedArtifact": { + "type": "object", + "additionalProperties": false, + "required": ["case_id", "role", "format", "media_type", "provider_id", "oracle_path", "source_ids"], + "properties": { + "case_id": { "type": "string", "pattern": "^[a-z0-9_]+$" }, + "role": { "type": "string", "pattern": "^[a-z0-9_-]+$" }, + "format": { "type": "string", "minLength": 1 }, + "media_type": { "type": "string", "pattern": "^[^/]+/[^/]+$" }, + "provider_id": { "type": "string", "pattern": "^tool\\.[a-z0-9.-]+$" }, + "provider_version": { "type": "string", "minLength": 1 }, + "oracle_path": { "type": "string", "pattern": "^expected/(?:[a-z0-9_-]+/)*[a-z0-9_-]+(?:\\.[a-z0-9_-]+)+$" }, + "source_ids": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^fixture\\.[a-z0-9.-]+$" } + } } } } diff --git a/scripts/run-artifact-gallery.sh b/scripts/run-artifact-gallery.sh new file mode 100755 index 0000000..960a220 --- /dev/null +++ b/scripts/run-artifact-gallery.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/run-artifact-gallery.sh --output-dir DIR [--img2pdf PATH] + +Run the explicit local, real-provider artifact gallery and retain its output. +Requires Pandoc 2.0.0 or newer and img2pdf 0.6.3. The output directory must +be absent or empty. This script never installs tools or removes existing files. +EOF +} + +unavailable() { + printf 'artifact gallery unavailable: %s\n' "$1" >&2 + exit 2 +} + +output_dir="" +img2pdf_executable="" +while (($#)); do + case "$1" in + --output-dir) + if (($# < 2)) || [[ -z "$2" ]]; then + usage >&2 + exit 2 + fi + output_dir="$2" + shift 2 + ;; + --img2pdf) + if (($# < 2)) || [[ -z "$2" ]]; then + usage >&2 + exit 2 + fi + img2pdf_executable="$2" + shift 2 + ;; + --help) + usage + exit 0 + ;; + *) + printf 'Unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$output_dir" ]]; then + usage >&2 + exit 2 +fi + +if ! command -v cargo >/dev/null 2>&1; then + unavailable "Cargo is missing (Rust 1.94 or newer is required for this repository)" +fi +if ! command -v pandoc >/dev/null 2>&1; then + unavailable "Pandoc is missing (version 2.0.0 or newer is required)" +fi +pandoc_version="$(pandoc --version 2>/dev/null)" || unavailable "Pandoc version probe failed" +pandoc_version="${pandoc_version%%$'\n'*}" +if [[ ! "$pandoc_version" =~ ^pandoc[[:space:]]+([0-9]+)\.([0-9]+)(\.[0-9]+)?([[:space:]]|$) ]]; then + unavailable "cannot parse Pandoc version: $pandoc_version" +fi +if ((10#${BASH_REMATCH[1]} < 2)); then + unavailable "Pandoc 2.0.0 or newer is required; observed: $pandoc_version" +fi + +if [[ -z "$img2pdf_executable" ]]; then + img2pdf_executable="$(command -v img2pdf || true)" +fi +if [[ -z "$img2pdf_executable" ]]; then + unavailable "img2pdf is missing (version 0.6.3 is required); pass --img2pdf PATH" +fi +if [[ ! -x "$img2pdf_executable" ]]; then + unavailable "img2pdf executable is missing or not executable: $img2pdf_executable" +fi +img2pdf_version="$("$img2pdf_executable" --version 2>/dev/null)" || unavailable "img2pdf version probe failed" +if [[ "$img2pdf_version" != "img2pdf 0.6.3" ]]; then + unavailable "img2pdf 0.6.3 is required; observed: $img2pdf_version" +fi +img2pdf_executable="$(cd "$(dirname "$img2pdf_executable")" && pwd -P)/$(basename "$img2pdf_executable")" + +if [[ -e "$output_dir" && ! -d "$output_dir" ]]; then + printf 'artifact gallery: output path is not a directory: %s\n' "$output_dir" >&2 + exit 2 +fi +if [[ -d "$output_dir" ]]; then + for entry in "$output_dir"/* "$output_dir"/.[!.]* "$output_dir"/..?*; do + if [[ -e "$entry" || -L "$entry" ]]; then + printf 'artifact gallery: output directory must be empty: %s\n' "$output_dir" >&2 + exit 2 + fi + done +fi +mkdir -p "$output_dir" +output_dir="$(cd "$output_dir" && pwd -P)" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +cd "$script_dir/.." +printf 'artifact gallery: generating retained artifacts in %s\n' "$output_dir" +RENDERFLOW_GALLERY_OUTPUT_DIR="$output_dir" \ +RENDERFLOW_TEST_IMG2PDF="$img2pdf_executable" \ +cargo test --package renderflow-cli --test artifact_gallery_cli --locked -- --ignored --nocapture +printf 'artifact gallery: inspect %s\n' "$output_dir" diff --git a/tests/artifact_gallery_cli.rs b/tests/artifact_gallery_cli.rs new file mode 100644 index 0000000..aeffe64 --- /dev/null +++ b/tests/artifact_gallery_cli.rs @@ -0,0 +1,571 @@ +//! Opt-in, real-provider gallery built through the public CLI. Normal workspace +//! tests validate the corpus contract without pretending an absent provider ran. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +#[cfg(unix)] +use renderflow::evidence::StepState; +use renderflow::evidence::{ArtifactRole, RunState, ValidationState}; +use renderflow::graph::Format; +use renderflow::print_pdf_image::{inspect_print_image, PrintImageColorSpace}; +use renderflow::print_pdf_inspect::{inspect_print_pdf, ExpectedPrintPage}; +use renderflow::RunManifest; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +const CORPUS: &str = include_str!("fixtures/golden-artifacts/v1/corpus.json"); +const BIN: &str = env!("CARGO_BIN_EXE_renderflow"); + +fn digest(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn fixture<'a>(corpus: &'a Value, id: &str) -> &'a Value { + corpus["fixtures"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["id"] == id) + .unwrap_or_else(|| panic!("gallery fixture {id} missing from golden corpus")) +} + +fn expected<'a>(corpus: &'a Value, case_id: &str) -> &'a Value { + corpus["scenarios"] + .as_array() + .unwrap() + .iter() + .find(|scenario| scenario["id"] == "real_artifact_gallery") + .unwrap()["expected_artifacts"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["case_id"] == case_id) + .unwrap_or_else(|| panic!("gallery expected artifact {case_id} missing")) +} + +fn source_bytes(entry: &Value) -> Vec { + match entry["encoding"].as_str().unwrap() { + "utf8" => entry["payload"].as_str().unwrap().as_bytes().to_vec(), + "hex" => { + let payload = entry["payload"].as_str().unwrap(); + assert_eq!( + payload.len() % 2, + 0, + "fixture has an odd-length hex payload" + ); + payload + .as_bytes() + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()) + .collect() + } + other => panic!("unsupported corpus fixture encoding {other}"), + } +} + +fn materialize(entry: &Value, directory: &Path) -> (PathBuf, Vec) { + let bytes = source_bytes(entry); + assert_eq!(digest(&bytes), entry["sha256"].as_str().unwrap()); + let path = directory.join(entry["filename"].as_str().unwrap()); + fs::write(&path, &bytes).unwrap(); + (path, bytes) +} + +fn invoke(directory: &Path, args: &[&str]) -> Output { + Command::new(BIN) + .current_dir(directory) + .args(args) + .output() + .unwrap_or_else(|error| panic!("could not launch Renderflow CLI: {error}")) +} + +fn successful(directory: &Path, args: &[&str]) -> Output { + let result = invoke(directory, args); + assert!( + result.status.success(), + "gallery command {args:?} failed in {}: {}\n{}", + directory.display(), + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + result +} + +fn published_files(root: &Path) -> Vec { + fn visit(directory: &Path, files: &mut Vec) { + for entry in fs::read_dir(directory).unwrap() { + let entry = entry.unwrap(); + let kind = entry.file_type().unwrap(); + if kind.is_dir() { + visit(&entry.path(), files); + } else { + assert!(kind.is_file(), "published output must be a regular file"); + files.push(entry.path()); + } + } + } + let mut files = Vec::new(); + visit(&root.join("dist"), &mut files); + files.sort(); + files +} + +fn assert_published_files(directory: &Path, artifact: &str) { + let mut expected = vec![ + directory.join("dist/renderflow-run.json"), + directory.join("dist").join(artifact), + ]; + expected.sort(); + assert_eq!( + published_files(directory), + expected, + "unexpected published artifact or missing run manifest" + ); +} + +fn execute_project(directory: &Path) -> RunManifest { + successful( + directory, + &["spec", "validate", "--config", "renderflow.yaml"], + ); + let preview = successful( + directory, + &["build", "--config", "renderflow.yaml", "--dry-run"], + ); + let plan: Value = serde_json::from_slice(&preview.stdout).expect("dry-run plan is JSON"); + assert!(plan.get("edges").is_some(), "dry-run omitted planned edges"); + successful(directory, &["build", "--config", "renderflow.yaml"]); + let manifest: RunManifest = + serde_json::from_slice(&fs::read(directory.join("dist/renderflow-run.json")).unwrap()) + .unwrap(); + assert_eq!( + manifest.state, + RunState::Complete, + "{:#?}", + manifest.diagnostics + ); + assert_eq!(manifest.artifact_manifest.outputs.len(), 1); + manifest +} + +fn checked_oracle_path(entry: &Value) -> PathBuf { + let relative = entry["oracle_path"].as_str().unwrap(); + let path = Path::new(relative); + assert!( + !path.is_absolute() + && path + .components() + .all(|component| matches!(component, std::path::Component::Normal(_))), + "oracle path must remain within the corpus" + ); + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/golden-artifacts/v1") + .join(path) +} + +fn compare_text(case: &str, baseline: &Path, actual: &Path) { + let expected = fs::read_to_string(baseline).unwrap(); + let produced = fs::read_to_string(actual).unwrap(); + if expected != produced { + let first = expected + .bytes() + .zip(produced.bytes()) + .position(|(left, right)| left != right) + .unwrap_or(expected.len().min(produced.len())); + panic!( + "{case}: reviewed artifact drift at byte {first}\nexpected ({}):\n{expected}\nactual ({}):\n{produced}\nEdit the reviewed oracle in a separate PR after examining the generated artifact.", + baseline.display(), + actual.display() + ); + } +} + +fn require_providers() -> (String, String) { + let pandoc = Command::new("pandoc") + .arg("--version") + .output() + .unwrap_or_else(|error| { + panic!("unavailable: local Pandoc is required for real artifact gallery: {error}") + }); + assert!( + pandoc.status.success(), + "unavailable: Pandoc version probe failed" + ); + let pandoc_version = String::from_utf8_lossy(&pandoc.stdout) + .lines() + .next() + .unwrap_or_default() + .to_string(); + assert!( + pandoc_version + .strip_prefix("pandoc ") + .and_then(|version| version.split('.').next()) + .and_then(|major| major.parse::().ok()) + .is_some_and(|major| major >= 2), + "unavailable: Pandoc 2.0.0 or newer is required; observed: {pandoc_version}" + ); + let img2pdf = std::env::var("RENDERFLOW_TEST_IMG2PDF") + .expect("unavailable: set RENDERFLOW_TEST_IMG2PDF to an img2pdf 0.6.3 executable"); + let probe = Command::new(&img2pdf) + .arg("--version") + .output() + .unwrap_or_else(|error| { + panic!("unavailable: cannot run img2pdf provider {img2pdf}: {error}") + }); + assert!( + probe.status.success(), + "unavailable: img2pdf version probe failed" + ); + assert_eq!( + String::from_utf8_lossy(&probe.stdout).trim(), + "img2pdf 0.6.3", + "unavailable: gallery requires the proven img2pdf 0.6.3 provider" + ); + (pandoc_version, img2pdf) +} + +fn write_markdown_config(directory: &Path, digest: &str) { + fs::write( + directory.join("renderflow.yaml"), + format!( + "schema: renderflow/v2\nsources:\n - id: source.markdown\n path: synthetic.md\n format: markdown\n media_type: text/markdown\n sha256: \"{digest}\"\ntargets:\n exact:\n - id: target.web\n role: web\n format: html\n requirement: required\noutput:\n bundle_root: dist\n naming_template: \"{{source.id}}/{{target.role}}.{{ext}}\"\n" + ), + ) + .unwrap(); +} + +fn write_pdf_config(directory: &Path, filenames: &[String], digests: &[String], executable: &str) { + let executable = serde_json::to_string(executable).unwrap(); + fs::write( + directory.join("renderflow.yaml"), + format!( + "schema: renderflow/v2\nsources:\n - id: source.page001\n path: {}\n format: png\n media_type: image/png\n sha256: \"{}\"\n geometry: {{ width: 90, height: 90, unit: mm, bleed: 5 }}\n - id: source.page002\n path: {}\n format: png\n media_type: image/png\n sha256: \"{}\"\n geometry: {{ width: 90, height: 90, unit: mm, bleed: 5 }}\n - id: source.pages\n kind: collection\n members: [source.page001, source.page002]\ntargets:\n exact:\n - id: target.interior\n role: interior\n format: pdf\n requirement: required\nexecution:\n print_pdf_interior:\n executable: {executable}\n provider_version: \"0.6.3\"\n box_policy: media_bleed_trim_inset\n rotation: none\n scaling: fit\n color_policy: preserve_rgb_gray\n max_pages: 2\n max_input_bytes: 1000000\n max_output_bytes: 5000000\n timeout_seconds: 30\noutput:\n bundle_root: dist\n naming_template: \"{{source.id}}/{{target.role}}.{{ext}}\"\n", + serde_json::to_string(&filenames[0]).unwrap(), digests[0], + serde_json::to_string(&filenames[1]).unwrap(), digests[1], + ), + ) + .unwrap(); +} + +#[test] +#[ignore = "explicit real-provider gallery; use scripts/run-artifact-gallery.sh"] +fn real_artifact_gallery_v1() { + let (pandoc_version, img2pdf) = require_providers(); + let corpus: Value = serde_json::from_str(CORPUS).unwrap(); + let markdown_decl = expected(&corpus, "markdown_html"); + let pdf_decl = expected(&corpus, "ordered_print_pdf"); + assert_eq!(markdown_decl["provider_id"], "tool.pandoc"); + assert_eq!(markdown_decl["role"], "web"); + assert_eq!(markdown_decl["format"], "html"); + assert_eq!(markdown_decl["media_type"], "text/html"); + assert_eq!(pdf_decl["provider_id"], "tool.img2pdf"); + assert_eq!(pdf_decl["provider_version"], "0.6.3"); + assert_eq!(pdf_decl["role"], "interior"); + assert_eq!(pdf_decl["format"], "pdf"); + assert_eq!(pdf_decl["media_type"], "application/pdf"); + let oracle: Value = + serde_json::from_slice(&fs::read(checked_oracle_path(pdf_decl)).unwrap()).unwrap(); + assert_eq!(oracle["schema"], "renderflow.gallery-print-oracle/v1"); + let root_guard = tempfile::tempdir().unwrap(); + let root = if let Some(path) = std::env::var_os("RENDERFLOW_GALLERY_OUTPUT_DIR") { + let path = PathBuf::from(path); + assert!( + path.is_absolute(), + "gallery output directory must be absolute" + ); + if path.exists() { + assert!( + fs::read_dir(&path).unwrap().next().is_none(), + "gallery output directory must be empty to avoid stale evidence" + ); + } + fs::create_dir_all(&path).unwrap(); + path + } else { + root_guard.path().join("gallery") + }; + fs::create_dir_all(&root).unwrap(); + eprintln!("Real artifact gallery output: {}", root.display()); + + let markdown = fixture(&corpus, "fixture.document.markdown"); + let image_ids = ["fixture.image.print-page001", "fixture.image.print-page002"]; + let expected_source_ids = pdf_decl["source_ids"].as_array().unwrap(); + assert_eq!(expected_source_ids, &image_ids.map(|id| json!(id))); + let mut html_builds = Vec::new(); + let mut pdf_builds = Vec::new(); + let mut pdf_inspections = Vec::new(); + let mut pdf_expectations = Vec::new(); + for iteration in 1..=2 { + let html_dir = root.join(format!("markdown/run-{iteration}")); + fs::create_dir_all(&html_dir).unwrap(); + let (source, source_bytes) = materialize(markdown, &html_dir); + write_markdown_config(&html_dir, &digest(&source_bytes)); + let manifest = execute_project(&html_dir); + let output = html_dir.join("dist/source.markdown/web.html"); + assert!(output.is_file()); + assert_published_files(&html_dir, "source.markdown/web.html"); + compare_text( + "markdown_html", + &checked_oracle_path(markdown_decl), + &output, + ); + assert_eq!( + fs::read(&source).unwrap(), + source_bytes, + "Markdown source changed" + ); + let terminal = manifest + .artifact_manifest + .artifacts + .iter() + .find(|artifact| artifact.role == "web") + .unwrap(); + assert_eq!(terminal.lifecycle, ArtifactRole::Terminal); + assert_eq!(terminal.format, "html"); + assert_eq!(terminal.media_type, "text/html"); + assert_eq!(terminal.producer.provider.as_deref(), Some("tool.pandoc")); + assert_eq!(terminal.digest.value, digest(&fs::read(&output).unwrap())); + assert_eq!(terminal.sources.len(), 1); + assert_eq!( + manifest.artifact_manifest.outputs[0], + "dist/source.markdown/web.html" + ); + html_builds.push(fs::read(output).unwrap()); + + let pdf_dir = root.join(format!("print/run-{iteration}")); + fs::create_dir_all(&pdf_dir).unwrap(); + let mut image_paths = Vec::new(); + let mut original_images = Vec::new(); + let mut source_digests = Vec::new(); + let mut filenames = Vec::new(); + for id in image_ids { + let declaration = fixture(&corpus, id); + let (path, bytes) = materialize(declaration, &pdf_dir); + filenames.push(declaration["filename"].as_str().unwrap().to_string()); + source_digests.push(digest(&bytes)); + image_paths.push(path); + original_images.push(bytes); + } + write_pdf_config(&pdf_dir, &filenames, &source_digests, &img2pdf); + let manifest = execute_project(&pdf_dir); + let output = pdf_dir.join("dist/source.pages/interior.pdf"); + assert!(output.is_file()); + assert_published_files(&pdf_dir, "source.pages/interior.pdf"); + let declared_pages = oracle["pages"].as_array().unwrap(); + assert_eq!( + declared_pages.len(), + oracle["page_count"].as_u64().unwrap() as usize + ); + let mut expected_pages = Vec::new(); + for (index, path) in image_paths.iter().enumerate() { + let image = inspect_print_image(path, Format::Png).unwrap(); + let declared = &declared_pages[index]; + assert_eq!(declared["source_id"], image_ids[index]); + assert_eq!( + image.width_px, + declared["pixel_width"].as_u64().unwrap() as u32 + ); + assert_eq!( + image.height_px, + declared["pixel_height"].as_u64().unwrap() as u32 + ); + assert_eq!(image.color_space, PrintImageColorSpace::Rgb); + assert_eq!( + image.image_stream_sha256, + declared["image_stream_sha256"].as_str().unwrap() + ); + expected_pages.push(ExpectedPrintPage { + media_width_pt: oracle["media_width_pt"].as_f64().unwrap(), + media_height_pt: oracle["media_height_pt"].as_f64().unwrap(), + trim_inset_pt: oracle["trim_inset_pt"].as_f64().unwrap(), + pixel_width: image.width_px, + pixel_height: image.height_px, + rotation: oracle["rotation"].as_u64().unwrap() as u16, + color_space: oracle["color_space"].as_str().unwrap().to_string(), + image_filter: oracle["image_filter"].as_str().unwrap().to_string(), + image_stream_sha256: image.image_stream_sha256, + }); + } + let inspection = inspect_print_pdf(&output, &expected_pages).unwrap(); + assert_eq!(inspection.page_count, 2); + let terminal = manifest + .artifact_manifest + .artifacts + .iter() + .find(|artifact| artifact.role == "interior") + .unwrap(); + assert_eq!(terminal.lifecycle, ArtifactRole::Terminal); + assert_eq!(terminal.validation, ValidationState::Valid); + assert_eq!(terminal.producer.provider.as_deref(), Some("tool.img2pdf")); + assert_eq!(terminal.digest.value, inspection.sha256); + assert_eq!(terminal.sources.len(), 2); + let sources = manifest + .artifact_manifest + .artifacts + .iter() + .filter(|artifact| artifact.lifecycle == ArtifactRole::Source) + .collect::>(); + assert_eq!(sources.len(), 2); + assert_eq!( + terminal.sources, + sources + .iter() + .map(|source| source.artifact_id.clone()) + .collect::>() + ); + for (index, source) in sources.iter().enumerate() { + assert_eq!(source.metadata["renderflow.collection.index"], index); + assert_eq!( + fs::read(&image_paths[index]).unwrap(), + original_images[index], + "PNG source changed" + ); + } + assert_eq!( + manifest.artifact_manifest.outputs[0], + "dist/source.pages/interior.pdf" + ); + assert_eq!( + terminal.metadata["renderflow.print_pdf.inspection"]["sha256"], + inspection.sha256 + ); + pdf_builds.push(fs::read(output).unwrap()); + pdf_inspections.push(inspection.sha256); + pdf_expectations = expected_pages; + } + assert_eq!( + html_builds[0], html_builds[1], + "markdown_html changed across clean builds" + ); + assert_eq!( + pdf_builds[0], pdf_builds[1], + "ordered_print_pdf changed across clean builds" + ); + + let stale_dir = root.join("negative/stale-source"); + fs::create_dir_all(&stale_dir).unwrap(); + let mut stale_filenames = Vec::new(); + let mut original_digests = Vec::new(); + for id in image_ids { + let declaration = fixture(&corpus, id); + let (_, bytes) = materialize(declaration, &stale_dir); + stale_filenames.push(declaration["filename"].as_str().unwrap().to_string()); + original_digests.push(digest(&bytes)); + } + write_pdf_config(&stale_dir, &stale_filenames, &original_digests, &img2pdf); + fs::write( + stale_dir.join(&stale_filenames[0]), + b"deliberately changed source", + ) + .unwrap(); + let stale = invoke(&stale_dir, &["build", "--config", "renderflow.yaml"]); + fs::write(stale_dir.join("refusal.stderr.txt"), &stale.stderr).unwrap(); + assert!(!stale.status.success(), "changed source was accepted"); + assert!( + String::from_utf8_lossy(&stale.stderr).contains("collection.member.digest_mismatch"), + "changed source produced no typed refusal: {}", + String::from_utf8_lossy(&stale.stderr) + ); + assert!( + !stale_dir.join("dist/source.pages/interior.pdf").exists(), + "changed source published an output" + ); + let mut negative_checks = vec![json!({ + "id":"stale_source", "expected":"collection.member.digest_mismatch", + "evidence":"negative/stale-source/refusal.stderr.txt", "status":"passed" + })]; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let refused_dir = root.join("negative/invalid-provider"); + fs::create_dir_all(&refused_dir).unwrap(); + let shim = refused_dir.join("fake-img2pdf"); + fs::write( + &shim, + "#!/bin/sh\nif [ \"${1:-}\" = \"--version\" ]; then printf 'img2pdf 0.6.3\\n'; exit 0; fi\nwhile [ \"$#\" -gt 0 ]; do\n if [ \"$1\" = \"--output\" ]; then shift; printf 'invalid pdf' > \"$1\"; exit 0; fi\n shift\ndone\nexit 24\n", + ) + .unwrap(); + fs::set_permissions(&shim, fs::Permissions::from_mode(0o755)).unwrap(); + let mut filenames = Vec::new(); + let mut digests = Vec::new(); + for id in image_ids { + let declaration = fixture(&corpus, id); + let (_, bytes) = materialize(declaration, &refused_dir); + filenames.push(declaration["filename"].as_str().unwrap().to_string()); + digests.push(digest(&bytes)); + } + write_pdf_config(&refused_dir, &filenames, &digests, shim.to_str().unwrap()); + let refused = invoke(&refused_dir, &["build", "--config", "renderflow.yaml"]); + fs::write(refused_dir.join("refusal.stderr.txt"), &refused.stderr).unwrap(); + assert!( + !refused.status.success(), + "invalid provider output was accepted" + ); + assert!( + !refused_dir.join("dist/source.pages/interior.pdf").exists(), + "invalid provider output was published" + ); + let failed: RunManifest = serde_json::from_slice( + &fs::read(refused_dir.join("dist/renderflow-run.json")).unwrap(), + ) + .unwrap(); + assert_eq!(failed.state, RunState::Failed); + assert!(failed.artifact_manifest.outputs.is_empty()); + assert!( + failed.steps.iter().any(|step| { + step.state == StepState::Failed + && step + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "print_pdf.invalid_structure") + }), + "missing typed invalid structure refusal: {:?}", + failed.steps + ); + negative_checks.push(json!({ + "id":"invalid_provider_output", "expected":"print_pdf.invalid_structure", + "evidence":"negative/invalid-provider/dist/renderflow-run.json", "status":"passed" + })); + } + + let invalid_dir = root.join("negative/invalid-pdf"); + fs::create_dir_all(&invalid_dir).unwrap(); + let invalid_pdf = invalid_dir.join("truncated.pdf"); + fs::write(&invalid_pdf, &pdf_builds[0][..pdf_builds[0].len() / 2]).unwrap(); + let invalid = inspect_print_pdf(&invalid_pdf, &pdf_expectations); + fs::write( + invalid_dir.join("refusal.txt"), + format!("{}\n", invalid.as_ref().unwrap_err()), + ) + .unwrap(); + assert!( + invalid.is_err(), + "truncated generated PDF passed the independent print inspector" + ); + negative_checks.push(json!({ + "id":"invalid_pdf", "expected":"independent PDF inspection refusal", + "evidence":"negative/invalid-pdf/refusal.txt", "status":"passed" + })); + let report = json!({ + "schema": "renderflow.artifact-gallery-report/v1", + "corpus_version": corpus["corpus_version"], + "status": "passed", + "providers": {"pandoc": pandoc_version, "img2pdf": "0.6.3"}, + "cases": [ + {"case_id":"markdown_html","status":"passed","output":"markdown/run-1/dist/source.markdown/web.html","sha256":digest(&html_builds[0]),"clean_builds_equal":true}, + {"case_id":"ordered_print_pdf","status":"passed","output":"print/run-1/dist/source.pages/interior.pdf","sha256":pdf_inspections[0],"page_count":2,"clean_builds_equal":true} + ], + "negative_checks": negative_checks + }); + fs::write( + root.join("comparison.json"), + serde_json::to_vec_pretty(&report).unwrap(), + ) + .unwrap(); + eprintln!("PASS: two real CLI artifact cases, three immutable source inputs, two clean builds each. Open {}", root.display()); +} diff --git a/tests/fixtures/golden-artifacts/v1/corpus.json b/tests/fixtures/golden-artifacts/v1/corpus.json index 0f8d595..19fed30 100644 --- a/tests/fixtures/golden-artifacts/v1/corpus.json +++ b/tests/fixtures/golden-artifacts/v1/corpus.json @@ -1,6 +1,6 @@ { "schema_version": "renderflow.golden-corpus/v1", - "corpus_version": "1.0.0", + "corpus_version": "1.1.0", "license": "CC0-1.0", "redistribution": "Synthetic fixtures created for Renderflow conformance; no publication or third-party content.", "fixtures": [ @@ -9,6 +9,7 @@ "filename": "synthetic.md", "encoding": "utf8", "payload": "# Synthetic artifact\n\nA bounded, redistribution-safe Renderflow fixture.\n", + "sha256": "05a7ce0f745ed256ffbb4a7d900f815d63904fe2c66eb6ede18caa5616ef6378", "expected_format": "markdown", "expected_media_type": "text/markdown", "family": "document", @@ -34,6 +35,28 @@ "family": "image", "expected_outcome": "accepted" }, + { + "id": "fixture.image.print-page001", + "filename": "print-page-001.png", + "encoding": "hex", + "payload": "89504e470d0a1a0a0000000d4948445200000064000000640802000000ff800203000000ec49444154789cedd0410dc02000c040c0d004e0df046a66617d9125770a9aceb39fc137eb76c09f98159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815981598159815bc3c44020cfae786800000000049454e44ae426082", + "sha256": "4504b942743ef33c894e01de1c66b1d5b283bcb8006672e55a35423697a5d970", + "expected_format": "png", + "expected_media_type": "image/png", + "family": "image", + "expected_outcome": "accepted" + }, + { + "id": "fixture.image.print-page002", + "filename": "print-page-002.png", + "encoding": "hex", + "payload": "89504e470d0a1a0a0000000d4948445200000064000000640802000000ff800203000000eb49444154789cedd0410dc02000c04040cc04211a5db3b0bec8923b054de7b3cfe09b753be04fcc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0acc0a5e79600206fc9723aa0000000049454e44ae426082", + "sha256": "2dbeeb63e82b4d839e9cd18a37b36010c54b771e58432d30c72a83acb5c751ad", + "expected_format": "png", + "expected_media_type": "image/png", + "family": "image", + "expected_outcome": "accepted" + }, { "id": "fixture.image.svg", "filename": "shapes.svg", @@ -129,6 +152,10 @@ { "id": "fixture.collection.ordered-pages", "members": ["fixture.document.markdown", "fixture.data.json"] + }, + { + "id": "fixture.collection.print-pages", + "members": ["fixture.image.print-page001", "fixture.image.print-page002"] } ], "scenarios": [ @@ -150,6 +177,32 @@ { "id": "everything_profile", "tier": "fast", "fixtures": ["fixture.document.markdown"] }, { "id": "magazine_release_bundle", "tier": "fast", "fixtures": ["fixture.document.markdown", "fixture.image.png"] }, { "id": "optional_tool_providers", "tier": "tool_backed", "fixtures": ["fixture.document.markdown", "fixture.video.mp4"] }, + { + "id": "real_artifact_gallery", + "tier": "tool_backed", + "fixtures": ["fixture.document.markdown", "fixture.collection.print-pages"], + "expected_artifacts": [ + { + "case_id": "markdown_html", + "role": "web", + "format": "html", + "media_type": "text/html", + "provider_id": "tool.pandoc", + "oracle_path": "expected/markdown.html", + "source_ids": ["fixture.document.markdown"] + }, + { + "case_id": "ordered_print_pdf", + "role": "interior", + "format": "pdf", + "media_type": "application/pdf", + "provider_id": "tool.img2pdf", + "provider_version": "0.6.3", + "oracle_path": "expected/print-interior.json", + "source_ids": ["fixture.image.print-page001", "fixture.image.print-page002"] + } + ] + }, { "id": "maximal_release_matrix", "tier": "maximal", "fixtures": ["fixture.document.markdown", "fixture.image.png", "fixture.audio.wav", "fixture.video.mp4"] } ] } diff --git a/tests/fixtures/golden-artifacts/v1/expected/markdown.html b/tests/fixtures/golden-artifacts/v1/expected/markdown.html new file mode 100644 index 0000000..8de4e58 --- /dev/null +++ b/tests/fixtures/golden-artifacts/v1/expected/markdown.html @@ -0,0 +1,2 @@ +

Synthetic artifact

+

A bounded, redistribution-safe Renderflow fixture.

diff --git a/tests/fixtures/golden-artifacts/v1/expected/print-interior.json b/tests/fixtures/golden-artifacts/v1/expected/print-interior.json new file mode 100644 index 0000000..7fea3fb --- /dev/null +++ b/tests/fixtures/golden-artifacts/v1/expected/print-interior.json @@ -0,0 +1,24 @@ +{ + "schema": "renderflow.gallery-print-oracle/v1", + "page_count": 2, + "media_width_pt": 283.46456692913387, + "media_height_pt": 283.46456692913387, + "trim_inset_pt": 14.173228346456694, + "rotation": 0, + "color_space": "DeviceRGB", + "image_filter": "FlateDecode", + "pages": [ + { + "source_id": "fixture.image.print-page001", + "pixel_width": 100, + "pixel_height": 100, + "image_stream_sha256": "8fe456445985359dbda191a34986eac959a62abc9544d8a7b9f77c5c001e03d8" + }, + { + "source_id": "fixture.image.print-page002", + "pixel_width": 100, + "pixel_height": 100, + "image_stream_sha256": "fdcae1699d6d5291dab9e205e656d7193597913e39792a606a59dab67e600953" + } + ] +}