katana-document-viewer 0.1.4

KatanA document viewer artifact, render evaluation, and export foundation.
Documentation
use super::*;
use crate::export_quality::ExportQualityArtifacts;
use image::ImageEncoder;

const FIXTURE_IMAGE_WIDTH: u32 = 700;
const FIXTURE_IMAGE_HEIGHT: u32 = 500;
const RGB_CHANNEL_VALUE: u8 = 240;
const DEFAULT_POSTPROCESS_MILLIS: u128 = 20;
const BASELINE_PDF_GENERATION_MILLIS: u128 = 100;
const TEST_METADATA_PADDING_BYTES: usize = 2048;

#[derive(Clone)]
pub(crate) struct FixtureArtifacts {
    pub(crate) pdf: Vec<u8>,
    html: Vec<u8>,
    png: Vec<u8>,
    jpeg: Vec<u8>,
}

impl FixtureArtifacts {
    pub(crate) fn new() -> Result<Self, image::ImageError> {
        Ok(Self {
            html: Self::html(),
            pdf: Self::base_pdf(),
            png: png_bytes(FIXTURE_IMAGE_WIDTH, FIXTURE_IMAGE_HEIGHT)?,
            jpeg: jpeg_bytes(FIXTURE_IMAGE_WIDTH, FIXTURE_IMAGE_HEIGHT)?,
        })
    }

    pub(crate) fn with_test_metadata() -> Result<Self, image::ImageError> {
        let mut fixture = Self::new()?;
        fixture
            .pdf
            .extend_from_slice(&vec![b' '; TEST_METADATA_PADDING_BYTES]);
        Ok(fixture)
    }

    pub(crate) fn request(&self) -> ExportPostprocessEvaluationRequest<'_> {
        ExportPostprocessEvaluationRequest {
            artifacts: ExportQualityArtifacts {
                html: &self.html,
                pdf: &self.pdf,
                png: &self.png,
                jpeg: &self.jpeg,
            },
            baseline_pdf_generation_millis: BASELINE_PDF_GENERATION_MILLIS,
        }
    }

    pub(crate) fn optimized_pdf(&self) -> Vec<u8> {
        Self::base_pdf()
    }

    pub(crate) fn pdf_without_link_annotation(&self) -> Vec<u8> {
        String::from_utf8_lossy(&self.pdf)
            .replace("/Subtype /Link", "/Subtype /Text")
            .into_bytes()
    }

    pub(crate) fn pdf_with_one_byte_removed(&self) -> Vec<u8> {
        self.pdf[..self.pdf.len() - 1].to_vec()
    }

    fn html() -> Vec<u8> {
        concat!(
            "<main data-kdv-export data-kdv-export-style>",
            "<strong>太字</strong><a href=\"https://example.com\">link</a>",
            "<aside data-github-alert=\"warning\"></aside>",
            "<span data-kdv-task-state=\"done\"></span>",
            "<svg data-kdv-render-runtime=\"katana-render-runtime\"></svg>",
            "</main>"
        )
        .as_bytes()
        .to_vec()
    }

    pub(crate) fn base_pdf() -> Vec<u8> {
        concat!(
            "%PDF-1.4\n",
            "1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n",
            "2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n",
            "3 0 obj << /Type /Page /Parent 2 0 R /Resources << ",
            "/XObject << /Im5 5 0 R >> >> /Annots [6 0 R 7 0 R] >> endobj\n",
            "5 0 obj << /Type /XObject /Subtype /Image >> stream\nabc\nendstream endobj\n",
            "6 0 obj << /Type /Annot /Subtype /Link /Dest [3 0 R /XYZ 0 0 null] >> endobj\n",
            "7 0 obj << /Type /Annot /Subtype /Link /A << /S /URI ",
            "/URI (https://example.com) >> >> endobj\n",
            "%%EOF\n"
        )
        .as_bytes()
        .to_vec()
    }
}

pub(crate) struct StaticPostprocessAdapter {
    adapter_name: String,
    result: Result<Vec<u8>, PdfPostprocessError>,
    elapsed_millis: u128,
}

impl StaticPostprocessAdapter {
    pub(crate) fn success(adapter_name: &str, pdf: Vec<u8>) -> Self {
        Self {
            adapter_name: adapter_name.to_string(),
            result: Ok(pdf),
            elapsed_millis: DEFAULT_POSTPROCESS_MILLIS,
        }
    }

    pub(crate) fn failure(adapter_name: &str, message: &str) -> Self {
        Self {
            adapter_name: adapter_name.to_string(),
            result: Err(PdfPostprocessError::new(message)),
            elapsed_millis: DEFAULT_POSTPROCESS_MILLIS,
        }
    }

    pub(crate) fn with_elapsed_millis(mut self, elapsed_millis: u128) -> Self {
        self.elapsed_millis = elapsed_millis;
        self
    }
}

impl PdfPostprocessAdapter for StaticPostprocessAdapter {
    fn name(&self) -> &str {
        &self.adapter_name
    }

    fn postprocess_pdf(
        &self,
        _input: &PdfPostprocessInput<'_>,
    ) -> Result<PdfPostprocessOutput, PdfPostprocessError> {
        self.result.clone().map(|pdf| PdfPostprocessOutput {
            pdf,
            elapsed_millis: self.elapsed_millis,
        })
    }
}

fn png_bytes(width: u32, height: u32) -> Result<Vec<u8>, image::ImageError> {
    let image = image::RgbImage::from_pixel(
        width,
        height,
        image::Rgb([RGB_CHANNEL_VALUE, RGB_CHANNEL_VALUE, RGB_CHANNEL_VALUE]),
    );
    let mut bytes = Vec::new();
    let encode_result = image::codecs::png::PngEncoder::new(&mut bytes).write_image(
        image.as_raw(),
        width,
        height,
        image::ColorType::Rgb8.into(),
    );
    assert!(encode_result.is_ok(), "png fixture encode failed");
    Ok(bytes)
}

fn jpeg_bytes(width: u32, height: u32) -> Result<Vec<u8>, image::ImageError> {
    let image = image::RgbImage::from_pixel(
        width,
        height,
        image::Rgb([RGB_CHANNEL_VALUE, RGB_CHANNEL_VALUE, RGB_CHANNEL_VALUE]),
    );
    let mut bytes = Vec::new();
    let encode_result = image::codecs::jpeg::JpegEncoder::new(&mut bytes).write_image(
        image.as_raw(),
        width,
        height,
        image::ColorType::Rgb8.into(),
    );
    assert!(encode_result.is_ok(), "jpeg fixture encode failed");
    Ok(bytes)
}