use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Document {
pub metadata: Metadata,
pub sections: Vec<Section>,
pub assets: Vec<Asset>,
}
impl Document {
#[must_use]
pub fn new() -> Self {
Self {
metadata: Metadata::default(),
sections: Vec::new(),
assets: Vec::new(),
}
}
}
impl Default for Document {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Metadata {
pub title: Option<String>,
pub author: Option<String>,
pub created: Option<String>,
pub modified: Option<String>,
pub description: Option<String>,
pub subject: Option<String>,
pub keywords: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PageLayout {
pub width: Option<u32>,
pub height: Option<u32>,
pub landscape: bool,
pub margin_left: Option<u32>,
pub margin_right: Option<u32>,
pub margin_top: Option<u32>,
pub margin_bottom: Option<u32>,
}
impl PageLayout {
#[must_use]
pub fn a4_portrait() -> Self {
Self {
width: Some(59528),
height: Some(84188),
landscape: false,
margin_left: Some(5670),
margin_right: Some(5670),
margin_top: Some(4252),
margin_bottom: Some(4252),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum HeaderFooterType {
Both,
Even,
Odd,
#[serde(untagged)]
Other(String),
}
impl HeaderFooterType {
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Both => "both",
Self::Even => "even",
Self::Odd => "odd",
Self::Other(s) => s.as_str(),
}
}
}
impl From<&str> for HeaderFooterType {
fn from(s: &str) -> Self {
match s {
"both" => Self::Both,
"even" => Self::Even,
"odd" => Self::Odd,
other => Self::Other(other.to_string()),
}
}
}
impl From<String> for HeaderFooterType {
fn from(s: String) -> Self {
Self::from(s.as_str())
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct BreakSetting {
pub widow_orphan: bool,
pub keep_with_next: bool,
pub keep_lines: bool,
pub page_break_before: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Section {
pub blocks: Vec<Block>,
pub page_layout: Option<PageLayout>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub header: Option<Vec<Block>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub footer: Option<Vec<Block>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub header_footer_type: Option<HeaderFooterType>,
#[serde(default)]
pub break_setting: BreakSetting,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Block {
Heading {
level: u8,
inlines: Vec<Inline>,
},
Paragraph {
inlines: Vec<Inline>,
},
Table {
rows: Vec<TableRow>,
col_count: usize,
inner_margin: Option<TableInnerMargin>,
},
CodeBlock {
language: Option<String>,
code: String,
},
BlockQuote {
blocks: Vec<Block>,
},
List {
ordered: bool,
start: u32,
items: Vec<ListItem>,
},
Image {
src: String,
alt: String,
},
HorizontalRule,
PageBreak,
Footnote {
id: String,
content: Vec<Block>,
},
Math {
display: bool,
tex: String,
},
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Default, Clone, PartialEq)]
pub struct InlineFormat {
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strikethrough: bool,
pub superscript: bool,
pub subscript: bool,
pub color: Option<String>,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Inline {
pub text: String,
pub bold: bool,
pub italic: bool,
pub underline: bool,
pub strikethrough: bool,
pub code: bool,
pub superscript: bool,
pub subscript: bool,
pub link: Option<String>,
pub footnote_ref: Option<String>,
pub color: Option<String>,
pub font_name: Option<String>,
pub ruby: Option<String>,
}
impl Inline {
#[must_use]
pub fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
..Self::default()
}
}
#[must_use]
pub fn bold(text: impl Into<String>) -> Self {
Self {
text: text.into(),
bold: true,
..Self::default()
}
}
#[must_use]
pub fn with_formatting(text: String, fmt: &InlineFormat) -> Self {
Self {
text,
bold: fmt.bold,
italic: fmt.italic,
underline: fmt.underline,
strikethrough: fmt.strikethrough,
superscript: fmt.superscript,
subscript: fmt.subscript,
color: fmt.color.clone(),
code: false,
link: None,
footnote_ref: None,
font_name: None,
ruby: None,
}
}
#[must_use]
pub fn with_link(mut self, link: Option<String>) -> Self {
self.link = link;
self
}
#[must_use]
pub fn with_ruby(mut self, ruby: Option<String>) -> Self {
self.ruby = ruby;
self
}
#[must_use]
pub fn with_font_name(mut self, font_name: Option<String>) -> Self {
self.font_name = font_name;
self
}
#[must_use]
pub fn footnote_ref(id: impl Into<String>) -> Self {
Self {
footnote_ref: Some(id.into()),
..Self::default()
}
}
}
pub(crate) const DEFAULT_TABLE_INNER_MARGIN: u32 = 141;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TableInnerMargin {
pub left: u32,
pub right: u32,
pub top: u32,
pub bottom: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TableRow {
pub cells: Vec<TableCell>,
pub is_header: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TableCell {
pub blocks: Vec<Block>,
pub colspan: u32,
pub rowspan: u32,
}
impl Default for TableCell {
fn default() -> Self {
Self {
blocks: Vec::new(),
colspan: 1,
rowspan: 1,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ListItem {
pub blocks: Vec<Block>,
pub children: Vec<ListItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checked: Option<bool>,
}
impl ListItem {
#[must_use]
pub fn new(blocks: Vec<Block>, children: Vec<ListItem>) -> Self {
Self {
blocks,
children,
checked: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Asset {
pub name: String,
pub data: Vec<u8>,
pub mime_type: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inline_plain_sets_text_and_all_bools_false() {
let i = Inline::plain("hello");
assert_eq!(i.text, "hello");
assert!(!i.bold);
assert!(!i.italic);
assert!(!i.underline);
assert!(!i.strikethrough);
assert!(!i.code);
assert!(!i.superscript);
assert!(!i.subscript);
assert!(i.link.is_none());
assert!(i.footnote_ref.is_none());
assert!(i.color.is_none());
assert!(i.font_name.is_none());
assert!(i.ruby.is_none());
}
#[test]
fn inline_bold_sets_text_and_bold_true_rest_false() {
let i = Inline::bold("strong");
assert_eq!(i.text, "strong");
assert!(i.bold);
assert!(!i.italic);
assert!(!i.underline);
assert!(!i.strikethrough);
assert!(!i.code);
assert!(!i.superscript);
assert!(!i.subscript);
assert!(i.link.is_none());
assert!(i.footnote_ref.is_none());
assert!(i.color.is_none());
assert!(i.font_name.is_none());
assert!(i.ruby.is_none());
}
#[test]
fn inline_default_color_and_font_name_are_none() {
let i = Inline::default();
assert!(i.color.is_none());
assert!(i.font_name.is_none());
assert!(i.ruby.is_none());
}
#[test]
fn inline_ruby_field_roundtrips() {
let i = Inline {
text: "漢字".into(),
ruby: Some("한자".into()),
..Inline::default()
};
assert_eq!(i.text, "漢字");
assert_eq!(i.ruby.as_deref(), Some("한자"));
}
#[test]
fn document_new_has_empty_sections_and_assets() {
let doc = Document::new();
assert!(doc.sections.is_empty());
assert!(doc.assets.is_empty());
}
#[test]
fn document_default_equals_new() {
assert_eq!(Document::new(), Document::default());
}
#[test]
fn list_item_new_has_checked_none() {
let item = ListItem::new(vec![], vec![]);
assert!(item.checked.is_none());
}
#[test]
fn list_item_checked_some_false() {
let item = ListItem {
blocks: vec![],
children: vec![],
checked: Some(false),
};
assert_eq!(item.checked, Some(false));
}
#[test]
fn list_item_checked_some_true() {
let item = ListItem {
blocks: vec![],
children: vec![],
checked: Some(true),
};
assert_eq!(item.checked, Some(true));
}
#[test]
fn list_item_checked_none_by_default_via_new() {
let item = ListItem::new(vec![Block::Paragraph { inlines: vec![] }], vec![]);
assert!(
item.checked.is_none(),
"ListItem::new must produce checked=None"
);
}
#[test]
fn list_item_clone_preserves_checked() {
let item = ListItem {
blocks: vec![],
children: vec![],
checked: Some(true),
};
let cloned = item.clone();
assert_eq!(cloned.checked, Some(true));
}
#[test]
fn header_footer_type_serde_roundtrip() {
use serde_json;
let cases: &[(HeaderFooterType, &str)] = &[
(HeaderFooterType::Both, r#""both""#),
(HeaderFooterType::Even, r#""even""#),
(HeaderFooterType::Odd, r#""odd""#),
(HeaderFooterType::Other("custom".to_string()), r#""custom""#),
];
for (variant, expected_json) in cases {
let json = serde_json::to_string(variant)
.unwrap_or_else(|e| panic!("serialize {variant:?} failed: {e}"));
assert_eq!(
json, *expected_json,
"serialized form mismatch for {variant:?}"
);
let back: HeaderFooterType = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("deserialize {json:?} failed: {e}"));
assert_eq!(
back, *variant,
"deserialized value mismatch for {variant:?}"
);
}
}
#[test]
fn header_footer_type_from_string_normalizes_known_values() {
assert_eq!(
HeaderFooterType::from("both".to_string()),
HeaderFooterType::Both
);
assert_eq!(
HeaderFooterType::from("even".to_string()),
HeaderFooterType::Even
);
assert_eq!(
HeaderFooterType::from("odd".to_string()),
HeaderFooterType::Odd
);
}
#[test]
fn header_footer_type_from_string_unknown_becomes_other() {
assert_eq!(
HeaderFooterType::from("custom".to_string()),
HeaderFooterType::Other("custom".to_string())
);
assert_eq!(
HeaderFooterType::from("unknown".to_string()),
HeaderFooterType::Other("unknown".to_string())
);
}
#[test]
fn header_footer_type_known_never_created_as_other() {
let other_both = HeaderFooterType::from("both".to_string());
match other_both {
HeaderFooterType::Both => {
}
HeaderFooterType::Other(_) => {
panic!("Known value 'both' should not be wrapped in Other variant");
}
_ => panic!("Unexpected variant"),
}
}
#[test]
fn header_footer_type_from_empty_string() {
let result = HeaderFooterType::from("");
assert_eq!(result, HeaderFooterType::Other(String::new()));
}
#[test]
fn header_footer_type_from_whitespace() {
let result = HeaderFooterType::from(" both ");
assert_eq!(result, HeaderFooterType::Other(" both ".to_string()));
}
#[test]
fn header_footer_type_from_capitalized() {
let result = HeaderFooterType::from("Both");
assert_eq!(result, HeaderFooterType::Other("Both".to_string()));
}
#[test]
fn header_footer_type_from_str_ref() {
assert_eq!(HeaderFooterType::from("both"), HeaderFooterType::Both);
assert_eq!(HeaderFooterType::from("even"), HeaderFooterType::Even);
assert_eq!(HeaderFooterType::from("odd"), HeaderFooterType::Odd);
}
}