use std::collections::HashMap;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::sync::LazyLock;
use crate::ffi::stext::Rect;
use crate::ffi::{Handle, HandleStore};
pub static STORIES: LazyLock<HandleStore<Story>> = LazyLock::new(HandleStore::new);
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlaceStoryReturn {
AllFitted = 0,
MoreToFit = 1,
OverflowWidth = 2,
}
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlaceStoryFlag {
None = 0,
NoOverflow = 1,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoryState {
Created,
Placing,
Placed,
Drawn,
Complete,
}
#[derive(Debug, Clone, Default)]
pub struct CssStyle {
pub font_family: Option<String>,
pub font_size: Option<f32>,
pub font_weight: Option<String>,
pub font_style: Option<String>,
pub color: Option<String>,
pub background_color: Option<String>,
pub margin: Option<f32>,
pub padding: Option<f32>,
pub text_align: Option<String>,
pub line_height: Option<f32>,
}
#[derive(Debug, Clone)]
pub struct StoryElement {
pub tag: String,
pub id: Option<String>,
pub href: Option<String>,
pub class: Option<String>,
pub text: Option<String>,
pub style: CssStyle,
pub children: Vec<StoryElement>,
pub rect: Rect,
pub depth: i32,
pub heading_level: i32, }
impl StoryElement {
pub fn new(tag: &str) -> Self {
Self {
tag: tag.to_string(),
id: None,
href: None,
class: None,
text: None,
style: CssStyle::default(),
children: Vec::new(),
rect: Rect::default(),
depth: 0,
heading_level: 0,
}
}
pub fn is_heading(&self) -> bool {
matches!(
self.tag.to_lowercase().as_str(),
"h1" | "h2" | "h3" | "h4" | "h5" | "h6"
)
}
pub fn get_heading_level(&self) -> i32 {
match self.tag.to_lowercase().as_str() {
"h1" => 1,
"h2" => 2,
"h3" => 3,
"h4" => 4,
"h5" => 5,
"h6" => 6,
_ => 0,
}
}
}
#[repr(C)]
#[derive(Debug, Clone)]
pub struct StoryElementPosition {
pub depth: i32,
pub heading: i32,
pub id: *const c_char,
pub href: *const c_char,
pub rect: Rect,
pub text: *const c_char,
pub open_close: i32,
pub rectangle_num: i32,
}
impl Default for StoryElementPosition {
fn default() -> Self {
Self {
depth: 0,
heading: 0,
id: std::ptr::null(),
href: std::ptr::null(),
rect: Rect::default(),
text: std::ptr::null(),
open_close: 0,
rectangle_num: 0,
}
}
}
#[derive(Debug, Clone)]
pub struct PlacedRegion {
pub rect: Rect,
pub elements: Vec<usize>, pub filled: Rect,
}
pub struct Story {
pub html: String,
pub user_css: String,
pub em: f32,
pub archive: Option<Handle>,
pub document: Option<StoryElement>,
pub state: StoryState,
pub warnings: Vec<String>,
pub layout_position: usize,
pub placed_regions: Vec<PlacedRegion>,
pub rectangle_num: i32,
cached_strings: HashMap<String, CString>,
}
impl Story {
pub fn new(html: &str, user_css: &str, em: f32, archive: Option<Handle>) -> Self {
let mut story = Self {
html: html.to_string(),
user_css: user_css.to_string(),
em,
archive,
document: None,
state: StoryState::Created,
warnings: Vec::new(),
layout_position: 0,
placed_regions: Vec::new(),
rectangle_num: 0,
cached_strings: HashMap::new(),
};
story.parse_html();
story
}
fn parse_html(&mut self) {
let mut root = StoryElement::new("body");
let html = self.html.trim();
if html.is_empty() {
self.document = Some(root);
return;
}
let mut current_depth = 0;
let mut elements: Vec<StoryElement> = Vec::new();
let mut pos = 0;
let bytes = html.as_bytes();
while pos < bytes.len() {
if bytes[pos] == b'<' {
let tag_start = pos + 1;
let mut tag_end = tag_start;
while tag_end < bytes.len() && bytes[tag_end] != b'>' {
tag_end += 1;
}
if tag_end < bytes.len() {
let tag_content = &html[tag_start..tag_end];
if tag_content.starts_with('/') {
current_depth -= 1;
} else if !tag_content.ends_with('/') && !tag_content.starts_with('!') {
let tag_name = tag_content
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
let mut elem = StoryElement::new(&tag_name);
elem.depth = current_depth;
elem.heading_level = elem.get_heading_level();
if let Some(id) = Self::extract_attr(tag_content, "id") {
elem.id = Some(id);
}
if let Some(href) = Self::extract_attr(tag_content, "href") {
elem.href = Some(href);
}
if let Some(class) = Self::extract_attr(tag_content, "class") {
elem.class = Some(class);
}
elements.push(elem);
current_depth += 1;
}
pos = tag_end + 1;
} else {
pos += 1;
}
} else {
let text_start = pos;
while pos < bytes.len() && bytes[pos] != b'<' {
pos += 1;
}
let text = html[text_start..pos].trim();
if !text.is_empty() && !elements.is_empty() {
if let Some(last) = elements.last_mut() {
last.text = Some(text.to_string());
}
}
}
}
root.children = elements;
self.document = Some(root);
}
fn extract_attr(tag_content: &str, attr_name: &str) -> Option<String> {
let pattern = format!("{}=\"", attr_name);
if let Some(start) = tag_content.find(&pattern) {
let value_start = start + pattern.len();
if let Some(end) = tag_content[value_start..].find('"') {
return Some(tag_content[value_start..value_start + end].to_string());
}
}
let pattern = format!("{}='", attr_name);
if let Some(start) = tag_content.find(&pattern) {
let value_start = start + pattern.len();
if let Some(end) = tag_content[value_start..].find('\'') {
return Some(tag_content[value_start..value_start + end].to_string());
}
}
None
}
pub fn place(&mut self, where_rect: Rect, flags: i32) -> (PlaceStoryReturn, Rect) {
if self.state == StoryState::Complete {
return (PlaceStoryReturn::AllFitted, Rect::default());
}
self.state = StoryState::Placing;
self.rectangle_num += 1;
let mut filled = Rect {
x0: where_rect.x0,
y0: where_rect.y0,
x1: where_rect.x0,
y1: where_rect.y0,
};
let elements = if let Some(ref doc) = self.document {
&doc.children
} else {
return (PlaceStoryReturn::AllFitted, filled);
};
if self.layout_position >= elements.len() {
self.state = StoryState::Complete;
return (PlaceStoryReturn::AllFitted, filled);
}
let mut y = where_rect.y0;
let line_height = self.em * 1.2;
let margin = self.em * 0.5;
let mut placed_count = 0;
let check_overflow = flags & (PlaceStoryFlag::NoOverflow as i32) != 0;
while self.layout_position < elements.len() {
let elem = &elements[self.layout_position];
let elem_height = if elem.is_heading() {
line_height * (1.5 + (6 - elem.heading_level) as f32 * 0.2)
} else {
line_height
};
if y + elem_height + margin > where_rect.y1 {
break;
}
if check_overflow {
if let Some(ref text) = elem.text {
let estimated_width = text.len() as f32 * self.em * 0.5;
if estimated_width > (where_rect.x1 - where_rect.x0) {
self.state = StoryState::Placed;
return (PlaceStoryReturn::OverflowWidth, filled);
}
}
}
y += elem_height + margin;
placed_count += 1;
self.layout_position += 1;
filled.x1 = where_rect.x1;
filled.y1 = y;
}
let region = PlacedRegion {
rect: where_rect,
elements: (self.layout_position - placed_count..self.layout_position).collect(),
filled,
};
self.placed_regions.push(region);
self.state = StoryState::Placed;
if self.layout_position >= elements.len() {
self.state = StoryState::Complete;
(PlaceStoryReturn::AllFitted, filled)
} else {
(PlaceStoryReturn::MoreToFit, filled)
}
}
pub fn draw(&mut self, _device: Handle, _ctm: [f32; 6]) {
if self.state == StoryState::Placed {
self.state = StoryState::Drawn;
}
}
pub fn reset(&mut self) {
self.layout_position = 0;
self.placed_regions.clear();
self.rectangle_num = 0;
self.state = StoryState::Created;
}
pub fn get_warnings(&mut self) -> Option<String> {
self.state = StoryState::Placing;
if self.warnings.is_empty() {
None
} else {
Some(self.warnings.join("\n"))
}
}
fn get_cached_cstring(&mut self, s: &str) -> *const c_char {
if !self.cached_strings.contains_key(s) {
if let Ok(cstr) = CString::new(s) {
self.cached_strings.insert(s.to_string(), cstr);
} else {
return std::ptr::null();
}
}
self.cached_strings
.get(s)
.map(|cs| cs.as_ptr())
.unwrap_or(std::ptr::null())
}
pub fn enumerate_positions<F>(&mut self, mut callback: F)
where
F: FnMut(&StoryElementPosition),
{
if let Some(doc) = self.document.clone() {
self.enumerate_element_positions(&doc.children, &mut callback, 0);
}
}
fn enumerate_element_positions<F>(
&mut self,
elements: &[StoryElement],
callback: &mut F,
depth: i32,
) where
F: FnMut(&StoryElementPosition),
{
let elements_clone: Vec<StoryElement> = elements.to_vec();
for elem in &elements_clone {
if elem.is_heading() || elem.id.is_some() {
let mut pos = StoryElementPosition::default();
pos.depth = depth;
pos.heading = elem.heading_level;
pos.rect = elem.rect;
pos.open_close = 0b11; pos.rectangle_num = self.rectangle_num;
if let Some(ref id) = elem.id {
pos.id = self.get_cached_cstring(id);
}
if let Some(ref href) = elem.href {
pos.href = self.get_cached_cstring(href);
}
if let Some(ref text) = elem.text {
pos.text = self.get_cached_cstring(text);
}
callback(&pos);
}
if !elem.children.is_empty() {
self.enumerate_element_positions(&elem.children, callback, depth + 1);
}
}
}
}
#[unsafe(no_mangle)]
pub extern "C" fn fz_new_story(
_ctx: Handle,
buf: Handle,
user_css: *const c_char,
em: f32,
archive: Handle,
) -> Handle {
let html = if buf == 0 {
String::new()
} else if let Some(buf_arc) = crate::ffi::BUFFERS.get(buf) {
let buf_guard = buf_arc.lock().unwrap();
String::from_utf8_lossy(buf_guard.data()).to_string()
} else {
String::new()
};
let css = if user_css.is_null() {
String::new()
} else {
unsafe { CStr::from_ptr(user_css).to_str().unwrap_or("").to_string() }
};
let arch = if archive == 0 { None } else { Some(archive) };
let story = Story::new(&html, &css, em, arch);
STORIES.insert(story)
}
#[unsafe(no_mangle)]
pub extern "C" fn fz_story_warnings(_ctx: Handle, story: Handle) -> *const c_char {
if let Some(story_arc) = STORIES.get(story) {
let mut story_guard = story_arc.lock().unwrap();
if let Some(warnings) = story_guard.get_warnings() {
let ptr = story_guard.get_cached_cstring(&warnings);
return ptr;
}
}
std::ptr::null()
}
#[unsafe(no_mangle)]
pub extern "C" fn fz_place_story(
_ctx: Handle,
story: Handle,
where_x0: f32,
where_y0: f32,
where_x1: f32,
where_y1: f32,
filled: *mut Rect,
) -> i32 {
fz_place_story_flags(
_ctx, story, where_x0, where_y0, where_x1, where_y1, filled, 0,
)
}
#[unsafe(no_mangle)]
pub extern "C" fn fz_place_story_flags(
_ctx: Handle,
story: Handle,
where_x0: f32,
where_y0: f32,
where_x1: f32,
where_y1: f32,
filled: *mut Rect,
flags: i32,
) -> i32 {
let where_rect = Rect {
x0: where_x0,
y0: where_y0,
x1: where_x1,
y1: where_y1,
};
if let Some(story_arc) = STORIES.get(story) {
let mut story_guard = story_arc.lock().unwrap();
let (result, filled_rect) = story_guard.place(where_rect, flags);
if !filled.is_null() {
unsafe {
*filled = filled_rect;
}
}
result as i32
} else {
PlaceStoryReturn::AllFitted as i32
}
}
#[unsafe(no_mangle)]
pub extern "C" fn fz_draw_story(
_ctx: Handle,
story: Handle,
dev: Handle,
ctm_a: f32,
ctm_b: f32,
ctm_c: f32,
ctm_d: f32,
ctm_e: f32,
ctm_f: f32,
) {
let ctm = [ctm_a, ctm_b, ctm_c, ctm_d, ctm_e, ctm_f];
if let Some(story_arc) = STORIES.get(story) {
let mut story_guard = story_arc.lock().unwrap();
story_guard.draw(dev, ctm);
}
}
#[unsafe(no_mangle)]
pub extern "C" fn fz_reset_story(_ctx: Handle, story: Handle) {
if let Some(story_arc) = STORIES.get(story) {
let mut story_guard = story_arc.lock().unwrap();
story_guard.reset();
}
}
#[unsafe(no_mangle)]
pub extern "C" fn fz_drop_story(_ctx: Handle, story: Handle) {
STORIES.remove(story);
}
#[unsafe(no_mangle)]
pub extern "C" fn fz_story_document(_ctx: Handle, story: Handle) -> Handle {
if let Some(story_arc) = STORIES.get(story) {
let story_guard = story_arc.lock().unwrap();
if story_guard.state == StoryState::Created {
return story;
}
}
0
}
pub type StoryPositionCallback =
extern "C" fn(ctx: Handle, arg: *mut std::ffi::c_void, pos: *const StoryElementPosition);
#[unsafe(no_mangle)]
pub extern "C" fn fz_story_positions(
ctx: Handle,
story: Handle,
callback: StoryPositionCallback,
arg: *mut std::ffi::c_void,
) {
if let Some(story_arc) = STORIES.get(story) {
let mut story_guard = story_arc.lock().unwrap();
story_guard.enumerate_positions(|pos| {
callback(ctx, arg, pos);
});
}
}
#[unsafe(no_mangle)]
pub extern "C" fn fz_story_state(story: Handle) -> i32 {
if let Some(story_arc) = STORIES.get(story) {
let story_guard = story_arc.lock().unwrap();
story_guard.state as i32
} else {
-1
}
}
#[unsafe(no_mangle)]
pub extern "C" fn fz_story_is_complete(story: Handle) -> i32 {
if let Some(story_arc) = STORIES.get(story) {
let story_guard = story_arc.lock().unwrap();
if story_guard.state == StoryState::Complete {
1
} else {
0
}
} else {
1
}
}
#[unsafe(no_mangle)]
pub extern "C" fn fz_story_rectangle_num(story: Handle) -> i32 {
if let Some(story_arc) = STORIES.get(story) {
let story_guard = story_arc.lock().unwrap();
story_guard.rectangle_num
} else {
0
}
}
#[unsafe(no_mangle)]
pub extern "C" fn fz_story_placed_regions_count(story: Handle) -> i32 {
if let Some(story_arc) = STORIES.get(story) {
let story_guard = story_arc.lock().unwrap();
story_guard.placed_regions.len() as i32
} else {
0
}
}
#[unsafe(no_mangle)]
pub extern "C" fn fz_story_em(story: Handle) -> f32 {
if let Some(story_arc) = STORIES.get(story) {
let story_guard = story_arc.lock().unwrap();
story_guard.em
} else {
12.0
}
}
#[unsafe(no_mangle)]
pub extern "C" fn fz_story_set_em(story: Handle, em: f32) {
if let Some(story_arc) = STORIES.get(story) {
let mut story_guard = story_arc.lock().unwrap();
story_guard.em = em;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::CString;
fn create_html_buffer(ctx: Handle, html: &[u8]) -> Handle {
crate::ffi::buffer::fz_new_buffer_from_data(ctx, html.as_ptr() as *mut u8, html.len())
}
#[test]
fn test_story_creation() {
let ctx = 1;
let html = b"<h1>Hello World</h1><p>This is a test.</p>";
let buf = create_html_buffer(ctx, html);
let story = fz_new_story(ctx, buf, std::ptr::null(), 12.0, 0);
assert!(story > 0);
fz_drop_story(ctx, story);
crate::ffi::buffer::fz_drop_buffer(ctx, buf);
}
#[test]
fn test_story_with_css() {
let ctx = 1;
let html = b"<h1>Styled</h1>";
let css = CString::new("h1 { color: red; font-size: 24pt; }").unwrap();
let buf = create_html_buffer(ctx, html);
let story = fz_new_story(ctx, buf, css.as_ptr(), 12.0, 0);
assert!(story > 0);
fz_drop_story(ctx, story);
crate::ffi::buffer::fz_drop_buffer(ctx, buf);
}
#[test]
fn test_story_place() {
let ctx = 1;
let html = b"<h1>Title</h1><p>Content</p>";
let buf = create_html_buffer(ctx, html);
let story = fz_new_story(ctx, buf, std::ptr::null(), 12.0, 0);
let mut filled = Rect::default();
let result = fz_place_story(ctx, story, 0.0, 0.0, 612.0, 792.0, &mut filled);
assert_eq!(result, PlaceStoryReturn::AllFitted as i32);
assert!(filled.y1 > filled.y0);
fz_drop_story(ctx, story);
crate::ffi::buffer::fz_drop_buffer(ctx, buf);
}
#[test]
fn test_story_place_partial() {
let ctx = 1;
let html =
b"<h1>Title</h1><p>Line 1</p><p>Line 2</p><p>Line 3</p><p>Line 4</p><p>Line 5</p>";
let buf = create_html_buffer(ctx, html);
let story = fz_new_story(ctx, buf, std::ptr::null(), 12.0, 0);
let mut filled = Rect::default();
let result = fz_place_story(ctx, story, 0.0, 0.0, 100.0, 30.0, &mut filled);
assert!(
result == PlaceStoryReturn::MoreToFit as i32
|| result == PlaceStoryReturn::AllFitted as i32
);
fz_drop_story(ctx, story);
crate::ffi::buffer::fz_drop_buffer(ctx, buf);
}
#[test]
fn test_story_reset() {
let ctx = 1;
let html = b"<p>Test</p>";
let buf = create_html_buffer(ctx, html);
let story = fz_new_story(ctx, buf, std::ptr::null(), 12.0, 0);
let mut filled = Rect::default();
fz_place_story(ctx, story, 0.0, 0.0, 612.0, 792.0, &mut filled);
fz_reset_story(ctx, story);
assert_eq!(fz_story_rectangle_num(story), 0);
fz_drop_story(ctx, story);
crate::ffi::buffer::fz_drop_buffer(ctx, buf);
}
#[test]
fn test_story_document() {
let ctx = 1;
let html = b"<div id='test'>Content</div>";
let buf = create_html_buffer(ctx, html);
let story = fz_new_story(ctx, buf, std::ptr::null(), 12.0, 0);
let doc = fz_story_document(ctx, story);
assert!(doc > 0);
fz_drop_story(ctx, story);
crate::ffi::buffer::fz_drop_buffer(ctx, buf);
}
#[test]
fn test_story_warnings_empty() {
let ctx = 1;
let html = b"<p>Valid HTML</p>";
let buf = create_html_buffer(ctx, html);
let story = fz_new_story(ctx, buf, std::ptr::null(), 12.0, 0);
let warnings = fz_story_warnings(ctx, story);
assert!(warnings.is_null());
fz_drop_story(ctx, story);
crate::ffi::buffer::fz_drop_buffer(ctx, buf);
}
#[test]
fn test_story_is_complete() {
let ctx = 1;
let html = b"<p>Short</p>";
let buf = create_html_buffer(ctx, html);
let story = fz_new_story(ctx, buf, std::ptr::null(), 12.0, 0);
assert_eq!(fz_story_is_complete(story), 0);
let mut filled = Rect::default();
fz_place_story(ctx, story, 0.0, 0.0, 612.0, 792.0, &mut filled);
assert_eq!(fz_story_is_complete(story), 1);
fz_drop_story(ctx, story);
crate::ffi::buffer::fz_drop_buffer(ctx, buf);
}
#[test]
fn test_story_em() {
let ctx = 1;
let html = b"<p>Test</p>";
let buf = create_html_buffer(ctx, html);
let story = fz_new_story(ctx, buf, std::ptr::null(), 14.0, 0);
assert!((fz_story_em(story) - 14.0).abs() < 0.01);
fz_story_set_em(story, 16.0);
assert!((fz_story_em(story) - 16.0).abs() < 0.01);
fz_drop_story(ctx, story);
crate::ffi::buffer::fz_drop_buffer(ctx, buf);
}
#[test]
fn test_empty_story() {
let ctx = 1;
let story = fz_new_story(ctx, 0, std::ptr::null(), 12.0, 0);
assert!(story > 0);
let mut filled = Rect::default();
let result = fz_place_story(ctx, story, 0.0, 0.0, 612.0, 792.0, &mut filled);
assert_eq!(result, PlaceStoryReturn::AllFitted as i32);
fz_drop_story(ctx, story);
}
#[test]
fn test_story_with_headings() {
let ctx = 1;
let html = b"<h1>H1</h1><h2>H2</h2><h3>H3</h3>";
let buf = create_html_buffer(ctx, html);
let story = fz_new_story(ctx, buf, std::ptr::null(), 12.0, 0);
let mut filled = Rect::default();
let result = fz_place_story(ctx, story, 0.0, 0.0, 612.0, 792.0, &mut filled);
assert_eq!(result, PlaceStoryReturn::AllFitted as i32);
fz_drop_story(ctx, story);
crate::ffi::buffer::fz_drop_buffer(ctx, buf);
}
#[test]
fn test_story_positions_callback() {
static mut POSITION_COUNT: i32 = 0;
extern "C" fn callback(
_ctx: Handle,
_arg: *mut std::ffi::c_void,
_pos: *const StoryElementPosition,
) {
unsafe {
POSITION_COUNT += 1;
}
}
let ctx = 1;
let html = b"<h1 id='chapter1'>Chapter 1</h1><h2>Section</h2>";
let buf = create_html_buffer(ctx, html);
let story = fz_new_story(ctx, buf, std::ptr::null(), 12.0, 0);
unsafe {
POSITION_COUNT = 0;
}
fz_story_positions(ctx, story, callback, std::ptr::null_mut());
unsafe {
assert!(POSITION_COUNT > 0);
}
fz_drop_story(ctx, story);
crate::ffi::buffer::fz_drop_buffer(ctx, buf);
}
#[test]
fn test_place_story_flags_overflow() {
let ctx = 1;
let html =
b"<p>This is a very long line of text that should cause overflow in a narrow rectangle</p>";
let buf = create_html_buffer(ctx, html);
let story = fz_new_story(ctx, buf, std::ptr::null(), 12.0, 0);
let mut filled = Rect::default();
let result = fz_place_story_flags(
ctx,
story,
0.0,
0.0,
50.0,
792.0,
&mut filled,
PlaceStoryFlag::NoOverflow as i32,
);
assert!(
result == PlaceStoryReturn::OverflowWidth as i32
|| result == PlaceStoryReturn::AllFitted as i32
|| result == PlaceStoryReturn::MoreToFit as i32
);
fz_drop_story(ctx, story);
crate::ffi::buffer::fz_drop_buffer(ctx, buf);
}
}