use crate::format::DocumentFormat;
fn default_true() -> bool {
true
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UnderlineStyle {
Single,
Double,
Thick,
Dotted,
Dash,
DotDash,
DotDotDash,
Wave,
Words,
None,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ParagraphAlignment {
Left,
Center,
Right,
Justify,
Distribute,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LineSpacing {
Auto(u32),
Multiple(u32),
Exact(u32),
AtLeast(u32),
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BorderStyle {
None,
Single,
Thick,
Double,
Dotted,
Dashed,
Wave,
DashSmallGap,
Outset,
Inset,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CellVerticalAlign {
Top,
Center,
Bottom,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TableAlignment {
Left,
Center,
Right,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TextDirection {
LrTb,
TbRl,
BtLr,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImageFormat {
Png,
Jpeg,
Gif,
Tiff,
Bmp,
Emf,
Wmf,
}
impl ImageFormat {
pub fn content_type(&self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
Self::Gif => "image/gif",
Self::Tiff => "image/tiff",
Self::Bmp => "image/bmp",
Self::Emf => "image/x-emf",
Self::Wmf => "image/x-wmf",
}
}
pub fn extension(&self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpeg => "jpg",
Self::Gif => "gif",
Self::Tiff => "tiff",
Self::Bmp => "bmp",
Self::Emf => "emf",
Self::Wmf => "wmf",
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ImagePositioning {
#[default]
Inline,
Floating(FloatingImage),
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SectionBreakType {
#[default]
Continuous,
NextPage,
EvenPage,
OddPage,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VerticalAlign {
Superscript,
Subscript,
Baseline,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FloatAnchor {
#[default]
Page,
Margin,
Column,
Paragraph,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TextWrap {
#[default]
Square,
Tight,
Through,
TopAndBottom,
Behind,
InFront,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ListStyle {
Bullet,
Decimal,
LowerRoman,
UpperRoman,
LowerAlpha,
UpperAlpha,
Dash,
Square,
Circle,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct BorderLine {
pub style: BorderStyle,
pub color: Option<[u8; 3]>,
pub size: Option<u32>,
pub space: Option<u32>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TableBorder {
pub top: Option<BorderLine>,
pub bottom: Option<BorderLine>,
pub left: Option<BorderLine>,
pub right: Option<BorderLine>,
pub inside_h: Option<BorderLine>,
pub inside_v: Option<BorderLine>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PageSetup {
pub width_twips: u32,
pub height_twips: u32,
pub margin_top_twips: u32,
pub margin_bottom_twips: u32,
pub margin_left_twips: u32,
pub margin_right_twips: u32,
pub landscape: bool,
pub header_distance_twips: u32,
pub footer_distance_twips: u32,
}
impl Default for PageSetup {
fn default() -> Self {
Self {
width_twips: 12240,
height_twips: 15840,
margin_top_twips: 1440,
margin_bottom_twips: 1440,
margin_left_twips: 1800,
margin_right_twips: 1800,
landscape: false,
header_distance_twips: 720,
footer_distance_twips: 720,
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct ColumnLayout {
pub count: u32,
pub space_twips: Option<u32>,
pub separator: bool,
#[serde(default)]
pub column_widths_twips: Vec<u32>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ParagraphBorder {
pub top: Option<BorderLine>,
pub bottom: Option<BorderLine>,
pub left: Option<BorderLine>,
pub right: Option<BorderLine>,
pub between: Option<BorderLine>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CellPadding {
pub top_twips: Option<u32>,
pub bottom_twips: Option<u32>,
pub left_twips: Option<u32>,
pub right_twips: Option<u32>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FloatingImage {
pub x_emu: i64,
pub y_emu: i64,
pub width_emu: u64,
pub height_emu: u64,
pub h_anchor: FloatAnchor,
pub v_anchor: FloatAnchor,
pub text_wrap: TextWrap,
#[serde(default)]
pub allow_overlap: bool,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct HeaderFooter {
pub content: Vec<Element>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct TextBox {
pub content: Vec<Element>,
pub width_emu: Option<u64>,
pub height_emu: Option<u64>,
pub x_emu: Option<i64>,
pub y_emu: Option<i64>,
#[serde(default)]
pub h_anchor: FloatAnchor,
#[serde(default)]
pub v_anchor: FloatAnchor,
#[serde(default)]
pub wrap: TextWrap,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct Note {
pub id: u32,
pub content: Vec<Element>,
pub marker: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct FootnoteRef {
pub note_id: u32,
pub marker: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct CodeBlock {
pub language: Option<String>,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct DocumentIR {
pub metadata: Metadata,
pub sections: Vec<Section>,
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct Metadata {
pub format: DocumentFormat,
pub title: Option<String>,
pub author: Option<String>,
pub subject: Option<String>,
#[serde(default)]
pub keywords: Vec<String>,
pub created: Option<String>,
pub modified: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct Section {
pub title: Option<String>,
pub elements: Vec<Element>,
pub page_setup: Option<PageSetup>,
pub columns: Option<ColumnLayout>,
#[serde(default)]
pub break_type: SectionBreakType,
pub header: Option<HeaderFooter>,
pub footer: Option<HeaderFooter>,
pub first_page_header: Option<HeaderFooter>,
pub first_page_footer: Option<HeaderFooter>,
pub even_page_header: Option<HeaderFooter>,
pub even_page_footer: Option<HeaderFooter>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_rgb: Option<[u8; 3]>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Element {
Heading(Heading),
Paragraph(Paragraph),
Table(Table),
List(List),
Image(Image),
ThematicBreak,
TextBox(TextBox),
PageBreak,
ColumnBreak,
Footnote(Note),
Endnote(Note),
CodeBlock(CodeBlock),
Shape(Shape),
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct Shape {
pub kind: ShapeGeom,
pub x_emu: i64,
pub y_emu: i64,
pub width_emu: u64,
pub height_emu: u64,
#[serde(default)]
pub h_anchor: FloatAnchor,
#[serde(default)]
pub v_anchor: FloatAnchor,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke_rgb: Option<[u8; 3]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fill_rgb: Option<[u8; 3]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke_w_emu: Option<i64>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ShapeGeom {
#[default]
Line,
Rect,
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct Heading {
#[serde(default = "default_heading_level")]
pub level: u8,
#[serde(default)]
pub content: Vec<InlineContent>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame_position: Option<FramePosition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alignment: Option<ParagraphAlignment>,
}
fn default_heading_level() -> u8 {
1
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct Paragraph {
pub content: Vec<InlineContent>,
pub alignment: Option<ParagraphAlignment>,
pub indent_left_twips: Option<i32>,
pub indent_right_twips: Option<i32>,
pub first_line_indent_twips: Option<i32>,
pub space_before_twips: Option<u32>,
pub space_after_twips: Option<u32>,
pub line_spacing: Option<LineSpacing>,
pub background_color: Option<[u8; 3]>,
pub border: Option<ParagraphBorder>,
#[serde(default)]
pub keep_with_next: bool,
#[serde(default)]
pub keep_together: bool,
#[serde(default)]
pub page_break_before: bool,
pub outline_level: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame_position: Option<FramePosition>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FramePosition {
pub x_twips: i32,
pub y_twips: i32,
pub width_twips: i32,
pub height_twips: i32,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum InlineContent {
Text(TextSpan),
LineBreak,
FootnoteRef(FootnoteRef),
EndnoteRef(FootnoteRef),
}
pub fn first_inline_font_size_pt(content: &[InlineContent]) -> Option<f32> {
for ic in content {
if let InlineContent::Text(span) = ic {
if let Some(half_pt) = span.font_size_half_pt {
return Some(half_pt as f32 / 2.0);
}
}
}
None
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct TextSpan {
pub text: String,
pub bold: bool,
pub italic: bool,
pub strikethrough: bool,
pub hyperlink: Option<String>,
pub font_size_half_pt: Option<u32>,
pub color: Option<[u8; 3]>,
pub underline: Option<UnderlineStyle>,
pub font_name: Option<String>,
pub highlight: Option<[u8; 3]>,
pub vertical_align: Option<VerticalAlign>,
#[serde(default)]
pub all_caps: bool,
#[serde(default)]
pub small_caps: bool,
pub char_spacing_half_pt: Option<i32>,
}
impl TextSpan {
pub fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
..Default::default()
}
}
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct Table {
pub rows: Vec<TableRow>,
#[serde(default)]
pub column_widths_twips: Vec<u32>,
pub border: Option<TableBorder>,
pub alignment: Option<TableAlignment>,
pub cell_padding_twips: Option<u32>,
pub caption: Option<String>,
pub width_twips: Option<u32>,
pub indent_left_twips: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TableRow {
pub cells: Vec<TableCell>,
pub is_header: bool,
pub height_twips: Option<u32>,
#[serde(default = "default_true")]
pub allow_break: bool,
#[serde(default)]
pub repeat_as_header: bool,
}
impl Default for TableRow {
fn default() -> Self {
Self {
cells: Vec::new(),
is_header: false,
height_twips: None,
allow_break: true,
repeat_as_header: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct TableCell {
pub content: Vec<Element>,
pub col_span: u32,
pub row_span: u32,
pub background_color: Option<[u8; 3]>,
pub border: Option<TableBorder>,
pub vertical_align: Option<CellVerticalAlign>,
pub text_align: Option<ParagraphAlignment>,
pub width_twips: Option<u32>,
pub padding: Option<CellPadding>,
pub text_direction: Option<TextDirection>,
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct List {
pub ordered: bool,
pub items: Vec<ListItem>,
pub start_number: Option<u32>,
pub style: Option<ListStyle>,
#[serde(default)]
pub level: u8,
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct ListItem {
pub content: Vec<Element>,
pub nested: Option<List>,
}
pub fn inline_to_element_block(content: Vec<InlineContent>) -> Vec<Element> {
if content.is_empty() {
Vec::new()
} else {
vec![Element::Paragraph(Paragraph {
content,
..Default::default()
})]
}
}
pub fn build_nested_list(
ordered: bool,
items: &[(u8, Vec<InlineContent>)],
base_level: u8,
) -> List {
let mut list_items = Vec::new();
let mut idx = 0;
while idx < items.len() {
let (level, content) = &items[idx];
let nested_start = idx + 1;
let mut nested_end = nested_start;
while nested_end < items.len() && items[nested_end].0 > base_level {
nested_end += 1;
}
let nested = if *level <= base_level && nested_end > nested_start {
Some(build_nested_list(ordered, &items[nested_start..nested_end], base_level + 1))
} else {
None
};
list_items.push(ListItem {
content: inline_to_element_block(content.clone()),
nested,
});
idx = if nested_end > nested_start {
nested_end
} else {
idx + 1
};
}
List {
ordered,
items: list_items,
..Default::default()
}
}
#[derive(Debug, Clone, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct Image {
pub alt_text: Option<String>,
pub data: Option<Vec<u8>>,
pub format: Option<ImageFormat>,
pub display_width_emu: Option<u64>,
pub display_height_emu: Option<u64>,
pub pixel_width: Option<u32>,
pub pixel_height: Option<u32>,
#[serde(default)]
pub decorative: bool,
#[serde(default)]
pub positioning: ImagePositioning,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_font_size_returns_half_pt_as_pt() {
let content = vec![InlineContent::Text(TextSpan {
text: "hi".into(),
font_size_half_pt: Some(24), ..Default::default()
})];
assert_eq!(first_inline_font_size_pt(&content), Some(12.0));
}
#[test]
fn first_font_size_picks_first_declared() {
let content = vec![
InlineContent::Text(TextSpan {
text: "a".into(),
font_size_half_pt: Some(20), ..Default::default()
}),
InlineContent::Text(TextSpan {
text: "b".into(),
font_size_half_pt: Some(48), ..Default::default()
}),
];
assert_eq!(first_inline_font_size_pt(&content), Some(10.0));
}
#[test]
fn first_font_size_skips_unsized_runs() {
let content = vec![
InlineContent::Text(TextSpan {
text: "a".into(),
..Default::default()
}),
InlineContent::Text(TextSpan {
text: "b".into(),
font_size_half_pt: Some(16), ..Default::default()
}),
];
assert_eq!(first_inline_font_size_pt(&content), Some(8.0));
}
#[test]
fn first_font_size_empty_returns_none() {
assert_eq!(first_inline_font_size_pt(&[]), None);
}
#[test]
fn first_font_size_all_unsized_returns_none() {
let content = vec![
InlineContent::Text(TextSpan::plain("a")),
InlineContent::Text(TextSpan::plain("b")),
];
assert_eq!(first_inline_font_size_pt(&content), None);
}
#[test]
fn inline_to_element_block_empty_returns_empty() {
let result = inline_to_element_block(vec![]);
assert!(result.is_empty());
}
#[test]
fn inline_to_element_block_wraps_in_paragraph() {
let inline = vec![InlineContent::Text(TextSpan::plain("hello"))];
let result = inline_to_element_block(inline);
assert_eq!(result.len(), 1);
match &result[0] {
Element::Paragraph(p) => {
assert_eq!(p.content.len(), 1);
assert!(matches!(
&p.content[0],
InlineContent::Text(s) if s.text == "hello"
));
},
_ => panic!("expected Paragraph"),
}
}
fn item(level: u8, text: &str) -> (u8, Vec<InlineContent>) {
(level, vec![InlineContent::Text(TextSpan::plain(text))])
}
fn list_item_text(item: &ListItem) -> String {
let mut out = String::new();
for el in &item.content {
if let Element::Paragraph(p) = el {
for c in &p.content {
if let InlineContent::Text(s) = c {
out.push_str(&s.text);
}
}
}
}
out
}
#[test]
fn build_nested_list_flat() {
let items = vec![item(0, "A"), item(0, "B"), item(0, "C")];
let list = build_nested_list(false, &items, 0);
assert!(!list.ordered);
assert_eq!(list.items.len(), 3);
assert!(list.items.iter().all(|li| li.nested.is_none()));
assert_eq!(list_item_text(&list.items[1]), "B");
}
#[test]
fn build_nested_list_two_levels() {
let items = vec![item(0, "A"), item(1, "A.1"), item(1, "A.2"), item(0, "B")];
let list = build_nested_list(true, &items, 0);
assert!(list.ordered);
assert_eq!(list.items.len(), 2);
let nested = list.items[0].nested.as_ref().expect("A has nested");
assert_eq!(nested.items.len(), 2);
assert_eq!(list_item_text(&nested.items[0]), "A.1");
assert_eq!(list_item_text(&nested.items[1]), "A.2");
assert!(list.items[1].nested.is_none());
}
#[test]
fn build_nested_list_three_levels() {
let items = vec![item(0, "A"), item(1, "A.1"), item(2, "A.1.x"), item(0, "B")];
let list = build_nested_list(false, &items, 0);
let l1 = list.items[0].nested.as_ref().unwrap();
assert_eq!(l1.items.len(), 1);
let l2 = l1.items[0].nested.as_ref().unwrap();
assert_eq!(l2.items.len(), 1);
assert_eq!(list_item_text(&l2.items[0]), "A.1.x");
}
#[test]
fn build_nested_list_empty() {
let list = build_nested_list(false, &[], 0);
assert!(list.items.is_empty());
}
#[test]
fn text_span_plain_has_default_styling() {
let s = TextSpan::plain("hi");
assert_eq!(s.text, "hi");
assert!(!s.bold);
assert!(!s.italic);
assert!(s.font_size_half_pt.is_none());
assert!(s.hyperlink.is_none());
}
#[test]
fn shape_default_is_line_at_origin() {
let s = Shape::default();
assert!(matches!(s.kind, ShapeGeom::Line));
assert_eq!(s.x_emu, 0);
assert_eq!(s.width_emu, 0);
assert!(s.stroke_rgb.is_none());
}
#[test]
fn frame_position_round_trips_via_serde() {
let fp = FramePosition {
x_twips: 720,
y_twips: 1080,
width_twips: 5000,
height_twips: 400,
};
let json = serde_json::to_string(&fp).unwrap();
let back: FramePosition = serde_json::from_str(&json).unwrap();
assert_eq!(fp, back);
}
}