katana-document-viewer 0.1.4

KatanA document viewer artifact, render evaluation, and export foundation.
Documentation
use super::*;
use crate::artifact::{ArtifactBytes, ArtifactFactory, ArtifactFormat};
use crate::test_support::SampleSnapshotFactory;

#[test]
fn manifest_backend_exports_non_empty_formats() -> Result<(), Box<dyn std::error::Error>> {
    let pipeline = ForgePipeline::new(ManifestOnlyBackend);
    let graph = BuildGraph::from_request(&BuildRequest {
        snapshot: SampleSnapshotFactory::create()?,
        profile: BuildProfile::markdown_export(),
        theme: crate::KdvThemeSnapshot::katana_light(),
    });

    for (format, artifact_format) in [
        (ExportFormat::Html, ArtifactFormat::Html),
        (ExportFormat::Pdf, ArtifactFormat::Pdf),
        (ExportFormat::Png, ArtifactFormat::Png),
        (ExportFormat::Jpeg, ArtifactFormat::Jpeg),
    ] {
        let output = pipeline.export(&ExportRequest {
            graph: graph.clone(),
            format,
            theme: crate::KdvThemeSnapshot::katana_light(),
        })?;
        assert_eq!(output.artifact.manifest.format, artifact_format);
        assert_eq!(output.artifact.manifest.source_revision.0, "rev-1");
        assert!(!output.artifact.bytes.bytes.is_empty());
    }
    Ok(())
}

#[test]
fn forge_pipeline_rejects_empty_export_artifact() -> Result<(), Box<dyn std::error::Error>> {
    let pipeline = ForgePipeline::new(EmptyBackend);
    let graph = BuildGraph::from_request(&BuildRequest {
        snapshot: SampleSnapshotFactory::create()?,
        profile: BuildProfile::markdown_export(),
        theme: crate::KdvThemeSnapshot::katana_light(),
    });
    let result = pipeline.export(&ExportRequest {
        graph,
        format: ExportFormat::Html,
        theme: crate::KdvThemeSnapshot::katana_light(),
    });

    assert!(matches!(
        result,
        Err(ForgeError::EmptyExportArtifact(ExportFormat::Html))
    ));
    Ok(())
}

#[test]
fn forge_pipeline_propagates_backend_export_error() -> Result<(), Box<dyn std::error::Error>> {
    let pipeline = ForgePipeline::new(FailingBackend);
    let graph = BuildGraph::from_request(&BuildRequest {
        snapshot: SampleSnapshotFactory::create()?,
        profile: BuildProfile::markdown_export(),
        theme: crate::KdvThemeSnapshot::katana_light(),
    });

    for _ in 0..20 {
        let result = pipeline.export(&ExportRequest {
            graph: graph.clone(),
            format: ExportFormat::Html,
            theme: crate::KdvThemeSnapshot::katana_light(),
        });
        assert!(matches!(result, Err(ForgeError::Backend(message)) if message == "export failed"));
    }
    Ok(())
}

#[test]
fn non_html_exports_do_not_claim_html_backed_rendering() -> Result<(), Box<dyn std::error::Error>> {
    let pipeline = ForgePipeline::new(ManifestOnlyBackend);
    let graph = BuildGraph::from_request(&BuildRequest {
        snapshot: SampleSnapshotFactory::create()?,
        profile: BuildProfile::markdown_export(),
        theme: crate::KdvThemeSnapshot::katana_light(),
    });
    let theme = crate::KdvThemeSnapshot::katana_light();
    let outputs = [
        export_bytes(&pipeline, &graph, ExportFormat::Pdf, &theme)?,
        export_bytes(&pipeline, &graph, ExportFormat::Png, &theme)?,
        export_bytes(&pipeline, &graph, ExportFormat::Jpeg, &theme)?,
    ];

    for bytes in outputs {
        assert!(
            !contains_bytes(&bytes, b"KDV_HTML_FINGERPRINT"),
            "non-HTML export must not claim rendered HTML fidelity before the Rust backend exists"
        );
    }
    Ok(())
}

#[test]
fn non_html_exports_use_rust_rendered_document_surface() -> Result<(), Box<dyn std::error::Error>> {
    let pipeline = ForgePipeline::new(ManifestOnlyBackend);
    let graph = BuildGraph::from_request(&BuildRequest {
        snapshot: SampleSnapshotFactory::create()?,
        profile: BuildProfile::markdown_export(),
        theme: crate::KdvThemeSnapshot::katana_light(),
    });
    let theme = crate::KdvThemeSnapshot::katana_light();
    let pdf = export_bytes(&pipeline, &graph, ExportFormat::Pdf, &theme)?;
    let png = export_bytes(&pipeline, &graph, ExportFormat::Png, &theme)?;
    let jpeg = export_bytes(&pipeline, &graph, ExportFormat::Jpeg, &theme)?;

    assert!(
        contains_bytes(&pdf, b"/Subtype /Image"),
        "PDF export must embed a Rust-rendered document surface image"
    );
    assert!(
        !contains_bytes(&pdf, b"not implemented"),
        "PDF export must not be a placeholder message"
    );
    assert_eq!(png_dimensions(&png), Some((1280, 720)));
    assert!(
        jpeg.len() > TINY_JPEG_BYTE_LEN * 10,
        "JPEG export must not be the 1x1 placeholder"
    );
    Ok(())
}

struct EmptyBackend;

impl ForgeBackend for EmptyBackend {
    fn build(&self, request: &BuildRequest) -> Result<BuildGraph, ForgeError> {
        Ok(BuildGraph::from_request(request))
    }

    fn export(&self, request: &ExportRequest) -> Result<ExportOutput, ForgeError> {
        let snapshot = &request.graph.snapshot;
        let artifact = ArtifactFactory::export(
            request.format.artifact_format(),
            snapshot.id.clone(),
            snapshot.revision.clone(),
            ArtifactBytes { bytes: Vec::new() },
        );
        Ok(ExportOutput {
            artifact,
            diagnostics: request.graph.diagnostics.clone(),
        })
    }
}

struct FailingBackend;

impl ForgeBackend for FailingBackend {
    fn build(&self, request: &BuildRequest) -> Result<BuildGraph, ForgeError> {
        Ok(BuildGraph::from_request(request))
    }

    fn export(&self, _request: &ExportRequest) -> Result<ExportOutput, ForgeError> {
        Err(ForgeError::Backend("export failed".to_string()))
    }
}

fn export_bytes(
    pipeline: &ForgePipeline<ManifestOnlyBackend>,
    graph: &BuildGraph,
    format: ExportFormat,
    theme: &crate::KdvThemeSnapshot,
) -> Result<Vec<u8>, ForgeError> {
    let output = pipeline.export(&ExportRequest {
        graph: graph.clone(),
        format,
        theme: theme.clone(),
    })?;
    Ok(output.artifact.bytes.bytes)
}

fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
    haystack
        .windows(needle.len())
        .any(|window| window == needle)
}

fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
    const PNG_DIMENSION_CHUNK_END: usize = 24;
    const PNG_HEIGHT_END: usize = 24;
    const PNG_HEIGHT_START: usize = 20;
    const PNG_SIGNATURE_LEN: usize = 8;
    const PNG_WIDTH_END: usize = 20;
    const PNG_WIDTH_START: usize = 16;

    let signature = b"\x89PNG\r\n\x1a\n";
    if bytes.len() < PNG_DIMENSION_CHUNK_END || &bytes[..PNG_SIGNATURE_LEN] != signature {
        return None;
    }
    let width = u32::from_be_bytes(bytes[PNG_WIDTH_START..PNG_WIDTH_END].try_into().ok()?);
    let height = u32::from_be_bytes(bytes[PNG_HEIGHT_START..PNG_HEIGHT_END].try_into().ok()?);
    Some((width, height))
}

const TINY_JPEG_BYTE_LEN: usize = 717;