use std::fs;
use std::io::{Cursor, Read};
use std::path::{Path, PathBuf};
use image::codecs::png::PngEncoder;
use image::imageops::FilterType;
use image::{ColorType, ImageEncoder};
use resvg::tiny_skia::{Pixmap, Transform};
use resvg::usvg;
use crate::error::{DocxError, Result};
use crate::paragraph::ParagraphAlignment;
use crate::InputLimits;
const VISUAL_RASTER_DPI: u32 = 192;
const TWIPS_PER_PIXEL_AT_96_DPI: u32 = 15;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VisualKind {
Image,
Logo,
Signature,
Chart,
}
impl VisualKind {
pub(crate) fn as_docx_name(self) -> &'static str {
match self {
Self::Image => "RusDox Image",
Self::Logo => "RusDox Logo",
Self::Signature => "RusDox Signature",
Self::Chart => "RusDox Chart",
}
}
pub(crate) fn from_docx_name(value: &str) -> Self {
let lower = value.trim().to_ascii_lowercase();
if lower.starts_with("rusdox logo") {
Self::Logo
} else if lower.starts_with("rusdox signature") {
Self::Signature
} else if lower.starts_with("rusdox chart") {
Self::Chart
} else {
Self::Image
}
}
fn default_alignment(self) -> ParagraphAlignment {
match self {
Self::Image | Self::Chart => ParagraphAlignment::Center,
Self::Logo => ParagraphAlignment::Left,
Self::Signature => ParagraphAlignment::Right,
}
}
fn default_max_width_twips(self, content_width_twips: u32) -> u32 {
match self {
Self::Image | Self::Chart => content_width_twips,
Self::Logo => content_width_twips.min(2_880),
Self::Signature => content_width_twips.min(3_600),
}
}
fn default_max_height_twips(self, content_height_twips: u32) -> u32 {
match self {
Self::Image => content_height_twips.min(6_480),
Self::Chart => content_height_twips.min(5_760),
Self::Logo => content_height_twips.min(1_440),
Self::Signature => content_height_twips.min(1_080),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VisualFormat {
Png,
Jpeg,
Svg,
}
impl VisualFormat {
pub(crate) fn content_type(self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
Self::Svg => "image/svg+xml",
}
}
pub(crate) fn extension(self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpg",
Self::Svg => "svg",
}
}
pub(crate) fn from_path(path: &Path) -> Option<Self> {
match path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"png" => Some(Self::Png),
"jpg" | "jpeg" => Some(Self::Jpeg),
"svg" => Some(Self::Svg),
_ => None,
}
}
pub(crate) fn guess(bytes: &[u8]) -> Option<Self> {
if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
Some(Self::Png)
} else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
Some(Self::Jpeg)
} else {
let sample = String::from_utf8_lossy(&bytes[..bytes.len().min(256)]);
sample.contains("<svg").then_some(Self::Svg)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VisualSource {
Path(PathBuf),
Embedded {
format: VisualFormat,
bytes: Vec<u8>,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct VisualSizing {
width_twips: Option<u32>,
height_twips: Option<u32>,
max_width_twips: Option<u32>,
max_height_twips: Option<u32>,
}
impl VisualSizing {
pub fn width_twips(&self) -> Option<u32> {
self.width_twips
}
pub fn height_twips(&self) -> Option<u32> {
self.height_twips
}
pub fn max_width_twips(&self) -> Option<u32> {
self.max_width_twips
}
pub fn max_height_twips(&self) -> Option<u32> {
self.max_height_twips
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Visual {
kind: VisualKind,
source: VisualSource,
alt_text: Option<String>,
alignment: ParagraphAlignment,
sizing: VisualSizing,
input_limits: InputLimits,
}
impl Visual {
pub fn from_path(path: impl Into<PathBuf>) -> Self {
Self {
kind: VisualKind::Image,
source: VisualSource::Path(path.into()),
alt_text: None,
alignment: VisualKind::Image.default_alignment(),
sizing: VisualSizing::default(),
input_limits: InputLimits::default(),
}
}
pub fn from_bytes(bytes: Vec<u8>, format: VisualFormat) -> Self {
Self {
kind: VisualKind::Image,
source: VisualSource::Embedded { format, bytes },
alt_text: None,
alignment: VisualKind::Image.default_alignment(),
sizing: VisualSizing::default(),
input_limits: InputLimits::default(),
}
}
pub fn from_path_with_limits(path: impl Into<PathBuf>, input_limits: InputLimits) -> Self {
Self::from_path(path).with_input_limits(input_limits)
}
pub fn from_bytes_with_limits(
bytes: Vec<u8>,
format: VisualFormat,
input_limits: InputLimits,
) -> Self {
Self::from_bytes(bytes, format).with_input_limits(input_limits)
}
pub fn logo(path: impl Into<PathBuf>) -> Self {
Self::from_path(path).with_kind(VisualKind::Logo)
}
pub fn signature(path: impl Into<PathBuf>) -> Self {
Self::from_path(path).with_kind(VisualKind::Signature)
}
pub fn chart(path: impl Into<PathBuf>) -> Self {
Self::from_path(path).with_kind(VisualKind::Chart)
}
pub fn kind(&self) -> VisualKind {
self.kind
}
pub fn with_kind(mut self, kind: VisualKind) -> Self {
self.kind = kind;
if self.alignment == VisualKind::Image.default_alignment()
|| self.alignment == VisualKind::Logo.default_alignment()
|| self.alignment == VisualKind::Signature.default_alignment()
|| self.alignment == VisualKind::Chart.default_alignment()
{
self.alignment = kind.default_alignment();
}
self
}
pub fn source(&self) -> &VisualSource {
&self.source
}
pub fn alt_text(&self) -> Option<&str> {
self.alt_text.as_deref()
}
pub fn alt_text_text(mut self, alt_text: impl Into<String>) -> Self {
self.alt_text = Some(alt_text.into());
self
}
pub fn alignment(&self) -> &ParagraphAlignment {
&self.alignment
}
pub fn with_alignment(mut self, alignment: ParagraphAlignment) -> Self {
self.alignment = alignment;
self
}
pub fn width_twips(mut self, width_twips: u32) -> Self {
self.sizing.width_twips = Some(width_twips);
self
}
pub fn height_twips(mut self, height_twips: u32) -> Self {
self.sizing.height_twips = Some(height_twips);
self
}
pub fn max_width_twips(mut self, max_width_twips: u32) -> Self {
self.sizing.max_width_twips = Some(max_width_twips);
self
}
pub fn max_height_twips(mut self, max_height_twips: u32) -> Self {
self.sizing.max_height_twips = Some(max_height_twips);
self
}
pub fn sizing(&self) -> &VisualSizing {
&self.sizing
}
pub fn input_limits(&self) -> InputLimits {
self.input_limits
}
pub fn with_input_limits(mut self, input_limits: InputLimits) -> Self {
self.input_limits = input_limits;
self
}
pub fn source_path(&self) -> Option<&Path> {
match &self.source {
VisualSource::Path(path) => Some(path.as_path()),
VisualSource::Embedded { .. } => None,
}
}
pub(crate) fn docx_name(&self) -> &'static str {
self.kind.as_docx_name()
}
pub(crate) fn resolved_dimensions_twips(
&self,
content_width_twips: u32,
content_height_twips: u32,
) -> Result<(u32, u32)> {
let (intrinsic_width_px, intrinsic_height_px) = self.intrinsic_dimensions()?;
Ok(resolve_dimensions_from_intrinsic(
self,
pixels_to_twips(intrinsic_width_px),
pixels_to_twips(intrinsic_height_px),
content_width_twips,
content_height_twips,
))
}
pub(crate) fn intrinsic_dimensions(&self) -> Result<(u32, u32)> {
let loaded = load_visual_source(&self.source, self.input_limits)?;
intrinsic_dimensions_for_source(&loaded, self.input_limits)
}
pub(crate) fn docx_media(
&self,
display_width_twips: u32,
display_height_twips: u32,
) -> Result<(VisualFormat, Vec<u8>)> {
let loaded = load_visual_source(&self.source, self.input_limits)?;
match loaded.format {
VisualFormat::Png | VisualFormat::Jpeg => Ok((loaded.format, loaded.bytes)),
VisualFormat::Svg => {
let raster = rasterize_svg(
&loaded.bytes,
loaded.resources_dir.as_deref(),
twips_to_pixels_at_dpi(display_width_twips, VISUAL_RASTER_DPI),
twips_to_pixels_at_dpi(display_height_twips, VISUAL_RASTER_DPI),
self.input_limits,
)?;
Ok((VisualFormat::Png, encode_png(&raster)?))
}
}
}
pub(crate) fn pdf_raster(
&self,
display_width_twips: u32,
display_height_twips: u32,
) -> Result<RasterizedVisual> {
let loaded = load_visual_source(&self.source, self.input_limits)?;
let target_width = twips_to_pixels_at_dpi(display_width_twips, VISUAL_RASTER_DPI);
let target_height = twips_to_pixels_at_dpi(display_height_twips, VISUAL_RASTER_DPI);
match loaded.format {
VisualFormat::Svg => rasterize_svg(
&loaded.bytes,
loaded.resources_dir.as_deref(),
target_width,
target_height,
self.input_limits,
),
VisualFormat::Png | VisualFormat::Jpeg => rasterize_raster_image(
&loaded.bytes,
target_width,
target_height,
self.input_limits,
),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RasterizedVisual {
pub(crate) width_px: u32,
pub(crate) height_px: u32,
pub(crate) rgba: Vec<u8>,
}
#[derive(Debug)]
struct LoadedVisualSource {
format: VisualFormat,
bytes: Vec<u8>,
resources_dir: Option<PathBuf>,
}
fn load_visual_source(source: &VisualSource, limits: InputLimits) -> Result<LoadedVisualSource> {
match source {
VisualSource::Path(path) => {
let declared_format = VisualFormat::from_path(path);
let read_limit = match declared_format {
Some(VisualFormat::Svg) => limits.max_svg_bytes,
Some(VisualFormat::Png | VisualFormat::Jpeg) | None => limits.max_image_bytes,
};
let bytes = read_visual_with_limit(path, read_limit)?;
let format = declared_format
.or_else(|| VisualFormat::guess(&bytes))
.ok_or_else(|| {
DocxError::parse(format!(
"unsupported visual format for {} (expected PNG, JPEG, or SVG)",
path.display()
))
})?;
ensure_visual_byte_limit(&bytes, format, limits)?;
Ok(LoadedVisualSource {
format,
bytes,
resources_dir: path.parent().map(Path::to_path_buf),
})
}
VisualSource::Embedded { format, bytes } => {
ensure_visual_byte_limit(bytes, *format, limits)?;
Ok(LoadedVisualSource {
format: *format,
bytes: bytes.clone(),
resources_dir: None,
})
}
}
}
fn read_visual_with_limit(path: &Path, limit: u64) -> Result<Vec<u8>> {
let declared = fs::metadata(path)?.len();
if declared > limit {
return Err(DocxError::resource_limit(format!(
"visual '{}' is {declared} bytes; limit is {limit} bytes",
path.display()
)));
}
let mut bytes = Vec::new();
fs::File::open(path)?
.take(limit.saturating_add(1))
.read_to_end(&mut bytes)?;
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > limit {
return Err(DocxError::resource_limit(format!(
"visual '{}' exceeded the {limit} byte limit while reading",
path.display()
)));
}
Ok(bytes)
}
fn ensure_visual_byte_limit(bytes: &[u8], format: VisualFormat, limits: InputLimits) -> Result<()> {
let size = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
let limit = match format {
VisualFormat::Svg => limits.max_svg_bytes,
VisualFormat::Png | VisualFormat::Jpeg => limits.max_image_bytes,
};
if size > limit {
return Err(DocxError::resource_limit(format!(
"{} visual is {size} bytes; limit is {limit} bytes",
format.extension().to_ascii_uppercase()
)));
}
Ok(())
}
fn intrinsic_dimensions_for_source(
source: &LoadedVisualSource,
limits: InputLimits,
) -> Result<(u32, u32)> {
match source.format {
VisualFormat::Png | VisualFormat::Jpeg => {
let dimensions = image::ImageReader::new(Cursor::new(&source.bytes))
.with_guessed_format()
.map_err(|error| {
DocxError::parse(format!("failed to inspect visual format: {error}"))
})?
.into_dimensions()
.map_err(|error| {
DocxError::parse(format!("failed to inspect visual dimensions: {error}"))
})?;
ensure_pixel_budget(dimensions.0, dimensions.1, "decoded visual", limits)?;
Ok(dimensions)
}
VisualFormat::Svg => {
let tree = parse_svg_tree(&source.bytes, source.resources_dir.as_deref())?;
let size = tree.size().to_int_size();
Ok((size.width(), size.height()))
}
}
}
fn parse_svg_tree(bytes: &[u8], resources_dir: Option<&Path>) -> Result<usvg::Tree> {
let mut options = usvg::Options {
resources_dir: resources_dir.map(Path::to_path_buf),
image_href_resolver: usvg::ImageHrefResolver {
resolve_data: usvg::ImageHrefResolver::default_data_resolver(),
resolve_string: Box::new(|_, _| None),
},
..usvg::Options::default()
};
options.fontdb_mut().load_system_fonts();
usvg::Tree::from_data(bytes, &options)
.map_err(|error| DocxError::parse(format!("failed to parse SVG visual: {error}")))
}
fn rasterize_svg(
bytes: &[u8],
resources_dir: Option<&Path>,
width_px: u32,
height_px: u32,
limits: InputLimits,
) -> Result<RasterizedVisual> {
let tree = parse_svg_tree(bytes, resources_dir)?;
let width_px = width_px.max(1);
let height_px = height_px.max(1);
ensure_pixel_budget(width_px, height_px, "SVG render surface", limits)?;
let mut pixmap = Pixmap::new(width_px, height_px)
.ok_or_else(|| DocxError::parse("failed to allocate SVG render surface"))?;
let source_size = tree.size();
let transform = Transform::from_scale(
width_px as f32 / source_size.width(),
height_px as f32 / source_size.height(),
);
resvg::render(&tree, transform, &mut pixmap.as_mut());
Ok(RasterizedVisual {
width_px,
height_px,
rgba: pixmap.data().to_vec(),
})
}
fn rasterize_raster_image(
bytes: &[u8],
width_px: u32,
height_px: u32,
limits: InputLimits,
) -> Result<RasterizedVisual> {
let source_dimensions = image::ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|error| DocxError::parse(format!("failed to inspect visual format: {error}")))?
.into_dimensions()
.map_err(|error| {
DocxError::parse(format!("failed to inspect visual dimensions: {error}"))
})?;
ensure_pixel_budget(
source_dimensions.0,
source_dimensions.1,
"decoded visual",
limits,
)?;
let image = image::load_from_memory(bytes)
.map_err(|error| DocxError::parse(format!("failed to decode visual: {error}")))?;
let width_px = width_px.max(1);
let height_px = height_px.max(1);
ensure_pixel_budget(width_px, height_px, "visual render surface", limits)?;
let resized = if image.width() == width_px && image.height() == height_px {
image
} else {
image.resize_exact(width_px, height_px, FilterType::Lanczos3)
};
Ok(RasterizedVisual {
width_px,
height_px,
rgba: resized.to_rgba8().into_raw(),
})
}
fn ensure_pixel_budget(width: u32, height: u32, label: &str, limits: InputLimits) -> Result<()> {
let pixels = u64::from(width).saturating_mul(u64::from(height));
let limit = limits.max_image_pixels;
if pixels > limit {
return Err(DocxError::resource_limit(format!(
"{label} is {width}x{height} ({pixels} pixels); limit is {limit} pixels"
)));
}
Ok(())
}
fn encode_png(raster: &RasterizedVisual) -> Result<Vec<u8>> {
let mut bytes = Vec::new();
PngEncoder::new(&mut bytes)
.write_image(
&raster.rgba,
raster.width_px,
raster.height_px,
ColorType::Rgba8.into(),
)
.map_err(|error| DocxError::parse(format!("failed to encode PNG visual: {error}")))?;
Ok(bytes)
}
pub(crate) fn resolve_dimensions_from_intrinsic(
visual: &Visual,
intrinsic_width_twips: u32,
intrinsic_height_twips: u32,
content_width_twips: u32,
content_height_twips: u32,
) -> (u32, u32) {
let intrinsic_width_twips = intrinsic_width_twips.max(1);
let intrinsic_height_twips = intrinsic_height_twips.max(1);
let (mut width, mut height) = match (visual.sizing.width_twips, visual.sizing.height_twips) {
(Some(width), Some(height)) => (width.max(1), height.max(1)),
(Some(width), None) => (
width.max(1),
scale_dimension(width.max(1), intrinsic_height_twips, intrinsic_width_twips),
),
(None, Some(height)) => (
scale_dimension(height.max(1), intrinsic_width_twips, intrinsic_height_twips),
height.max(1),
),
(None, None) => (intrinsic_width_twips, intrinsic_height_twips),
};
let max_width = visual
.sizing
.max_width_twips
.unwrap_or_else(|| visual.kind.default_max_width_twips(content_width_twips))
.min(content_width_twips.max(1));
let max_height = visual
.sizing
.max_height_twips
.unwrap_or_else(|| visual.kind.default_max_height_twips(content_height_twips))
.min(content_height_twips.max(1));
if width > max_width || height > max_height {
let width_ratio = max_width as f64 / width as f64;
let height_ratio = max_height as f64 / height as f64;
let scale = width_ratio.min(height_ratio);
width = ((width as f64 * scale).round() as u32).max(1);
height = ((height as f64 * scale).round() as u32).max(1);
}
(width.max(1), height.max(1))
}
fn scale_dimension(base: u32, numerator: u32, denominator: u32) -> u32 {
if denominator == 0 {
base.max(1)
} else {
((u64::from(base) * u64::from(numerator) + u64::from(denominator / 2))
/ u64::from(denominator)) as u32
}
.max(1)
}
pub(crate) fn pixels_to_twips(pixels: u32) -> u32 {
pixels.saturating_mul(TWIPS_PER_PIXEL_AT_96_DPI).max(1)
}
pub(crate) fn twips_to_pixels_at_dpi(twips: u32, dpi: u32) -> u32 {
(u64::from(twips) * u64::from(dpi)).div_ceil(1_440) as u32
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use tempfile::tempdir;
use super::{
ensure_pixel_budget, ensure_visual_byte_limit, parse_svg_tree, pixels_to_twips,
resolve_dimensions_from_intrinsic, twips_to_pixels_at_dpi, Visual, VisualFormat,
VisualKind,
};
use crate::{InputLimits, ParagraphAlignment};
const SIMPLE_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 40">
<rect width="120" height="40" fill="#F8FAFC"/>
<path d="M12 28 L28 12 L44 28" stroke="#0F766E" stroke-width="6" fill="none" stroke-linecap="round"/>
<text x="52" y="26" font-size="16" fill="#0F172A">RusDox</text>
</svg>"##;
#[test]
fn visual_byte_and_pixel_limits_are_enforced_before_rendering() {
let byte_error = ensure_visual_byte_limit(
b"oversized",
VisualFormat::Svg,
InputLimits {
max_svg_bytes: 4,
..InputLimits::default()
},
)
.expect_err("SVG byte ceiling must fail");
assert!(byte_error.to_string().contains("resource limit"));
let pixel_error = ensure_pixel_budget(8_001, 8_000, "test raster", InputLimits::default())
.expect_err("pixel ceiling must fail");
assert!(pixel_error.to_string().contains("64008000 pixels"));
}
#[test]
fn visual_kind_defaults_are_stable() {
assert_eq!(
Visual::from_path("hero.png").alignment(),
&ParagraphAlignment::Center
);
assert_eq!(
Visual::logo("mark.svg").alignment(),
&ParagraphAlignment::Left
);
assert_eq!(
Visual::signature("sig.svg").alignment(),
&ParagraphAlignment::Right
);
assert_eq!(
Visual::chart("bench.svg").alignment(),
&ParagraphAlignment::Center
);
}
#[test]
fn visual_dimensions_fit_within_kind_defaults() {
let image = Visual::from_path("placeholder.png");
let logo = Visual::logo("placeholder.svg");
let signature = Visual::signature("placeholder.svg");
let chart = Visual::chart("placeholder.svg");
let image_size = resolve_dimensions_from_intrinsic(&image, 9_000, 4_500, 6_000, 10_000);
let logo_size = resolve_dimensions_from_intrinsic(&logo, 9_000, 4_500, 6_000, 10_000);
let signature_size =
resolve_dimensions_from_intrinsic(&signature, 9_000, 4_500, 6_000, 10_000);
let chart_size = resolve_dimensions_from_intrinsic(&chart, 9_000, 4_500, 6_000, 10_000);
assert_eq!(image_size, (6_000, 3_000));
assert!(logo_size.0 <= 2_880);
assert!(logo_size.1 <= 1_440);
assert!(signature_size.0 <= 3_600);
assert!(signature_size.1 <= 1_080);
assert_eq!(chart_size, (6_000, 3_000));
}
#[test]
fn explicit_visual_dimension_preserves_aspect_ratio() {
let visual = Visual::from_path("photo.png").width_twips(2_400);
let size = resolve_dimensions_from_intrinsic(&visual, 4_800, 1_600, 8_000, 10_000);
assert_eq!(size, (2_400, 800));
}
#[test]
fn pixel_twip_conversions_match_expected_document_units() {
assert_eq!(pixels_to_twips(96), 1_440);
assert_eq!(twips_to_pixels_at_dpi(1_440, 192), 192);
assert_eq!(twips_to_pixels_at_dpi(2_880, 192), 384);
}
#[test]
fn svg_visual_supports_intrinsic_dimensions_and_rasterization() {
let visual = Visual::from_bytes(SIMPLE_SVG.as_bytes().to_vec(), VisualFormat::Svg)
.with_kind(VisualKind::Logo);
let intrinsic = visual.intrinsic_dimensions().expect("svg dimensions");
assert_eq!(intrinsic, (120, 40));
let raster = visual.pdf_raster(2_400, 800).expect("svg raster");
assert_eq!(raster.width_px, 320);
assert_eq!(raster.height_px, 107);
assert_eq!(raster.rgba.len(), 320 * 107 * 4);
}
#[test]
fn svg_parser_never_reads_external_file_references() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><image href="/etc/passwd" width="10" height="10"/></svg>"#;
let tree = parse_svg_tree(svg, Some(Path::new("/"))).expect("safe SVG parse");
assert!(tree.root().children().is_empty());
}
#[test]
fn docx_media_rasterizes_svg_to_png() {
let visual = Visual::from_bytes(SIMPLE_SVG.as_bytes().to_vec(), VisualFormat::Svg)
.with_kind(VisualKind::Chart);
let (format, bytes) = visual.docx_media(4_800, 1_600).expect("docx media");
assert_eq!(format, VisualFormat::Png);
assert!(bytes.starts_with(b"\x89PNG\r\n\x1A\n"));
}
#[test]
fn path_backed_visual_renders_without_external_resources() {
let temp = tempdir().expect("temp dir");
let svg_path = temp.path().join("logo.svg");
fs::write(&svg_path, SIMPLE_SVG).expect("write svg");
let visual = Visual::logo(&svg_path);
let intrinsic = visual.intrinsic_dimensions().expect("svg dimensions");
assert_eq!(intrinsic, (120, 40));
}
}