document-svg 2.0.2

Convert PDF, Word, Excel, PowerPoint, diagram and CAD files into one SVG per page, locally — a Rust library and the docsvg command
Documentation
use std::fmt::Write as FmtWrite;
use std::io::{Cursor, Read};

use crate::convert::{ConvertOptions, PageConsumer};
use crate::error::{Error, Result};
use crate::ir::Page;

const MAX_OFF_BYTES: u64 = 256 * 1024 * 1024;
const MAX_OFF_LINES: usize = 5_000_000;
const MAX_OFF_LINE_BYTES: usize = 1024 * 1024;
const MAX_OFF_VERTICES: usize = 1_000_000;
const MAX_OFF_FACES: usize = 200_000;
const MAX_OFF_FACE_VERTICES: usize = 100_000;
const MAX_OFF_INDICES: usize = 4_000_000;
const MAX_OBJ_TEXT_BYTES: usize = 256 * 1024 * 1024;

struct OffPageSink<'a> {
    inner: &'a mut dyn PageConsumer,
}

impl PageConsumer for OffPageSink<'_> {
    fn consume(&mut self, mut page: Page) -> Result<()> {
        page.source_format = "off".into();
        page.title = "OFF polygon mesh".into();
        self.inner.consume(page)
    }
}

pub(crate) fn convert<R: Read>(
    input: R,
    options: &ConvertOptions,
    sink: &mut dyn PageConsumer,
) -> Result<Vec<String>> {
    let max_bytes = options.max_input_bytes.min(MAX_OFF_BYTES);
    let mut bytes = Vec::new();
    Read::take(input, max_bytes.saturating_add(1)).read_to_end(&mut bytes)?;
    if bytes.len() as u64 > max_bytes {
        return Err(Error::LimitExceeded(format!(
            "OFF input exceeds maximum limit of {max_bytes} bytes"
        )));
    }
    let text = String::from_utf8(bytes).map_err(|error| {
        Error::Unsupported(format!("binary OFF files are unsupported: {error}"))
    })?;
    let (obj_text, mut warnings) = parse_off(&text)?;
    if obj_text.len() as u64 > options.max_input_bytes || obj_text.len() > MAX_OBJ_TEXT_BYTES {
        return Err(Error::LimitExceeded(
            "OFF-to-OBJ intermediate mesh exceeds its bounded text limit".into(),
        ));
    }
    let mut page_sink = OffPageSink { inner: sink };
    warnings.extend(crate::cad::obj::convert(
        Cursor::new(obj_text),
        options,
        &mut page_sink,
    )?);
    Ok(warnings)
}

fn parse_off(text: &str) -> Result<(String, Vec<String>)> {
    if text.lines().count() > MAX_OFF_LINES {
        return Err(Error::LimitExceeded(format!(
            "OFF input exceeds {MAX_OFF_LINES} lines"
        )));
    }
    let lines = text.lines().collect::<Vec<_>>();
    let mut line_index = 0usize;
    let (header_line, header) = next_data_line(&lines, &mut line_index)?;
    let mut header_parts = header.split_whitespace();
    let first = header_parts.next().unwrap_or_default();
    let variant = first.to_ascii_uppercase();
    if !["OFF", "COFF", "NOFF", "CNOFF"].contains(&variant.as_str()) && variant.ends_with("OFF") {
        return Err(Error::Unsupported(format!(
            "OFF header variant '{first}' is unsupported; only 3D OFF/COFF/NOFF/CNOFF is rendered"
        )));
    }
    let (has_colors, has_normals, header_present) = match variant.as_str() {
        "OFF" => (false, false, true),
        "COFF" => (true, false, true),
        "NOFF" => (false, true, true),
        "CNOFF" => (true, true, true),
        _ => (false, false, false),
    };

    let mut count_tokens = Vec::<&str>::new();
    if header_present {
        count_tokens.extend(header_parts);
    } else {
        count_tokens.extend(header.split_whitespace());
    }
    while count_tokens.len() < 3 {
        let (_, line) = next_data_line(&lines, &mut line_index)?;
        count_tokens.extend(line.split_whitespace());
    }
    if count_tokens.len() != 3 {
        return Err(Error::InvalidInput(format!(
            "OFF vertex/face/edge counts at line {} must contain exactly three integers",
            header_line
        )));
    }
    let vertex_count = parse_count(count_tokens[0], "vertex", header_line, MAX_OFF_VERTICES)?;
    let face_count = parse_count(count_tokens[1], "face", header_line, MAX_OFF_FACES)?;
    let _declared_edge_count = parse_count(count_tokens[2], "edge", header_line, MAX_OFF_INDICES)?;
    if vertex_count == 0 {
        return Err(Error::Unsupported("OFF mesh contains no vertices".into()));
    }
    if face_count == 0 {
        return Err(Error::Unsupported(
            "OFF preview requires at least one polygon face".into(),
        ));
    }

    let mut obj = String::new();
    let mut warnings = Vec::new();
    let mut ignored_attributes = has_colors || has_normals;
    for vertex_id in 0..vertex_count {
        let (line_number, line) = next_data_line(&lines, &mut line_index)?;
        if line_index > MAX_OFF_LINES {
            return Err(Error::LimitExceeded(format!(
                "OFF input exceeds {MAX_OFF_LINES} lines"
            )));
        }
        let values = line.split_whitespace().collect::<Vec<_>>();
        if values.len() < 3 {
            return Err(Error::InvalidInput(format!(
                "OFF vertex {} at line {line_number} needs three coordinates",
                vertex_id + 1
            )));
        }
        let mut coordinates = [0.0; 3];
        for index in 0..3 {
            coordinates[index] = parse_coordinate(values[index], line_number)?;
        }
        ignored_attributes |= values.len() > 3;
        let old_len = obj.len();
        writeln!(
            &mut obj,
            "v {} {} {}",
            coordinates[0], coordinates[1], coordinates[2]
        )
        .map_err(|_| Error::InvalidInput("could not write OFF intermediate vertex".into()))?;
        enforce_obj_text_limit(&obj, old_len)?;
    }

    let mut total_indices = 0usize;
    for face_id in 0..face_count {
        let (line_number, line) = next_data_line(&lines, &mut line_index)?;
        let values = line.split_whitespace().collect::<Vec<_>>();
        let Some(count_text) = values.first() else {
            return Err(Error::InvalidInput(format!(
                "OFF face {} at line {line_number} is empty",
                face_id + 1
            )));
        };
        let face_vertices = parse_count(
            count_text,
            "face vertex",
            line_number,
            MAX_OFF_FACE_VERTICES,
        )?;
        if face_vertices < 3 {
            return Err(Error::InvalidInput(format!(
                "OFF face {} at line {line_number} has fewer than three vertices",
                face_id + 1
            )));
        }
        let required = face_vertices
            .checked_add(1)
            .ok_or_else(|| Error::LimitExceeded("OFF face field count overflowed".into()))?;
        if values.len() < required {
            return Err(Error::InvalidInput(format!(
                "OFF face {} at line {line_number} has an incomplete vertex list",
                face_id + 1
            )));
        }
        total_indices = total_indices
            .checked_add(face_vertices)
            .ok_or_else(|| Error::LimitExceeded("OFF face index count overflowed".into()))?;
        if total_indices > MAX_OFF_INDICES {
            return Err(Error::LimitExceeded(format!(
                "OFF total face indices exceed {MAX_OFF_INDICES}"
            )));
        }
        let old_len = obj.len();
        obj.push('f');
        for index in &values[1..required] {
            let vertex = index.parse::<usize>().map_err(|_| {
                Error::InvalidInput(format!(
                    "invalid OFF vertex index '{index}' at line {line_number}"
                ))
            })?;
            if vertex >= vertex_count {
                return Err(Error::InvalidInput(format!(
                    "OFF face {} references vertex {vertex}, outside 0..{}",
                    face_id + 1,
                    vertex_count
                )));
            }
            write!(&mut obj, " {}", vertex + 1)
                .map_err(|_| Error::InvalidInput("could not write OFF intermediate face".into()))?;
        }
        obj.push('\n');
        enforce_obj_text_limit(&obj, old_len)?;
        ignored_attributes |= values.len() > required;
    }

    while let Some((line_number, line)) = next_optional_data_line(&lines, &mut line_index)? {
        if line.eq_ignore_ascii_case("End") {
            continue;
        }
        return Err(Error::Unsupported(format!(
            "OFF contains extra data after its declared faces at line {line_number}"
        )));
    }
    if ignored_attributes {
        warnings.push(
            "OFF vertex normals/colors and face colors were ignored; geometry is rendered with the shared shaded-mesh renderer".into(),
        );
    }
    Ok((obj, warnings))
}

fn next_data_line<'a>(lines: &[&'a str], index: &mut usize) -> Result<(usize, &'a str)> {
    next_optional_data_line(lines, index)?.ok_or_else(|| {
        Error::InvalidInput("OFF input ended before all declared mesh records were read".into())
    })
}

fn next_optional_data_line<'a>(
    lines: &[&'a str],
    index: &mut usize,
) -> Result<Option<(usize, &'a str)>> {
    while *index < lines.len() {
        let line_number = *index + 1;
        let line = lines[*index].trim_end_matches('\r');
        *index += 1;
        if line.len() > MAX_OFF_LINE_BYTES {
            return Err(Error::LimitExceeded(format!(
                "OFF line {line_number} exceeds {MAX_OFF_LINE_BYTES} bytes"
            )));
        }
        let line = line.split('#').next().unwrap_or_default().trim();
        if !line.is_empty() {
            return Ok(Some((line_number, line)));
        }
    }
    Ok(None)
}

fn parse_count(value: &str, context: &str, line_number: usize, limit: usize) -> Result<usize> {
    let count = value.parse::<usize>().map_err(|_| {
        Error::InvalidInput(format!(
            "invalid OFF {context} count '{value}' at line {line_number}"
        ))
    })?;
    if count > limit {
        return Err(Error::LimitExceeded(format!(
            "OFF {context} count exceeds {limit}"
        )));
    }
    Ok(count)
}

fn parse_coordinate(value: &str, line_number: usize) -> Result<f64> {
    let coordinate = value.parse::<f64>().map_err(|_| {
        Error::InvalidInput(format!(
            "invalid OFF vertex coordinate '{value}' at line {line_number}"
        ))
    })?;
    if !coordinate.is_finite() || coordinate.abs() > 1.0e12 {
        return Err(Error::InvalidInput(format!(
            "OFF vertex coordinate is non-finite or outside ±1e12 at line {line_number}"
        )));
    }
    Ok(coordinate)
}

fn enforce_obj_text_limit(output: &str, old_len: usize) -> Result<()> {
    if output.len() > MAX_OBJ_TEXT_BYTES {
        return Err(Error::LimitExceeded(format!(
            "OFF-to-OBJ output exceeded {MAX_OBJ_TEXT_BYTES} bytes (record started at byte {old_len})"
        )));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::parse_off;
    use crate::error::Error;

    #[test]
    fn converts_standard_and_count_inline_off_faces_to_obj() {
        let off = "# square\nOFF 4 1 4\n0 0 0\n1 0 0\n1 1 0\n0 1 0\n4 0 1 2 3\n";
        let (obj, warnings) = parse_off(off).unwrap();
        assert!(obj.contains("v 1 1 0"));
        assert!(obj.contains("f 1 2 3 4"));
        assert!(warnings.is_empty());
    }

    #[test]
    fn supports_coff_and_noff_geometry_but_reports_ignored_attributes() {
        let coff =
            "COFF\n3 1 3\n0 0 0 255 0 0\n1 0 0 0 255 0\n0 1 0 0 0 255\n3 0 1 2 255 255 255\n";
        let (coff_obj, warnings) = parse_off(coff).unwrap();
        assert!(coff_obj.contains("f 1 2 3"));
        assert!(warnings.iter().any(|warning| warning.contains("colors")));

        let noff = "NOFF\n3 1 3\n0 0 0 0 0 1\n1 0 0 0 0 1\n0 1 0 0 0 1\n3 0 1 2\n";
        let (noff_obj, warnings) = parse_off(noff).unwrap();
        assert!(noff_obj.contains("f 1 2 3"));
        assert!(warnings.iter().any(|warning| warning.contains("normals")));
    }

    #[test]
    fn rejects_invalid_indices_and_unsupported_header_variants() {
        let invalid_index = "OFF\n3 1 3\n0 0 0\n1 0 0\n0 1 0\n3 0 1 9\n";
        assert!(matches!(
            parse_off(invalid_index),
            Err(Error::InvalidInput(_))
        ));
        let four_d = "4OFF\n4 1 4\n0 0 0 0\n1 0 0 0\n0 1 0 0\n0 0 1 0\n3 0 1 2\n";
        assert!(matches!(parse_off(four_d), Err(Error::Unsupported(_))));
    }
}