jumpcut 2.0.1

JumpCut is a library and CLI for converting Fountain-formatted text files into FDX, HTML, JSON, text, and PDF formats.
Documentation
use crate::pagination::sentence_boundary::{
    industry_sentence_boundary_offsets, sentence_boundary_offsets, text_ends_sentence,
};
use crate::pagination::split_scoring::choose_best_scored_split;
use crate::pagination::wrapping::{
    WrapConfig, wrap_text_for_element, wrap_text_for_element_with_offsets,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FlowSplitDecision {
    pub top_line_count: usize,
    pub bottom_line_count: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FlowSplitPlan {
    pub top_text: String,
    pub bottom_text: String,
    pub top_end_offset: usize,
    pub bottom_start_offset: usize,
    pub top_line_count: usize,
    pub bottom_line_count: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct FlowSplitPolicy {
    prefer_sentence_boundaries: bool,
    prefer_fuller_top_fragment: bool,
    allow_exact_fit_sentence_runt: bool,
}

impl Default for FlowSplitPolicy {
    fn default() -> Self {
        Self {
            prefer_sentence_boundaries: true,
            prefer_fuller_top_fragment: true,
            allow_exact_fit_sentence_runt: false,
        }
    }
}

pub fn choose_flow_split(
    text: &str,
    config: &WrapConfig,
    max_top_lines: usize,
    min_top_lines: usize,
    min_bottom_lines: usize,
) -> Option<FlowSplitPlan> {
    choose_flow_split_with_policy(
        text,
        config,
        max_top_lines,
        min_top_lines,
        min_bottom_lines,
        FlowSplitPolicy::default(),
    )
}

pub fn choose_flow_split_allow_exact_fit_sentence_runt(
    text: &str,
    config: &WrapConfig,
    max_top_lines: usize,
    min_top_lines: usize,
    min_bottom_lines: usize,
) -> Option<FlowSplitPlan> {
    choose_flow_split_with_policy(
        text,
        config,
        max_top_lines,
        min_top_lines,
        min_bottom_lines,
        FlowSplitPolicy {
            allow_exact_fit_sentence_runt: true,
            ..FlowSplitPolicy::default()
        },
    )
}

pub fn choose_flow_split_industry(
    text: &str,
    config: &WrapConfig,
    max_top_lines: usize,
    min_top_lines: usize,
    min_bottom_lines: usize,
) -> Option<FlowSplitPlan> {
    choose_flow_split_industry_with_policy(
        text,
        config,
        max_top_lines,
        min_top_lines,
        min_bottom_lines,
        FlowSplitPolicy::default(),
    )
}

pub fn choose_flow_split_industry_allow_exact_fit_sentence_runt(
    text: &str,
    config: &WrapConfig,
    max_top_lines: usize,
    min_top_lines: usize,
    min_bottom_lines: usize,
) -> Option<FlowSplitPlan> {
    choose_flow_split_industry_with_policy(
        text,
        config,
        max_top_lines,
        min_top_lines,
        min_bottom_lines,
        FlowSplitPolicy {
            allow_exact_fit_sentence_runt: true,
            ..FlowSplitPolicy::default()
        },
    )
}

fn choose_flow_split_industry_with_policy(
    text: &str,
    config: &WrapConfig,
    max_top_lines: usize,
    min_top_lines: usize,
    min_bottom_lines: usize,
    policy: FlowSplitPolicy,
) -> Option<FlowSplitPlan> {
    choose_flow_split_industry_physical(
        text,
        config,
        max_top_lines,
        min_top_lines,
        min_bottom_lines,
        policy,
        true,
    )
}

pub fn choose_flow_split_industry_without_sentence_correction(
    text: &str,
    config: &WrapConfig,
    max_top_lines: usize,
    min_top_lines: usize,
    min_bottom_lines: usize,
) -> Option<FlowSplitPlan> {
    choose_flow_split_industry_physical(
        text,
        config,
        max_top_lines,
        min_top_lines,
        min_bottom_lines,
        FlowSplitPolicy::default(),
        false,
    )
}

#[allow(clippy::too_many_arguments)]
fn choose_flow_split_industry_physical(
    text: &str,
    config: &WrapConfig,
    max_top_lines: usize,
    min_top_lines: usize,
    min_bottom_lines: usize,
    policy: FlowSplitPolicy,
    correct_to_sentence: bool,
) -> Option<FlowSplitPlan> {
    let physical_lines = wrap_text_for_element_with_offsets(text, config);
    if max_top_lines == 0 || max_top_lines >= physical_lines.len() {
        return None;
    }
    let physical_offset = physical_lines[max_top_lines].start_offset;

    let candidate_offsets = if correct_to_sentence {
        industry_sentence_boundary_offsets(text)
            .into_iter()
            .filter(|offset| *offset <= physical_offset)
            .rev()
            .collect::<Vec<_>>()
    } else {
        vec![physical_offset]
    };

    candidate_offsets.into_iter().find_map(|offset| {
        build_flow_split_plan(
            text,
            config,
            offset,
            max_top_lines,
            min_top_lines,
            min_bottom_lines,
            policy,
            correct_to_sentence,
        )
    })
}

#[allow(clippy::too_many_arguments)]
fn build_flow_split_plan(
    text: &str,
    config: &WrapConfig,
    offset: usize,
    max_top_lines: usize,
    min_top_lines: usize,
    min_bottom_lines: usize,
    policy: FlowSplitPolicy,
    ends_sentence: bool,
) -> Option<FlowSplitPlan> {
    if offset == 0 || offset >= text.len() {
        return None;
    }
    let (top_text, bottom_text) = text.split_at(offset);
    let top_lines = wrap_fragment_lines(top_text, config);
    let bottom_lines = wrap_fragment_lines(bottom_text, config);
    let top_line_count = top_lines.len();
    let bottom_line_count = bottom_lines.len();
    if top_line_count < min_top_lines
        || bottom_line_count < min_bottom_lines
        || top_line_count > max_top_lines
    {
        return None;
    }
    if has_discouraged_runt_top_line(&top_lines, top_text)
        && !(policy.allow_exact_fit_sentence_runt
            && ends_sentence
            && top_line_count == max_top_lines)
    {
        return None;
    }

    Some(FlowSplitPlan {
        top_text: top_text.to_string(),
        bottom_text: bottom_text.to_string(),
        top_end_offset: offset,
        bottom_start_offset: offset,
        top_line_count,
        bottom_line_count,
    })
}

fn choose_flow_split_with_policy(
    text: &str,
    config: &WrapConfig,
    max_top_lines: usize,
    min_top_lines: usize,
    min_bottom_lines: usize,
    policy: FlowSplitPolicy,
) -> Option<FlowSplitPlan> {
    choose_flow_split_from_offsets(
        text,
        config,
        max_top_lines,
        min_top_lines,
        min_bottom_lines,
        policy,
        sentence_boundary_offsets(text),
        false,
    )
}

#[allow(clippy::too_many_arguments)]
fn choose_flow_split_from_offsets(
    text: &str,
    config: &WrapConfig,
    max_top_lines: usize,
    min_top_lines: usize,
    min_bottom_lines: usize,
    policy: FlowSplitPolicy,
    candidate_offsets: Vec<usize>,
    offsets_are_recognized_sentences: bool,
) -> Option<FlowSplitPlan> {
    choose_best_scored_split(candidate_offsets, |offset| {
        if offset == 0 || offset >= text.len() {
            return None;
        }

        let (top_text, bottom_text) = text.split_at(offset);
        let top_lines = wrap_fragment_lines(top_text, config);
        let bottom_lines = wrap_fragment_lines(bottom_text, config);
        let top_line_count = top_lines.len();
        let bottom_line_count = bottom_lines.len();
        let ends_sentence = policy.prefer_sentence_boundaries
            && (offsets_are_recognized_sentences || text_ends_sentence(top_text));

        if top_line_count < min_top_lines || bottom_line_count < min_bottom_lines {
            return None;
        }
        if top_line_count > max_top_lines {
            return None;
        }
        if has_discouraged_runt_top_line(&top_lines, top_text)
            && !(policy.allow_exact_fit_sentence_runt
                && ends_sentence
                && top_line_count == max_top_lines)
        {
            return None;
        }

        Some(FlowSplitScore {
            ends_sentence,
            fuller_top_fragment: if policy.prefer_fuller_top_fragment {
                top_line_count
            } else {
                0
            },
            balance_score: balance_score(top_line_count, bottom_line_count),
        })
    })
    .map(|offset| {
        let (top_text, bottom_text) = text.split_at(offset);
        let top_line_count = wrap_fragment_lines(top_text, config).len();
        let bottom_line_count = wrap_fragment_lines(bottom_text, config).len();

        FlowSplitPlan {
            top_text: top_text.to_string(),
            bottom_text: bottom_text.to_string(),
            top_end_offset: offset,
            bottom_start_offset: offset,
            top_line_count,
            bottom_line_count,
        }
    })
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct FlowSplitScore {
    ends_sentence: bool,
    fuller_top_fragment: usize,
    balance_score: usize,
}

fn wrap_fragment_lines(text: &str, config: &WrapConfig) -> Vec<String> {
    if text.is_empty() {
        Vec::new()
    } else {
        wrap_text_for_element(text, config)
    }
}

fn balance_score(top_lines: usize, bottom_lines: usize) -> usize {
    usize::MAX - top_lines.abs_diff(bottom_lines)
}

fn has_discouraged_runt_top_line(top_lines: &[String], _top_text: &str) -> bool {
    top_lines
        .last()
        .is_some_and(|line| visible_line_length(line) <= 12)
}

fn visible_line_length(line: &str) -> usize {
    line.trim().chars().count()
}