svg-format 0.1.1

Structural formatter for SVG documents
Documentation
/// Remove common leading whitespace from a block of text, trimming leading and
/// trailing blank lines, while also returning the byte offset where the
/// dedented content begins within the original text.
pub fn dedent_block_with_offset(text: &str) -> (String, Option<usize>) {
    if text.is_empty() {
        return (String::new(), None);
    }

    let mut lines = Vec::new();
    let mut starts = Vec::new();
    let mut offset = 0usize;
    for segment in text.split_inclusive('\n') {
        let mut line = segment.strip_suffix('\n').unwrap_or(segment);
        line = line.strip_suffix('\r').unwrap_or(line);
        lines.push(line);
        starts.push(offset);
        offset += segment.len();
    }

    if lines.is_empty() {
        return (String::new(), None);
    }

    let first_non_empty = lines.iter().position(|l| !l.trim().is_empty());
    let last_non_empty = lines.iter().rposition(|l| !l.trim().is_empty());
    let (Some(start), Some(end)) = (first_non_empty, last_non_empty) else {
        return (String::new(), None);
    };

    let block = &lines[start..=end];
    // Common indent is measured in whitespace *characters* so the per-line
    // byte cut always lands on a UTF-8 boundary. Every non-blank line has at
    // least `min_indent` leading whitespace chars, so `cut_indent_bytes`
    // never slices through a multi-byte character.
    let min_indent = block
        .iter()
        .filter(|l| !l.trim().is_empty())
        .map(|l| l.chars().take_while(|c| c.is_whitespace()).count())
        .min()
        .unwrap_or(0);

    let dedented = block
        .iter()
        .map(|l| {
            if l.trim().is_empty() {
                ""
            } else {
                &l[cut_indent_bytes(l, min_indent)..]
            }
        })
        .collect::<Vec<_>>()
        .join("\n");

    // The dedented content begins on `lines[start]`, after the same byte cut
    // that `dedented` applies to it — so the file offset reuses
    // `cut_indent_bytes` for an exact match with the slicing above.
    let content_offset = starts[start] + cut_indent_bytes(lines[start], min_indent);
    (dedented, Some(content_offset))
}

/// Byte length of `line`'s first `indent_chars` leading whitespace
/// characters. The caller guarantees `line` has at least `indent_chars`
/// leading whitespace chars, so the returned length is a UTF-8 char
/// boundary and `&line[returned..]` never panics.
fn cut_indent_bytes(line: &str, indent_chars: usize) -> usize {
    line.chars().take(indent_chars).map(char::len_utf8).sum()
}

/// Collapse runs of whitespace into single spaces and trim.
pub fn collapse_whitespace(text: &str) -> String {
    let mut result = String::with_capacity(text.len());
    let mut prev_ws = true; // treat start as whitespace to trim leading
    for ch in text.chars() {
        if ch.is_whitespace() {
            if !prev_ws {
                result.push(' ');
            }
            prev_ws = true;
        } else {
            result.push(ch);
            prev_ws = false;
        }
    }
    // trim trailing space
    if result.ends_with(' ') {
        result.pop();
    }
    result
}

/// SVG elements whose content is whitespace-sensitive inline text.
///
/// For these elements the formatter preserves raw content between the start
/// and end tags as a single text block instead of formatting each child node
/// on its own line (which would break entity references like `&lt;` apart
/// from surrounding text).
pub fn is_text_content_element(tag_name: &str) -> bool {
    matches!(tag_name, "text" | "tspan" | "textPath" | "title" | "desc")
}

#[derive(Clone, Copy)]
enum TextContentToken<'a> {
    Text(&'a str),
    Entity(&'a str),
    Whitespace(&'a str),
}

pub fn normalize_text_content_with_entities(text: &str) -> String {
    let mut tokens = Vec::new();
    let mut offset = 0;
    while offset < text.len() {
        let rest = &text[offset..];
        let Some(ch) = rest.chars().next() else {
            break;
        };

        if ch.is_whitespace() {
            let start = offset;
            offset += ch.len_utf8();
            while offset < text.len() {
                let Some(next) = text[offset..].chars().next() else {
                    break;
                };
                if !next.is_whitespace() {
                    break;
                }
                offset += next.len_utf8();
            }
            tokens.push(TextContentToken::Whitespace(&text[start..offset]));
            continue;
        }

        if ch == '&'
            && let Some(len) = entity_reference_len(rest)
        {
            tokens.push(TextContentToken::Entity(&text[offset..offset + len]));
            offset += len;
            continue;
        }

        let start = offset;
        offset += ch.len_utf8();
        while offset < text.len() {
            let Some(next) = text[offset..].chars().next() else {
                break;
            };
            if next.is_whitespace() {
                break;
            }
            if next == '&' && entity_reference_len(&text[offset..]).is_some() {
                break;
            }
            offset += next.len_utf8();
        }
        tokens.push(TextContentToken::Text(&text[start..offset]));
    }

    let mut normalized = String::new();
    for (index, token) in tokens.iter().enumerate() {
        match token {
            TextContentToken::Text(text) | TextContentToken::Entity(text) => {
                normalized.push_str(text);
            }
            TextContentToken::Whitespace(space) => {
                let prev = tokens[..index]
                    .iter()
                    .rev()
                    .find(|token| !matches!(token, TextContentToken::Whitespace(_)));
                let next = tokens[index + 1..]
                    .iter()
                    .find(|token| !matches!(token, TextContentToken::Whitespace(_)));

                let (Some(prev), Some(next)) = (prev, next) else {
                    continue;
                };

                if should_strip_entity_boundary_space(*prev, *next, space) {
                    continue;
                }

                if !normalized.ends_with(' ') {
                    normalized.push(' ');
                }
            }
        }
    }

    normalized.trim().to_string()
}

fn should_strip_entity_boundary_space(
    prev: TextContentToken<'_>,
    next: TextContentToken<'_>,
    whitespace: &str,
) -> bool {
    if !whitespace.contains(['\n', '\r']) {
        return false;
    }

    matches!(prev, TextContentToken::Entity(entity) if is_open_angle_entity(entity))
        && matches!(next, TextContentToken::Text(_))
        || matches!(prev, TextContentToken::Text(_))
            && matches!(next, TextContentToken::Entity(entity) if is_close_angle_entity(entity))
}

fn entity_reference_len(text: &str) -> Option<usize> {
    let end = text.find(';')?;
    let candidate = &text[..=end];
    let body = &candidate[1..candidate.len() - 1];
    if body.is_empty() {
        return None;
    }

    let valid = body
        .strip_prefix("#x")
        .or_else(|| body.strip_prefix("#X"))
        .map_or_else(
            || {
                body.strip_prefix('#').map_or_else(
                    || body.chars().all(|ch| ch.is_ascii_alphanumeric()),
                    |decimal| !decimal.is_empty() && decimal.chars().all(|ch| ch.is_ascii_digit()),
                )
            },
            |hex| !hex.is_empty() && hex.chars().all(|ch| ch.is_ascii_hexdigit()),
        );

    valid.then_some(candidate.len())
}

fn is_open_angle_entity(entity: &str) -> bool {
    matches!(
        entity.to_ascii_lowercase().as_str(),
        "&lt;" | "&#60;" | "&#x3c;"
    )
}

fn is_close_angle_entity(entity: &str) -> bool {
    matches!(
        entity.to_ascii_lowercase().as_str(),
        "&gt;" | "&#62;" | "&#x3e;"
    )
}

/// Decode XML character and entity references to literal characters.
///
/// Handles the five predefined XML entities (`&lt;`, `&gt;`, `&amp;`,
/// `&quot;`, `&apos;`) and numeric character references (`&#60;`,
/// `&#x3C;`). Unrecognized named entities are left verbatim.
///
/// Takes ownership to avoid allocation when the input contains no entities.
pub fn decode_xml_entities(text: String) -> String {
    if !text.contains('&') {
        return text;
    }

    let mut result = String::with_capacity(text.len());
    let mut offset = 0;

    while offset < text.len() {
        let rest = &text[offset..];

        if rest.starts_with('&')
            && let Some(len) = entity_reference_len(rest)
        {
            let entity = &rest[..len];
            if let Some(decoded) = decode_entity_char(entity) {
                result.push(decoded);
            } else {
                // Unknown named entity — keep verbatim
                result.push_str(entity);
            }
            offset += len;
            continue;
        }

        let Some(ch) = rest.chars().next() else {
            break;
        };
        result.push(ch);
        offset += ch.len_utf8();
    }

    result
}

/// If `text` is a single CDATA section surrounded only by whitespace,
/// return the inner payload and strip the `<![CDATA[` / `]]>` markers.
///
/// Used to peel the wrapper off `<style>` / `<script>` raw text before
/// delegating to a host formatter — the host's grammar (CSS, JS) does not
/// understand CDATA markers as content and rejects them as syntax errors.
/// The caller is responsible for restoring the wrapper on output.
///
/// Returns `None` if the text contains anything other than a single CDATA
/// section (multiple sections, surrounding non-whitespace characters, or
/// embedded `]]>` terminators that would prevent a clean round-trip).
///
/// On success returns `(inner_byte_offset, inner)` where `inner_byte_offset`
/// is the byte position of `inner` within the original `text` — i.e.
/// `&text[inner_byte_offset..][..inner.len()] == inner`. This lets callers
/// map a position inside the payload back to the original source without
/// fragile raw-pointer subtraction. The offset is derived from
/// [`str::trim`]'s leading-whitespace count plus the `<![CDATA[` prefix
/// length, both of which fall on UTF-8 char boundaries.
pub fn strip_cdata_wrapper(text: &str) -> Option<(usize, &str)> {
    const PREFIX: &str = "<![CDATA[";
    let trimmed = text.trim_start();
    let leading_ws = text.len() - trimmed.len();
    let inner = trimmed
        .trim_end()
        .strip_prefix(PREFIX)
        .and_then(|s| s.strip_suffix("]]>"))?;
    // Reject payloads that contain further CDATA markers — we cannot
    // safely round-trip nested or concatenated sections through a host
    // formatter. SVG stylesheets in the wild use a single section.
    if inner.contains("<![CDATA[") || inner.contains("]]>") {
        return None;
    }
    Some((leading_ws + PREFIX.len(), inner))
}

/// Encode characters that are invalid in XML text content as entity
/// references.
///
/// Encodes `&` → `&amp;`, `<` → `&lt;`, `>` → `&gt;`. Quotes are left
/// unencoded since they are valid in XML text content positions.
pub fn encode_xml_entities(text: &str) -> String {
    if !text.contains(['&', '<', '>']) {
        return text.to_string();
    }

    let mut result = String::with_capacity(text.len());
    for ch in text.chars() {
        match ch {
            '&' => result.push_str("&amp;"),
            '<' => result.push_str("&lt;"),
            '>' => result.push_str("&gt;"),
            _ => result.push(ch),
        }
    }
    result
}

/// Resolve a single entity reference to its character value.
fn decode_entity_char(entity: &str) -> Option<char> {
    let body = &entity[1..entity.len() - 1];
    match body {
        "lt" => Some('<'),
        "gt" => Some('>'),
        "amp" => Some('&'),
        "quot" => Some('"'),
        "apos" => Some('\''),
        _ => body
            .strip_prefix("#x")
            .or_else(|| body.strip_prefix("#X"))
            .map_or_else(
                || {
                    body.strip_prefix('#')
                        .and_then(|dec| dec.parse::<u32>().ok())
                        .and_then(char::from_u32)
                },
                |hex| u32::from_str_radix(hex, 16).ok().and_then(char::from_u32),
            ),
    }
}