use std::cmp::Ordering;
use std::collections::BTreeMap;
use crate::ElementLayoutOverrides;
use crate::pagination::margin::line_height_for_element_type;
use crate::pagination::sentence_boundary::sentence_boundary_offsets;
use crate::pagination::split_scoring::choose_best_scored_split;
use crate::pagination::wrapping::{
ElementType, InterruptionDashWrap, WrapConfig, wrap_config_with_overrides,
wrap_text_for_element,
};
use crate::pagination::{DialoguePartKind, DialogueUnit, LayoutGeometry};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DialoguePartSplitLines {
pub top_text: String,
pub bottom_text: String,
pub top_end_offset: usize,
pub bottom_start_offset: usize,
pub top_lines: Vec<String>,
pub bottom_lines: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DialogueSplitPlan {
pub top_line_count: usize,
pub bottom_line_count: usize,
pub top_height: f32,
pub bottom_height: f32,
pub ends_sentence: bool,
pub parts: Vec<DialoguePartSplitLines>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DialogueTextPart {
pub kind: DialoguePartKind,
pub text: String,
pub layout_overrides: ElementLayoutOverrides,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct DialogueSplitPolicy {
prefer_sentence_boundaries: bool,
prefer_fuller_top_fragment: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct DialogueSplitBoundary {
part_index: usize,
offset: usize,
ends_sentence: bool,
}
#[derive(Debug, Clone, PartialEq)]
struct DialogueSplitCandidate {
plan: DialogueSplitPlan,
top_dialogue_lines: usize,
bottom_dialogue_lines: usize,
top_spoken_lines: usize,
bottom_spoken_lines: usize,
bottom_first_spoken_line_chars: usize,
bottom_terminal_spoken_line_chars: usize,
mid_part_sentence_boundary_eligible: bool,
same_top_line_extension: bool,
boundary_part_index: usize,
boundary_offset: usize,
ends_sentence: bool,
top_content_bytes: usize,
}
impl Default for DialogueSplitPolicy {
fn default() -> Self {
Self {
prefer_sentence_boundaries: true,
prefer_fuller_top_fragment: true,
}
}
}
pub fn plan_dialogue_split(
dialogue: &DialogueUnit,
geometry: &LayoutGeometry,
interruption_dash_wrap: InterruptionDashWrap,
max_top_height: f32,
min_top_content_lines: usize,
min_bottom_content_lines: usize,
) -> Option<DialogueSplitPlan> {
let parts = dialogue
.parts
.iter()
.map(|part| DialogueTextPart {
kind: part.kind.clone(),
text: part.text.clone(),
layout_overrides: part.render_attributes.layout_overrides.clone(),
})
.collect::<Vec<_>>();
plan_dialogue_split_parts(
dialogue,
&parts,
geometry,
interruption_dash_wrap,
max_top_height,
min_top_content_lines,
min_bottom_content_lines,
)
}
pub fn plan_dialogue_split_parts(
_dialogue: &DialogueUnit,
parts: &[DialogueTextPart],
geometry: &LayoutGeometry,
interruption_dash_wrap: InterruptionDashWrap,
max_top_height: f32,
min_top_content_lines: usize,
min_bottom_content_lines: usize,
) -> Option<DialogueSplitPlan> {
let policy = DialogueSplitPolicy::default();
let candidates = generate_dialogue_split_candidates(parts, geometry, interruption_dash_wrap);
let winner = choose_best_scored_split(0..candidates.len(), |candidate_index| {
let candidate = &candidates[candidate_index];
if candidate.plan.top_height > max_top_height {
return None;
}
if candidate.top_dialogue_lines < min_top_content_lines
|| candidate.bottom_dialogue_lines < min_bottom_content_lines
{
return None;
}
Some(SplitScore {
ends_sentence: policy.prefer_sentence_boundaries && candidate.ends_sentence,
substantial_bottom: substantial_bottom(
candidate.bottom_spoken_lines,
candidate.bottom_first_spoken_line_chars,
candidate.bottom_terminal_spoken_line_chars,
candidate.mid_part_sentence_boundary_eligible,
candidate.same_top_line_extension,
),
fuller_top_fragment: if policy.prefer_fuller_top_fragment {
candidate.plan.top_line_count
} else {
0
},
balance_score: balance_score(
candidate.top_dialogue_lines,
candidate.bottom_dialogue_lines,
),
top_content_bytes: candidate.top_content_bytes,
})
});
winner.map(|candidate_index| candidates[candidate_index].plan.clone())
}
fn generate_dialogue_split_candidates(
parts: &[DialogueTextPart],
geometry: &LayoutGeometry,
interruption_dash_wrap: InterruptionDashWrap,
) -> Vec<DialogueSplitCandidate> {
let mut boundaries: BTreeMap<(usize, usize), bool> = BTreeMap::new();
for (part_index, part) in parts.iter().enumerate() {
boundaries
.entry((part_index, part.text.len()))
.or_insert(false);
if !matches!(
part.kind,
DialoguePartKind::Dialogue | DialoguePartKind::Lyric
) {
continue;
}
let config = wrap_config_with_overrides(
geometry,
element_type_for_part_kind(part.kind.clone()),
&part.layout_overrides,
interruption_dash_wrap,
);
let total_wrapped_lines = wrap_text_for_element(&part.text, &config).len();
if total_wrapped_lines < 3 {
continue;
}
for offset in sentence_boundary_offsets(&part.text) {
boundaries
.entry((part_index, offset))
.and_modify(|ends_sentence| *ends_sentence = true)
.or_insert(true);
}
}
let mut candidates = boundaries
.into_iter()
.filter_map(|((part_index, offset), ends_sentence)| {
build_candidate(
parts,
geometry,
interruption_dash_wrap,
DialogueSplitBoundary {
part_index,
offset,
ends_sentence,
},
)
})
.collect::<Vec<_>>();
for i in 0..candidates.len() {
let candidate = &candidates[i];
let same_top_line_extension = candidate.mid_part_sentence_boundary_eligible
&& candidates.iter().any(|earlier| {
earlier.ends_sentence
&& earlier.boundary_part_index == candidate.boundary_part_index
&& earlier.boundary_offset < candidate.boundary_offset
&& earlier.plan.top_line_count == candidate.plan.top_line_count
});
candidates[i].same_top_line_extension = same_top_line_extension;
}
candidates
}
fn build_candidate(
parts: &[DialogueTextPart],
geometry: &LayoutGeometry,
interruption_dash_wrap: InterruptionDashWrap,
boundary: DialogueSplitBoundary,
) -> Option<DialogueSplitCandidate> {
let mut top_line_count = 0;
let mut bottom_line_count = 0;
let mut top_height = 0.0;
let mut bottom_height = 0.0;
let mut top_dialogue_lines = 0;
let mut bottom_dialogue_lines = 0;
let mut top_spoken_lines = 0;
let mut bottom_spoken_lines = 0;
let mut bottom_first_spoken_line_chars = 0;
let mut bottom_terminal_spoken_line_chars = 0;
let mut mid_part_sentence_boundary_eligible = false;
let mut split_parts = Vec::with_capacity(parts.len());
for (part_index, part) in parts.iter().enumerate() {
let (top_text, bottom_text) = split_part_text(&part.text, part_index, boundary);
let config = wrap_config_with_overrides(
geometry,
element_type_for_part_kind(part.kind.clone()),
&part.layout_overrides,
interruption_dash_wrap,
);
let top_lines = wrap_fragment_lines(top_text, &config);
let bottom_lines = wrap_fragment_lines(bottom_text, &config);
let line_height = line_height_for_part_kind(part.kind.clone(), geometry);
top_line_count += top_lines.len();
bottom_line_count += bottom_lines.len();
top_height += top_lines.len() as f32 * line_height;
bottom_height += bottom_lines.len() as f32 * line_height;
if matches!(
part.kind,
DialoguePartKind::Dialogue | DialoguePartKind::Lyric | DialoguePartKind::Parenthetical
) {
top_dialogue_lines += top_lines.len();
bottom_dialogue_lines += bottom_lines.len();
}
if matches!(
part.kind,
DialoguePartKind::Dialogue | DialoguePartKind::Lyric
) {
top_spoken_lines += top_lines.len();
bottom_spoken_lines += bottom_lines.len();
if let Some(first_line) = bottom_lines.first() {
bottom_first_spoken_line_chars = first_line.trim_end().chars().count();
}
if let Some(last_line) = bottom_lines.last() {
bottom_terminal_spoken_line_chars = last_line.trim_end().chars().count();
}
mid_part_sentence_boundary_eligible = boundary.ends_sentence
&& part_index == boundary.part_index
&& boundary.offset < part.text.len();
}
split_parts.push(DialoguePartSplitLines {
top_text: top_text.to_string(),
bottom_text: bottom_text.to_string(),
top_end_offset: top_text.len(),
bottom_start_offset: part.text.len() - bottom_text.len(),
top_lines,
bottom_lines,
});
}
if top_line_count == 0 || bottom_line_count == 0 {
return None;
}
let top_content_bytes: usize = split_parts.iter().map(|p| p.top_text.len()).sum();
Some(DialogueSplitCandidate {
plan: DialogueSplitPlan {
top_line_count,
bottom_line_count,
top_height,
bottom_height,
ends_sentence: boundary.ends_sentence,
parts: split_parts,
},
top_dialogue_lines,
bottom_dialogue_lines,
top_spoken_lines,
bottom_spoken_lines,
bottom_first_spoken_line_chars,
bottom_terminal_spoken_line_chars,
mid_part_sentence_boundary_eligible,
same_top_line_extension: false,
boundary_part_index: boundary.part_index,
boundary_offset: boundary.offset,
ends_sentence: boundary.ends_sentence,
top_content_bytes,
})
}
fn split_part_text(text: &str, part_index: usize, boundary: DialogueSplitBoundary) -> (&str, &str) {
if part_index < boundary.part_index {
return (text, "");
}
if part_index > boundary.part_index {
return ("", text);
}
text.split_at(boundary.offset)
}
fn wrap_fragment_lines(text: &str, config: &WrapConfig) -> Vec<String> {
if text.is_empty() {
Vec::new()
} else {
wrap_text_for_element(text, config)
}
}
fn element_type_for_part_kind(kind: DialoguePartKind) -> ElementType {
match kind {
DialoguePartKind::Character => ElementType::Character,
DialoguePartKind::Parenthetical => ElementType::Parenthetical,
DialoguePartKind::Dialogue => ElementType::Dialogue,
DialoguePartKind::Lyric => ElementType::Lyric,
}
}
fn line_height_for_part_kind(kind: DialoguePartKind, geometry: &LayoutGeometry) -> f32 {
line_height_for_element_type(geometry, element_type_for_part_kind(kind))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SplitScore {
ends_sentence: bool,
fuller_top_fragment: usize,
substantial_bottom: bool,
balance_score: usize,
top_content_bytes: usize,
}
impl SplitScore {
fn priority_tuple(&self) -> (bool, bool, usize, usize, usize) {
(
self.ends_sentence,
self.substantial_bottom,
self.fuller_top_fragment,
self.balance_score,
self.top_content_bytes,
)
}
}
impl PartialOrd for SplitScore {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for SplitScore {
fn cmp(&self, other: &Self) -> Ordering {
self.priority_tuple().cmp(&other.priority_tuple())
}
}
fn balance_score(top_dialogue_lines: usize, bottom_dialogue_lines: usize) -> usize {
usize::MAX - top_dialogue_lines.abs_diff(bottom_dialogue_lines)
}
fn substantial_bottom(
bottom_dialogue_lines: usize,
bottom_first_spoken_line_chars: usize,
bottom_terminal_spoken_line_chars: usize,
mid_part_sentence_boundary_eligible: bool,
same_top_line_extension: bool,
) -> bool {
bottom_dialogue_lines >= 3
|| (mid_part_sentence_boundary_eligible && same_top_line_extension)
|| (mid_part_sentence_boundary_eligible
&& bottom_dialogue_lines >= 1
&& bottom_first_spoken_line_chars >= 16
&& bottom_terminal_spoken_line_chars >= 16)
}