katana-canvas-forge 0.1.7

Versioned diagram rendering and document export runtime for KatanA (Mermaid, Draw.io, HTML/PDF/PNG/JPEG).
Documentation
use super::native_text::{
    CODE_FENCE_MARKER, HEADING_COLUMN_SIZE_H1, HEADING_COLUMN_SIZE_H2, HEADING_COLUMN_SIZE_H3,
    HEADING_END_MARKER, HEADING_LEVEL_1, HEADING_LEVEL_2, HEADING_LEVEL_3, HEADING_LEVEL_4,
    HEADING_LEVEL_5, HEADING_LEVEL_6, HEADING_SEP_MARKER, HEADING_START_MARKER, NativeTextLine,
    TEXT_COLUMNS, WORD_SPACING,
};
use super::native_text_parser_html::{decode_entities, strip_tags};

pub(super) fn parse_typed_lines(
    text: &str,
    code_blocks: &[Vec<NativeTextLine>],
) -> Vec<NativeTextLine> {
    let mut result = Vec::new();
    for raw_line in text.lines() {
        let line = raw_line.trim();
        if line.is_empty() {
            continue;
        }
        if let Some(code_lines) = parse_code_fence(line, code_blocks) {
            result.extend(code_lines.iter().cloned());
            continue;
        }
        if let Some(wrapped) = parse_heading_line(line) {
            result.extend(wrapped);
            continue;
        }

        append_wrapped_body_lines(line, &mut result);
    }
    result
}

fn parse_code_fence<'a>(
    line: &'a str,
    code_blocks: &'a [Vec<NativeTextLine>],
) -> Option<&'a Vec<NativeTextLine>> {
    if !line.starts_with(CODE_FENCE_MARKER)
        || !line.ends_with(CODE_FENCE_MARKER)
        || line.len() <= CODE_FENCE_MARKER.len()
    {
        return None;
    }
    let inner = &line[CODE_FENCE_MARKER.len()..line.len() - CODE_FENCE_MARKER.len()];
    let Ok(idx) = inner.parse::<usize>() else {
        return None;
    };
    code_blocks.get(idx)
}

fn parse_heading_line(line: &str) -> Option<Vec<NativeTextLine>> {
    if !line.starts_with(HEADING_START_MARKER) {
        return None;
    }
    let rest = &line[HEADING_START_MARKER.len()..];
    let sep_pos = rest.find(HEADING_SEP_MARKER)?;
    let level_str = &rest[..sep_pos];
    let after_sep = &rest[sep_pos + HEADING_SEP_MARKER.len()..];
    let content_raw = after_sep.trim_end_matches(HEADING_END_MARKER).trim();
    let level = heading_level(level_str)?;
    let clean = strip_tags(content_raw);
    let text = decode_entities(&clean);
    let mut rows = Vec::new();
    for wrapped in wrap_line_n(&text, heading_columns(level)) {
        rows.push(NativeTextLine::heading(wrapped, level));
    }
    Some(rows)
}

fn heading_level(level_str: &str) -> Option<u8> {
    match level_str {
        "h1" => Some(HEADING_LEVEL_1),
        "h2" => Some(HEADING_LEVEL_2),
        "h3" => Some(HEADING_LEVEL_3),
        "h4" => Some(HEADING_LEVEL_4),
        "h5" => Some(HEADING_LEVEL_5),
        "h6" => Some(HEADING_LEVEL_6),
        _ => None,
    }
}

fn heading_columns(level: u8) -> usize {
    match level {
        HEADING_LEVEL_1 => HEADING_COLUMN_SIZE_H1,
        HEADING_LEVEL_2 => HEADING_COLUMN_SIZE_H2,
        HEADING_LEVEL_3 => HEADING_COLUMN_SIZE_H3,
        _ => TEXT_COLUMNS,
    }
}

fn append_wrapped_body_lines(line: &str, result: &mut Vec<NativeTextLine>) {
    let normalized = line.split_whitespace().collect::<Vec<_>>().join(" ");
    for wrapped in wrap_line_n(&normalized, TEXT_COLUMNS) {
        result.push(NativeTextLine::body(wrapped));
    }
}

fn wrap_line_n(line: &str, columns: usize) -> Vec<String> {
    let mut rows = Vec::new();
    let mut current = String::new();
    for word in line.split_whitespace() {
        let current_width = current.chars().count();
        let word_width = word.chars().count();
        if should_wrap_line(current_width, word_width, columns) {
            rows.push(std::mem::take(&mut current));
        }
        append_word(&mut current, word);
    }
    if current.is_empty() {
        return rows;
    }
    rows.push(current);
    rows
}

fn should_wrap_line(current_width: usize, word_width: usize, columns: usize) -> bool {
    if current_width == 0 {
        return false;
    }
    current_width + word_width + WORD_SPACING > columns
}

fn append_word(current: &mut String, word: &str) {
    if current.is_empty() {
        current.push_str(word);
        return;
    }
    current.push(' ');
    current.push_str(word);
}

#[cfg(test)]
#[path = "native_text_parser_lines_tests.rs"]
mod tests;