use super::{ParagraphStyle, TextStyle};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct TextRun {
pub text: String,
pub style: TextStyle,
}
impl TextRun {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
style: TextStyle::default(),
}
}
pub fn with_style(text: impl Into<String>, style: TextStyle) -> Self {
Self {
text: text.into(),
style,
}
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum InlineContent {
Text(TextRun),
LineBreak,
Image(ImageRef),
Equation(Equation),
Footnote(String),
Link { text: String, url: String },
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct Paragraph {
pub style: ParagraphStyle,
pub content: Vec<InlineContent>,
}
impl Paragraph {
pub fn new() -> Self {
Self::default()
}
pub fn with_style(style: ParagraphStyle) -> Self {
Self {
style,
content: Vec::new(),
}
}
pub fn text(text: impl Into<String>) -> Self {
Self {
style: ParagraphStyle::default(),
content: vec![InlineContent::Text(TextRun::new(text))],
}
}
pub fn push_text(&mut self, run: TextRun) {
self.content.push(InlineContent::Text(run));
}
pub fn push_line_break(&mut self) {
self.content.push(InlineContent::LineBreak);
}
pub fn plain_text(&self) -> String {
let mut result = String::new();
for item in &self.content {
match item {
InlineContent::Text(run) => result.push_str(&run.text),
InlineContent::LineBreak => result.push('\n'),
InlineContent::Link { text, .. } => result.push_str(text),
_ => {}
}
}
result
}
pub fn is_empty(&self) -> bool {
self.content.is_empty()
|| self.content.iter().all(|c| match c {
InlineContent::Text(run) => run.is_empty(),
_ => false,
})
}
pub fn has_text_content(&self) -> bool {
self.content.iter().any(|c| match c {
InlineContent::Text(run) => !run.text.trim().is_empty(),
InlineContent::Link { text, .. } => !text.trim().is_empty(),
InlineContent::Footnote(text) => !text.trim().is_empty(),
_ => false,
})
}
pub fn is_image_only(&self) -> bool {
if self.content.is_empty() {
return false;
}
let has_images = self
.content
.iter()
.any(|c| matches!(c, InlineContent::Image(_)));
let has_non_empty_text = self.content.iter().any(|c| match c {
InlineContent::Text(run) => !run.text.trim().is_empty(),
InlineContent::Link { text, .. } => !text.trim().is_empty(),
InlineContent::Footnote(text) => !text.trim().is_empty(),
InlineContent::Equation(_) => true,
_ => false,
});
has_images && !has_non_empty_text
}
pub fn dominant_font_size(&self) -> Option<f32> {
use std::collections::HashMap;
let mut size_weights: HashMap<u32, usize> = HashMap::new();
for item in &self.content {
if let InlineContent::Text(run) = item {
if let Some(size) = run.style.font_size {
let key = (size * 10.0) as u32;
let text_len = run.text.chars().count();
*size_weights.entry(key).or_insert(0) += text_len;
}
}
}
size_weights
.into_iter()
.max_by_key(|(_, weight)| *weight)
.map(|(key, _)| key as f32 / 10.0)
}
pub fn is_all_bold(&self) -> bool {
let text_runs: Vec<_> = self
.content
.iter()
.filter_map(|c| {
if let InlineContent::Text(run) = c {
if !run.text.trim().is_empty() {
return Some(run);
}
}
None
})
.collect();
!text_runs.is_empty() && text_runs.iter().all(|r| r.style.bold)
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ImageRef {
pub id: String,
pub alt_text: Option<String>,
pub width: Option<u32>,
pub height: Option<u32>,
}
impl ImageRef {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
alt_text: None,
width: None,
height: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Equation {
pub script: String,
pub latex: Option<String>,
}
impl Equation {
pub fn new(script: impl Into<String>) -> Self {
Self {
script: script.into(),
latex: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dominant_font_size() {
let mut para = Paragraph::new();
para.content.push(InlineContent::Text(TextRun::with_style(
"Short",
TextStyle {
font_size: Some(14.0),
..Default::default()
},
)));
para.content.push(InlineContent::Text(TextRun::with_style(
"This is a much longer text that should dominate",
TextStyle {
font_size: Some(12.0),
..Default::default()
},
)));
assert_eq!(para.dominant_font_size(), Some(12.0));
}
#[test]
fn test_dominant_font_size_empty() {
let para = Paragraph::new();
assert_eq!(para.dominant_font_size(), None);
}
#[test]
fn test_dominant_font_size_no_size_info() {
let mut para = Paragraph::new();
para.content
.push(InlineContent::Text(TextRun::new("No size info")));
assert_eq!(para.dominant_font_size(), None);
}
#[test]
fn test_is_all_bold() {
let mut para = Paragraph::new();
para.content.push(InlineContent::Text(TextRun::with_style(
"Bold text",
TextStyle {
bold: true,
..Default::default()
},
)));
para.content.push(InlineContent::Text(TextRun::with_style(
"Also bold",
TextStyle {
bold: true,
..Default::default()
},
)));
assert!(para.is_all_bold());
}
#[test]
fn test_is_all_bold_mixed() {
let mut para = Paragraph::new();
para.content.push(InlineContent::Text(TextRun::with_style(
"Bold text",
TextStyle {
bold: true,
..Default::default()
},
)));
para.content
.push(InlineContent::Text(TextRun::new("Not bold")));
assert!(!para.is_all_bold());
}
#[test]
fn test_is_all_bold_empty() {
let para = Paragraph::new();
assert!(!para.is_all_bold());
}
}