#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Document {
pub pages: Vec<Page>,
pub metadata: DocumentMetadata,
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DocumentMetadata {
pub format_version: Option<FormatVersion>,
pub created_ms: Option<i64>,
pub modified_ms: Option<i64>,
pub background_color: Option<Color>,
pub dark_mode_compatibility: Option<bool>,
pub page_dimensions: Option<(u32, u32)>,
#[cfg_attr(feature = "serde", serde(default))]
pub default_page_dimensions: Option<(u32, u32)>,
#[cfg_attr(feature = "serde", serde(default))]
pub page_mode: Option<u16>,
#[cfg_attr(feature = "serde", serde(default))]
pub orientation: Option<i32>,
#[cfg_attr(feature = "serde", serde(default))]
pub body_font_size_delta: Option<i32>,
pub flow_dimensions: Option<(u32, u32)>,
pub flow_page_padding: Option<(u32, u32)>,
pub page_ids: Vec<String>,
pub media_assets: Vec<MediaAsset>,
#[cfg_attr(feature = "serde", serde(default))]
pub media_manifest: Option<crate::MediaManifest>,
#[cfg_attr(feature = "serde", serde(default))]
pub archive_resources: Vec<crate::ArchiveResource>,
pub note_text: Option<RichTextBox>,
pub note_title: Option<RichTextBox>,
}
impl DocumentMetadata {
pub fn document_density(&self) -> f32 {
let Some((width, height)) = self.default_page_dimensions else {
return 1.0;
};
let axis = if self.orientation.unwrap_or(0) == 0 {
width
} else {
height
};
let density = axis as i32 as f32 / 360.0;
if density <= 0.0 { 1.0 } else { density }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct FormatVersion(pub u16);
impl FormatVersion {
pub const INITIAL: Self = Self(2034);
pub const MINIMUM_SUPPORTED: Self = Self(4000);
pub const MATH_OBJECTS: Self = Self(5200);
pub const TABLE_AND_CODE_BLOCK_OBJECTS: Self = Self(5400);
pub const CURRENT: Self = Self(5500);
pub const fn raw(self) -> u16 {
self.0
}
pub const fn supports_math_objects(self) -> bool {
self.0 >= Self::MATH_OBJECTS.0
}
pub const fn supports_table_and_code_block_objects(self) -> bool {
self.0 >= Self::TABLE_AND_CODE_BLOCK_OBJECTS.0
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Page {
pub uuid: String,
pub width: u32,
pub height: u32,
pub content_bbox: BoundingBox,
pub background_color: Option<Color>,
pub template: Option<PageTemplate>,
#[cfg_attr(feature = "serde", serde(default))]
pub background: PageBackground,
pub objects: Vec<crate::PageObject>,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct MediaAsset {
pub name: String,
pub archive_id: Option<u32>,
pub mime_type: String,
pub data: Vec<u8>,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum PageElement {
Image {
bbox: BoundingBox,
media_index: usize,
},
PlacedImage(PlacedImage),
TextBox(RichTextBox),
Shape(crate::NativeShape),
Line(crate::NativeLine),
}
impl PageElement {
pub const fn object_type(&self) -> ObjectType {
match self {
Self::Image { .. } | Self::PlacedImage(_) => ObjectType::Image,
Self::TextBox(_) => ObjectType::TextBox,
Self::Shape(_) => ObjectType::Shape,
Self::Line(_) => ObjectType::Line,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct PlacedImage {
pub bbox: BoundingBox,
pub rotation_degrees: Option<f64>,
pub media_id: Option<u32>,
pub media_index: Option<usize>,
pub crop_rect: Option<[i32; 4]>,
pub original_bbox: Option<BoundingBox>,
pub border_media_id: Option<u32>,
pub original_media_id: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum ObjectType {
None,
Stroke,
TextBox,
Image,
Container,
Shape,
Line,
DeprecatedDummyStroke,
Voice,
Formula,
DeprecatedTable,
Web,
Painting,
StrokeDevelopmentVersion,
Video,
Link,
StrokeBrush,
Unknown,
Plot,
Math,
Table,
CodeBlock,
AttachedFile,
StrokeGroup,
Other(u32),
}
impl ObjectType {
pub const fn raw(self) -> u32 {
match self {
Self::None => 0,
Self::Stroke => 1,
Self::TextBox => 2,
Self::Image => 3,
Self::Container => 4,
Self::Shape => 7,
Self::Line => 8,
Self::DeprecatedDummyStroke => 9,
Self::Voice => 10,
Self::Formula => 11,
Self::DeprecatedTable => 12,
Self::Web => 13,
Self::Painting => 14,
Self::StrokeDevelopmentVersion => 15,
Self::Video => 16,
Self::Link => 17,
Self::StrokeBrush => 18,
Self::Unknown => 19,
Self::Plot => 20,
Self::Math => 21,
Self::Table => 22,
Self::CodeBlock => 23,
Self::AttachedFile => 24,
Self::StrokeGroup => 100,
Self::Other(raw) => raw,
}
}
pub const fn is_supported_by(self, version: FormatVersion) -> bool {
match self {
Self::Math => version.supports_math_objects(),
Self::Table | Self::CodeBlock => version.supports_table_and_code_block_objects(),
_ => true,
}
}
}
impl From<u32> for ObjectType {
fn from(raw: u32) -> Self {
match raw {
0 => Self::None,
1 => Self::Stroke,
2 => Self::TextBox,
3 => Self::Image,
4 => Self::Container,
7 => Self::Shape,
8 => Self::Line,
9 => Self::DeprecatedDummyStroke,
10 => Self::Voice,
11 => Self::Formula,
12 => Self::DeprecatedTable,
13 => Self::Web,
14 => Self::Painting,
15 => Self::StrokeDevelopmentVersion,
16 => Self::Video,
17 => Self::Link,
18 => Self::StrokeBrush,
19 => Self::Unknown,
20 => Self::Plot,
21 => Self::Math,
22 => Self::Table,
23 => Self::CodeBlock,
24 => Self::AttachedFile,
100 => Self::StrokeGroup,
raw => Self::Other(raw),
}
}
}
impl From<ObjectType> for u32 {
fn from(object_type: ObjectType) -> Self {
object_type.raw()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum TextAreaType {
Margin,
Free,
Path,
Other(u8),
}
impl From<u8> for TextAreaType {
fn from(raw: u8) -> Self {
match raw {
0 => Self::Margin,
1 => Self::Free,
2 => Self::Path,
raw => Self::Other(raw),
}
}
}
impl TextAreaType {
pub const fn raw(self) -> u8 {
match self {
Self::Margin => 0,
Self::Free => 1,
Self::Path => 2,
Self::Other(raw) => raw,
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RichTextBox {
pub text_area_type: Option<TextAreaType>,
pub bbox: BoundingBox,
pub rotation_degrees: Option<f64>,
pub text: String,
pub color: Option<Color>,
pub highlight_color: Option<Color>,
pub underline: bool,
pub font_size: Option<f32>,
pub runs: Vec<RichTextRun>,
pub spans: Vec<RichTextSpan>,
pub paragraphs: Vec<RichTextParagraph>,
pub object_spans: Vec<RichTextObjectSpan>,
pub text_sections: Vec<RichTextSection>,
pub margins: Option<[f32; 4]>,
pub gravity: Option<u8>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RichTextObjectSpan {
pub object_type: ObjectType,
pub object_data: Vec<u8>,
pub content: Option<RichTextObjectContent>,
pub text_index_utf16: i32,
pub layout_option: ObjectSpanLayoutOption,
pub layout_constraint: ObjectSpanLayoutConstraint,
}
impl RichTextObjectSpan {
pub fn object_metadata(&self) -> crate::Result<Option<crate::ObjectMetadata>> {
if self.object_data.is_empty() {
return Ok(None);
}
let mut reader = crate::binary::Reader::new(&self.object_data, "rich-text object metadata");
crate::ObjectMetadata::read(&mut reader).map(Some)
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum RichTextObjectContent {
Image(Box<PlacedImage>),
Table(Box<RichTextTable>),
CodeBlock(Box<RichTextCodeBlock>),
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RichTextTable {
pub style: crate::TableStyle,
pub bbox: BoundingBox,
pub rotation_degrees: Option<f64>,
pub column_widths: Vec<f32>,
pub rows: Vec<RichTextTableRow>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RichTextTableRow {
pub max_height: Option<f32>,
pub min_height: Option<f32>,
pub metadata: crate::TableRecordMetadata,
pub index: u32,
pub height: f32,
pub cells: Vec<RichTextTableCell>,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RichTextTableCell {
pub border: Option<crate::TableBorder>,
pub metadata: crate::TableRecordMetadata,
pub column_index: u32,
pub row_span: u32,
pub column_span: u32,
pub background_color: u32,
pub has_own_background_color: bool,
pub bbox: BoundingBox,
pub editable: bool,
pub content: RichTextBox,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RichTextCodeBlock {
pub bbox: BoundingBox,
pub rotation_degrees: Option<f64>,
pub title: Option<RichTextBox>,
pub body: Option<RichTextBox>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum ObjectSpanLayoutOption {
Block,
Inline,
BlockWithSmallMargin,
BlockWithMediumMargin,
Other(u32),
}
impl From<u32> for ObjectSpanLayoutOption {
fn from(raw: u32) -> Self {
match raw {
0 => Self::Block,
1 => Self::Inline,
2 => Self::BlockWithSmallMargin,
3 => Self::BlockWithMediumMargin,
raw => Self::Other(raw),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum ObjectSpanLayoutConstraint {
Normal,
OverPagesOverlapPadding,
OverPages,
Other(u32),
}
impl From<u32> for ObjectSpanLayoutConstraint {
fn from(raw: u32) -> Self {
match raw {
0 => Self::Normal,
1 => Self::OverPagesOverlapPadding,
2 => Self::OverPages,
raw => Self::Other(raw),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RichTextSection {
pub start_utf16: i32,
pub length_utf16: i32,
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RichTextRun {
pub start: usize,
pub end: usize,
pub bold: bool,
pub italic: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SpanIntervalType {
#[default]
ClosedOpen,
ClosedClosed,
OpenOpen,
OpenClosed,
Other(u32),
}
impl SpanIntervalType {
pub const fn raw(self) -> u32 {
match self {
Self::ClosedOpen => 0,
Self::ClosedClosed => 1,
Self::OpenOpen => 2,
Self::OpenClosed => 3,
Self::Other(raw) => raw,
}
}
pub const fn contains_caret(self, start: u32, end: u32, index: u32) -> bool {
match self {
Self::ClosedOpen => index == start || start < index && index < end,
Self::ClosedClosed => start <= index && index <= end,
Self::OpenOpen => start < index && index < end,
Self::OpenClosed | Self::Other(_) => index == end || start < index && index < end,
}
}
}
impl From<u32> for SpanIntervalType {
fn from(raw: u32) -> Self {
match raw {
0 => Self::ClosedOpen,
1 => Self::ClosedClosed,
2 => Self::OpenOpen,
3 => Self::OpenClosed,
raw => Self::Other(raw),
}
}
}
#[cfg(feature = "serde")]
impl serde::Serialize for SpanIntervalType {
fn serialize<S: serde::Serializer>(
&self,
serializer: S,
) -> std::result::Result<S::Ok, S::Error> {
serializer.serialize_u32(self.raw())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for SpanIntervalType {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum Value {
Raw(u32),
Legacy(bool),
}
Ok(Self::from(match Value::deserialize(deserializer)? {
Value::Raw(raw) => raw,
Value::Legacy(expand) => u32::from(expand),
}))
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RichTextSpan {
pub kind: RichTextSpanType,
pub start_utf16: u32,
pub end_utf16: u32,
#[cfg_attr(feature = "serde", serde(alias = "expand"))]
pub interval_type: SpanIntervalType,
pub payload: Vec<u8>,
}
impl RichTextSpan {
pub const fn contains_caret(&self, index_utf16: u32) -> bool {
self.interval_type
.contains_caret(self.start_utf16, self.end_utf16, index_utf16)
}
pub fn boolean_value(&self) -> Option<bool> {
self.payload
.get(..2)
.map(|bytes| u16::from_le_bytes([bytes[0], bytes[1]]) == 1)
}
pub fn argb_value(&self) -> Option<u32> {
Some(u32::from_le_bytes(self.payload.get(..4)?.try_into().ok()?))
}
pub fn color_value(&self) -> Option<Color> {
let argb = self.argb_value()?;
Some(Color {
r: (argb >> 16) as u8,
g: (argb >> 8) as u8,
b: argb as u8,
})
}
pub fn composition_value(&self) -> Option<bool> {
if !matches!(
self.kind,
RichTextSpanType::Composing | RichTextSpanType::ComposingTag
) {
return None;
}
Some(self.payload.get(..8)?[0] != 0)
}
pub fn composing_background_value(&self) -> Option<u32> {
if self.kind != RichTextSpanType::ComposingBackgroundColor {
return None;
}
payload_u32(self.payload.get(..8)?, 0)
}
pub fn suggestion_value(&self) -> Option<RichTextSuggestion> {
if self.kind != RichTextSpanType::Suggestion {
return None;
}
let mut reader = crate::binary::Reader::new(&self.payload, "suggestion span");
let suggestion_type = reader.read_u32("suggestion type").ok()?;
let underline_argb = reader.read_u32("underline ARGB").ok()?;
let count = reader.read_i32("suggestion count").ok()?;
let count = usize::try_from(count).unwrap_or(0);
if count > reader.remaining() / 2 {
return None;
}
let mut strings = Vec::new();
for _ in 0..count {
let text = reader
.read_utf16_u16_without_null_sentinel("suggestion", usize::MAX)
.ok()?;
if !text.is_empty() {
strings.push(text);
}
}
Some(RichTextSuggestion {
suggestion_type,
underline_argb,
strings,
})
}
pub fn font_size_value(&self) -> Option<f32> {
let bytes = self.payload.get(..4)?.try_into().ok()?;
Some(f32::from_le_bytes(bytes))
}
pub fn font_name_value(&self) -> Option<&str> {
std::str::from_utf8(self.font_name_bytes()?).ok()
}
pub fn decoded_font_name_value(&self) -> Option<std::borrow::Cow<'_, str>> {
cesu8::from_cesu8(self.font_name_bytes()?).ok()
}
fn font_name_bytes(&self) -> Option<&[u8]> {
if self.kind != RichTextSpanType::FontName {
return None;
}
let length = usize::from(u16::from_le_bytes(
self.payload.get(8..10)?.try_into().ok()?,
));
let name = self.payload.get(10..10 + length)?.strip_suffix(&[0])?;
if name.contains(&0) {
return None;
}
Some(name)
}
pub fn hyperlink_value(&self) -> Option<RichTextHyperlink> {
if self.kind != RichTextSpanType::Hyperlink {
return None;
}
let kind = HyperlinkType::from(payload_u32(&self.payload, 0)?);
let date_time_type = payload_u32(&self.payload, 4)?;
let length = usize::try_from(payload_u32(&self.payload, 8)?).ok()?;
let byte_length = length.checked_mul(2)?;
let bytes = self.payload.get(12..12_usize.checked_add(byte_length)?)?;
let custom_data = (!bytes.is_empty())
.then(|| {
bytes
.as_chunks::<2>()
.0
.iter()
.map(|unit| u16::from_le_bytes([unit[0], unit[1]]))
.collect::<Vec<_>>()
})
.map(|units| String::from_utf16(&units))
.transpose()
.ok()?;
Some(RichTextHyperlink {
kind,
date_time_type,
custom_data,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RichTextSuggestion {
pub suggestion_type: u32,
pub underline_argb: u32,
pub strings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RichTextHyperlink {
pub kind: HyperlinkType,
pub date_time_type: u32,
pub custom_data: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum HyperlinkType {
Unknown,
Email,
Telephone,
Url,
Date,
Address,
DateTime,
Formula,
File,
Custom,
Other(u32),
}
#[cfg(feature = "render")]
impl HyperlinkType {
pub(crate) const fn is_hypertext(self) -> bool {
!matches!(self, Self::Unknown | Self::Other(_))
}
}
impl From<u32> for HyperlinkType {
fn from(raw: u32) -> Self {
match raw {
0 => Self::Unknown,
1 => Self::Email,
2 => Self::Telephone,
3 => Self::Url,
4 => Self::Date,
5 => Self::Address,
6 => Self::DateTime,
7 => Self::Formula,
8 => Self::File,
9 => Self::Custom,
raw => Self::Other(raw),
}
}
}
fn payload_u32(payload: &[u8], offset: usize) -> Option<u32> {
Some(u32::from_le_bytes(
payload
.get(offset..offset.checked_add(4)?)?
.try_into()
.ok()?,
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum RichTextSpanType {
None,
ForegroundColor,
FontSize,
FontName,
Bold,
Italic,
Underline,
Hyperlink,
ComposingBackgroundColor,
Composing,
BackgroundColor,
ComposingTag,
Timestamp,
Strikethrough,
Suggestion,
SpellCorrection,
Formula,
Other(u32),
}
impl From<u32> for RichTextSpanType {
fn from(raw: u32) -> Self {
match raw {
0 => Self::None,
1 => Self::ForegroundColor,
3 => Self::FontSize,
4 => Self::FontName,
5 => Self::Bold,
6 => Self::Italic,
7 => Self::Underline,
9 => Self::Hyperlink,
15 => Self::ComposingBackgroundColor,
16 => Self::Composing,
17 => Self::BackgroundColor,
18 => Self::ComposingTag,
19 => Self::Timestamp,
20 => Self::Strikethrough,
21 => Self::Suggestion,
22 => Self::SpellCorrection,
23 => Self::Formula,
raw => Self::Other(raw),
}
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RichTextParagraph {
pub kind: RichTextParagraphType,
pub start_paragraph: u32,
pub end_paragraph: u32,
pub payload: Vec<u8>,
}
impl RichTextParagraph {
pub fn alignment(&self) -> Option<ParagraphAlignment> {
if self.kind != RichTextParagraphType::Alignment {
return None;
}
Some(ParagraphAlignment::from(self.payload_u32(0)?))
}
pub fn indent(&self) -> Option<ParagraphIndent> {
if self.kind != RichTextParagraphType::IndentLevel {
return None;
}
Some(ParagraphIndent {
level: self.payload_u32(0)?,
direction: ParagraphDirection::from(self.payload_u32(4)?),
})
}
pub fn line_spacing(&self) -> Option<ParagraphLineSpacing> {
if self.kind != RichTextParagraphType::LineSpacing {
return None;
}
Some(ParagraphLineSpacing {
kind: LineSpacingType::from(self.payload_u32(0)?),
value: self.payload_f32(4)?,
})
}
pub fn bullet(&self) -> Option<ParagraphBullet> {
if self.kind != RichTextParagraphType::Bullet {
return None;
}
Some(ParagraphBullet {
kind: BulletType::from(self.payload_u32(0)?),
number: self.payload_u32(4)?,
checked: self.payload_u32(8)? != 0,
initial_number: self.payload_u32(12)?,
})
}
pub fn spacing(&self) -> Option<f32> {
matches!(
self.kind,
RichTextParagraphType::SpacingBefore | RichTextParagraphType::SpacingAfter
)
.then(|| self.payload_f32(0))?
}
pub fn predefined_style(&self) -> Option<ParagraphPredefinedStyle> {
if self.kind != RichTextParagraphType::PredefinedStyle {
return None;
}
Some(ParagraphPredefinedStyle {
style: PredefinedTextStyle::from(self.payload_u32(0)?),
following_style: PredefinedTextStyle::from(self.payload_u32(4)?),
})
}
pub fn is_parsed(&self) -> Option<bool> {
(self.kind == RichTextParagraphType::ParsingState)
.then(|| self.payload_u32(0).map(|value| value != 0))?
}
fn payload_u32(&self, offset: usize) -> Option<u32> {
let bytes = self.payload.get(offset..offset.checked_add(4)?)?;
Some(u32::from_le_bytes(bytes.try_into().ok()?))
}
fn payload_f32(&self, offset: usize) -> Option<f32> {
let bytes = self.payload.get(offset..offset.checked_add(4)?)?;
Some(f32::from_le_bytes(bytes.try_into().ok()?))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum RichTextParagraphType {
None,
Legacy,
IndentLevel,
Alignment,
LineSpacing,
Bullet,
ParsingState,
SpacingBefore,
SpacingAfter,
PredefinedStyle,
Other(u32),
}
impl From<u32> for RichTextParagraphType {
fn from(raw: u32) -> Self {
match raw {
0 => Self::None,
1 => Self::Legacy,
2 => Self::IndentLevel,
3 => Self::Alignment,
4 => Self::LineSpacing,
5 => Self::Bullet,
6 => Self::ParsingState,
8 => Self::SpacingBefore,
9 => Self::SpacingAfter,
10 => Self::PredefinedStyle,
raw => Self::Other(raw),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum ParagraphAlignment {
Left,
Right,
Center,
Both,
Other(u32),
}
impl From<u32> for ParagraphAlignment {
fn from(raw: u32) -> Self {
match raw {
0 => Self::Left,
1 => Self::Right,
2 => Self::Center,
3 => Self::Both,
raw => Self::Other(raw),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum ParagraphDirection {
None,
LeftToRight,
RightToLeft,
Other(u32),
}
impl From<u32> for ParagraphDirection {
fn from(raw: u32) -> Self {
match raw {
0 => Self::None,
1 => Self::LeftToRight,
2 => Self::RightToLeft,
raw => Self::Other(raw),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ParagraphIndent {
pub level: u32,
pub direction: ParagraphDirection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum LineSpacingType {
Pixels,
Percent,
Other(u32),
}
impl From<u32> for LineSpacingType {
fn from(raw: u32) -> Self {
match raw {
0 => Self::Pixels,
1 => Self::Percent,
raw => Self::Other(raw),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ParagraphLineSpacing {
pub kind: LineSpacingType,
pub value: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum BulletType {
None,
Arrow,
Checker,
Diamond,
Digit,
CircledDigit,
Alphabet,
RomanNumeral,
SolidCircle,
WhiteCircle,
UppercaseAlphabet,
BlackSquare,
WhiteSquare,
Other(u32),
}
impl From<u32> for BulletType {
fn from(raw: u32) -> Self {
match raw {
0 => Self::None,
1 => Self::Arrow,
2 => Self::Checker,
3 => Self::Diamond,
4 => Self::Digit,
5 => Self::CircledDigit,
6 => Self::Alphabet,
7 => Self::RomanNumeral,
8 => Self::SolidCircle,
9 => Self::WhiteCircle,
10 => Self::UppercaseAlphabet,
11 => Self::BlackSquare,
12 => Self::WhiteSquare,
raw => Self::Other(raw),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ParagraphBullet {
pub kind: BulletType,
pub number: u32,
pub checked: bool,
pub initial_number: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum PredefinedTextStyle {
Heading1,
Heading2,
Heading3,
Body1,
Other(u32),
}
impl From<u32> for PredefinedTextStyle {
fn from(raw: u32) -> Self {
match raw {
0 => Self::Heading1,
1 => Self::Heading2,
2 => Self::Heading3,
3 => Self::Body1,
raw => Self::Other(raw),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ParagraphPredefinedStyle {
pub style: PredefinedTextStyle,
pub following_style: PredefinedTextStyle,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct PageBackground {
pub template_uri: Option<String>,
pub image_id: Option<u32>,
pub image_mode: Option<u32>,
pub width: Option<u32>,
pub rotation: Option<u32>,
#[cfg_attr(feature = "serde", serde(default))]
pub color_argb: Option<u32>,
#[cfg_attr(feature = "serde", serde(default))]
pub pdf_paper: Option<Vec<PdfPaperRecord>>,
#[cfg_attr(feature = "serde", serde(default))]
pub template_type: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct PdfPaperRecord {
pub media_id: i32,
pub page_index: i32,
pub rectangle: PdfPaperRectangle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum PdfPaperRectangle {
Integer([i32; 4]),
LegacyFloatBits([u32; 4]),
Unspecified([u8; 16]),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PageTemplate {
pub id: u32,
pub source: PageTemplateSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum PageTemplateSource {
BuiltIn,
CustomPdf {
page_index: u32,
},
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Stroke {
#[cfg_attr(feature = "serde", serde(default))]
pub rendering: Option<crate::StrokeRendering>,
pub bbox: BoundingBox,
pub points: Vec<Point>,
pub pressures: Vec<f64>,
pub timestamps: Vec<i64>,
pub tilts: Vec<f64>,
pub orientations: Vec<f64>,
pub color: Option<Color>,
pub pen_width: f32,
}
impl Stroke {
pub const fn object_type(&self) -> ObjectType {
ObjectType::Stroke
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BoundingBox {
pub x_min: f64,
pub y_min: f64,
pub x_max: f64,
pub y_max: f64,
}
impl Default for BoundingBox {
fn default() -> Self {
Self {
x_min: 0.0,
y_min: 0.0,
x_max: 0.0,
y_max: 0.0,
}
}
}
#[cfg(test)]
mod tests {
use super::{
BulletType, DocumentMetadata, FormatVersion, HyperlinkType, LineSpacingType, ObjectType,
ParagraphAlignment, ParagraphDirection, PredefinedTextStyle, RichTextParagraph,
RichTextParagraphType, RichTextSpan, RichTextSpanType,
};
fn paragraph(kind: RichTextParagraphType, values: &[u32]) -> RichTextParagraph {
RichTextParagraph {
kind,
start_paragraph: 0,
end_paragraph: 1,
payload: values
.iter()
.flat_map(|value| value.to_le_bytes())
.collect(),
}
}
#[test]
fn color_span_argb_retains_alpha_and_rejects_truncated_payloads() {
let mut span = RichTextSpan {
kind: RichTextSpanType::BackgroundColor,
start_utf16: 0,
end_utf16: 1,
interval_type: crate::SpanIntervalType::from(0),
payload: Vec::new(),
};
for argb in [0x00345678_u32, 0x80345678, 0xff345678] {
span.payload = argb.to_le_bytes().to_vec();
assert_eq!(span.argb_value(), Some(argb));
assert_eq!(
span.color_value(),
Some(super::Color {
r: 0x34,
g: 0x56,
b: 0x78
})
);
span.payload.push(0xff);
assert_eq!(span.argb_value(), Some(argb));
}
for length in 0..4 {
span.payload = vec![0; length];
assert_eq!(span.argb_value(), None);
assert_eq!(span.color_value(), None);
}
}
fn span_payload(kind: RichTextSpanType, payload: &[u8]) -> RichTextSpan {
RichTextSpan {
kind,
start_utf16: 0,
end_utf16: 1,
interval_type: crate::SpanIntervalType::from(0),
payload: payload.to_vec(),
}
}
fn span_binary_capture() -> serde_json::Value {
use sha2::Digest;
const FIXTURE: &str = include_str!("../../../conformance/table-text-span-binary.json");
assert_eq!(
format!("{:x}", sha2::Sha256::digest(FIXTURE.as_bytes())),
"0e8437fead4285c0ead18349f8708309219c74c6aae8a2acc83bec9fdbbc1c7b"
);
serde_json::from_str(FIXTURE).unwrap()
}
fn capture_bytes(value: &serde_json::Value) -> Vec<u8> {
value
.as_array()
.unwrap()
.iter()
.map(|byte| u8::try_from(byte.as_u64().unwrap()).unwrap())
.collect()
}
#[test]
fn composition_and_suggestion_values_match_native_binary_reader_and_writer() {
let capture = span_binary_capture();
let mut readers = 0;
let mut writers = 0;
for case in capture["cases"].as_array().unwrap() {
let kind = RichTextSpanType::from(case["kind"].as_u64().unwrap() as u32);
if case["version"].as_u64() == Some(8)
&& matches!(
kind,
RichTextSpanType::ComposingBackgroundColor
| RichTextSpanType::Composing
| RichTextSpanType::ComposingTag
| RichTextSpanType::Suggestion
)
{
let record = capture_bytes(&case["record"]);
let available = case["available"].as_u64().unwrap() as usize;
let span = span_payload(kind, record.get(16..available).unwrap_or_default());
let original = span.payload.clone();
let applied = case["applied"].as_bool().unwrap();
match kind {
RichTextSpanType::ComposingBackgroundColor => assert_eq!(
span.composing_background_value(),
applied.then(|| case["decoded"]["color"].as_u64().unwrap() as u32),
"{}",
case["name"]
),
RichTextSpanType::Composing | RichTextSpanType::ComposingTag => assert_eq!(
span.composition_value(),
applied.then(|| case["decoded"]["enabled"].as_bool().unwrap()),
"{}",
case["name"]
),
RichTextSpanType::Suggestion => {
assert_native_suggestion(&span, case, applied);
}
_ => unreachable!(),
}
assert_eq!(span.payload, original);
readers += 1;
}
if kind == RichTextSpanType::Suggestion && case["written"].as_bool() == Some(true) {
let output = capture_bytes(&case["output"]);
let span = span_payload(kind, &output[16..]);
assert_native_suggestion(&span, case, true);
writers += 1;
}
}
assert_eq!(readers, 132);
assert_eq!(writers, 60);
}
fn assert_native_suggestion(span: &RichTextSpan, case: &serde_json::Value, applied: bool) {
let expected = applied.then(|| super::RichTextSuggestion {
suggestion_type: case["decoded"]["suggestion_type"].as_u64().unwrap() as u32,
underline_argb: case["decoded"]["underline"].as_u64().unwrap() as u32,
strings: case["decoded"]["strings"]
.as_array()
.unwrap()
.iter()
.map(|text| text.as_str().unwrap().to_owned())
.collect(),
});
assert_eq!(span.suggestion_value(), expected, "{}", case["name"]);
}
#[test]
fn composition_requires_complete_modern_frames_and_matching_kinds() {
for kind in [RichTextSpanType::Composing, RichTextSpanType::ComposingTag] {
for length in 0..8 {
assert_eq!(
span_payload(kind, &vec![0xff; length]).composition_value(),
None
);
}
for value in [0, 1, 2, 0xff] {
let mut payload = vec![0x7b; 12];
payload[0] = value;
assert_eq!(
span_payload(kind, &payload).composition_value(),
Some(value != 0)
);
}
}
for length in 0..8 {
assert_eq!(
span_payload(
RichTextSpanType::ComposingBackgroundColor,
&vec![0xff; length]
)
.composing_background_value(),
None
);
}
let unrelated = span_payload(RichTextSpanType::SpellCorrection, &[0; 16]);
assert_eq!(unrelated.composition_value(), None);
assert_eq!(unrelated.composing_background_value(), None);
assert_eq!(unrelated.suggestion_value(), None);
}
#[test]
fn suggestions_bound_declared_lists_and_reject_malformed_utf16() {
let mut payload = vec![0; 12];
for length in 0..12 {
assert_eq!(
span_payload(RichTextSpanType::Suggestion, &payload[..length]).suggestion_value(),
None
);
}
payload[8..12].copy_from_slice(&i32::MAX.to_le_bytes());
assert_eq!(
span_payload(RichTextSpanType::Suggestion, &payload).suggestion_value(),
None
);
payload[8..12].copy_from_slice(&1_u32.to_le_bytes());
payload.extend_from_slice(&u16::MAX.to_le_bytes());
assert_eq!(
span_payload(RichTextSpanType::Suggestion, &payload).suggestion_value(),
None
);
payload[12..14].copy_from_slice(&1_u16.to_le_bytes());
payload.extend_from_slice(&0xd800_u16.to_le_bytes());
assert_eq!(
span_payload(RichTextSpanType::Suggestion, &payload).suggestion_value(),
None
);
for count in [i32::MIN, -1, 0] {
payload[8..12].copy_from_slice(&count.to_le_bytes());
assert_eq!(
span_payload(RichTextSpanType::Suggestion, &payload)
.suggestion_value()
.unwrap()
.strings,
Vec::<String>::new()
);
}
}
#[test]
fn object_type_ids_round_trip_without_losing_future_values() {
for raw in 0..=100 {
let object_type = ObjectType::from(raw);
assert_eq!(object_type.raw(), raw);
}
assert_eq!(ObjectType::from(19), ObjectType::Unknown);
assert_eq!(ObjectType::from(25), ObjectType::Other(25));
}
#[test]
fn version_gates_match_current_sdk_contract() {
assert!(!ObjectType::Math.is_supported_by(FormatVersion(5199)));
assert!(ObjectType::Math.is_supported_by(FormatVersion::MATH_OBJECTS));
assert!(!ObjectType::Table.is_supported_by(FormatVersion(5399)));
assert!(ObjectType::Table.is_supported_by(FormatVersion::TABLE_AND_CODE_BLOCK_OBJECTS));
assert!(ObjectType::Stroke.is_supported_by(FormatVersion::INITIAL));
}
#[test]
fn document_density_uses_native_default_dimensions_and_orientation() {
let mut metadata = DocumentMetadata {
default_page_dimensions: Some((1080, 1440)),
page_dimensions: Some((360, 360)),
flow_dimensions: Some((720, 720)),
..Default::default()
};
assert_eq!(metadata.document_density(), 3.0);
metadata.orientation = Some(0);
assert_eq!(metadata.document_density(), 3.0);
for orientation in [1, 2, -1, i32::MIN] {
metadata.orientation = Some(orientation);
assert_eq!(metadata.document_density(), 4.0);
}
metadata.default_page_dimensions = Some((180, 720));
metadata.orientation = Some(0);
assert_eq!(metadata.document_density(), 0.5);
}
#[test]
fn document_density_falls_back_for_absent_or_invalid_signed_axes() {
let mut metadata = DocumentMetadata {
page_dimensions: Some((1080, 1440)),
flow_dimensions: Some((1080, 1440)),
..Default::default()
};
assert_eq!(metadata.document_density(), 1.0);
for dimensions in [(0, 720), (u32::MAX, 720), (0x8000_0000, 720)] {
metadata.default_page_dimensions = Some(dimensions);
metadata.orientation = Some(0);
assert_eq!(metadata.document_density(), 1.0);
metadata.orientation = Some(1);
assert_eq!(metadata.document_density(), 2.0);
}
for dimensions in [(720, 0), (720, u32::MAX), (720, 0x8000_0000)] {
metadata.default_page_dimensions = Some(dimensions);
metadata.orientation = Some(1);
assert_eq!(metadata.document_density(), 1.0);
metadata.orientation = Some(0);
assert_eq!(metadata.document_density(), 2.0);
}
}
fn font_name_span(payload: &[u8]) -> RichTextSpan {
RichTextSpan {
kind: RichTextSpanType::FontName,
start_utf16: 0,
end_utf16: 1,
interval_type: crate::SpanIntervalType::from(0),
payload: payload.to_vec(),
}
}
#[test]
fn font_names_use_native_byte_count_and_preserve_names_and_payloads() {
let regular = font_name_span(&[
0xde, 0xad, 0xbe, 0xef, 0x12, 0x34, 0x56, 0x78, 7, 0, b'R', b'o', b'b', b'o', b't',
b'o', 0,
]);
assert_eq!(regular.font_name_value(), Some("Roboto"));
assert!(matches!(
regular.decoded_font_name_value(),
Some(std::borrow::Cow::Borrowed("Roboto"))
));
let quoted_unicode = font_name_span(&[
0xde, 0xad, 0xbe, 0xef, 0x12, 0x34, 0x56, 0x78, 8, 0, b' ', b'"', 0xe7, 0xad, 0x86,
b'"', b' ', 0,
]);
let original = quoted_unicode.payload.clone();
assert_eq!(quoted_unicode.font_name_value(), Some(" \"筆\" "));
assert_eq!(quoted_unicode.payload, original);
let name = quoted_unicode.font_name_value().unwrap();
assert_eq!(name.as_ptr(), quoted_unicode.payload[10..].as_ptr());
let empty = font_name_span(&[0xde, 0xad, 0xbe, 0xef, 0x12, 0x34, 0x56, 0x78, 1, 0, 0]);
assert_eq!(empty.font_name_value(), Some(""));
assert_eq!(empty.decoded_font_name_value().as_deref(), Some(""));
}
#[test]
fn font_names_decode_native_cesu8_surrogate_pairs_without_losing_payloads() {
let span = font_name_span(&[
0, 0, 0, 0, 0, 0, 0, 0, 13, 0, 0xe5, 0xad, 0x97, 0xe4, 0xbd, 0x93, 0xed, 0xa0, 0xb4,
0xed, 0xb4, 0x9e, 0,
]);
let original = span.payload.clone();
assert_eq!(span.font_name_value(), None);
assert!(
matches!(span.decoded_font_name_value(), Some(std::borrow::Cow::Owned(ref name)) if name == "字体𝄞")
);
assert_eq!(span.payload, original);
let mut utf8 = vec![0; 8];
utf8.extend_from_slice(&11_u16.to_le_bytes());
utf8.extend_from_slice("字体𝄞".as_bytes());
utf8.push(0);
let span = font_name_span(&utf8);
assert!(matches!(
span.decoded_font_name_value(),
Some(std::borrow::Cow::Borrowed("字体𝄞"))
));
}
#[test]
fn font_names_reject_truncated_and_malformed_native_payloads() {
let valid = [
0xde, 0xad, 0xbe, 0xef, 0x12, 0x34, 0x56, 0x78, 7, 0, b'R', b'o', b'b', b'o', b't',
b'o', 0,
];
for end in 0..valid.len() {
assert_eq!(font_name_span(&valid[..end]).font_name_value(), None);
assert_eq!(
font_name_span(&valid[..end]).decoded_font_name_value(),
None
);
}
let mut wrong_kind = font_name_span(&valid);
wrong_kind.kind = RichTextSpanType::ForegroundColor;
assert_eq!(wrong_kind.font_name_value(), None);
assert_eq!(wrong_kind.decoded_font_name_value(), None);
let malformed: &[&[u8]] = &[
&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
&[0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0],
&[0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0xff, 0],
&[0, 0, 0, 0, 0, 0, 0, 0, 2, 0, b'A', b'B'],
&[0, 0, 0, 0, 0, 0, 0, 0, 3, 0, b'A', 0, 0],
&[0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0xe7, 0xad, 0],
&[0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0xed, 0xa0, 0xb4, 0],
&[0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0xed, 0xb4, 0x9e, 0],
&[
0, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0xed, 0xa0, 0xb4, 0xed, 0xb4, 0,
],
&[0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0xc0, 0x80, 0],
];
for payload in malformed {
assert_eq!(font_name_span(payload).font_name_value(), None);
assert_eq!(font_name_span(payload).decoded_font_name_value(), None);
}
}
#[test]
fn font_names_match_native_binary_writer_outputs() {
let capture = span_binary_capture();
let mut count = 0;
for case in capture["cases"].as_array().unwrap() {
if case["kind"].as_u64() != Some(4) || case["written"].as_bool() != Some(true) {
continue;
}
let bytes = capture_bytes(&case["output"]);
let span = font_name_span(&bytes[16..]);
let expected = case["decoded"]["name"].as_str().unwrap();
assert_eq!(
span.decoded_font_name_value().as_deref(),
Some(expected),
"{}",
case["name"]
);
for end in 0..span.payload.len() {
assert_eq!(
font_name_span(&span.payload[..end]).decoded_font_name_value(),
None,
"{} truncated at {end}",
case["name"]
);
}
count += 1;
}
assert_eq!(count, 60);
}
#[test]
fn decodes_apk_hyperlink_payload() {
let target = "https://example.com/markdown-test";
let mut payload = Vec::new();
payload.extend_from_slice(&3_u32.to_le_bytes());
payload.extend_from_slice(&0_u32.to_le_bytes());
payload.extend_from_slice(&(target.encode_utf16().count() as u32).to_le_bytes());
payload.extend(target.encode_utf16().flat_map(|unit| unit.to_le_bytes()));
let span = RichTextSpan {
kind: RichTextSpanType::Hyperlink,
start_utf16: 0,
end_utf16: 12,
interval_type: crate::SpanIntervalType::from(0),
payload,
};
let hyperlink = span.hyperlink_value().unwrap();
assert_eq!(hyperlink.kind, HyperlinkType::Url);
assert_eq!(hyperlink.date_time_type, 0);
assert_eq!(hyperlink.custom_data.as_deref(), Some(target));
}
#[test]
fn decodes_apk_paragraph_payloads() {
assert_eq!(
paragraph(RichTextParagraphType::Alignment, &[2]).alignment(),
Some(ParagraphAlignment::Center)
);
let indent = paragraph(RichTextParagraphType::IndentLevel, &[3, 2])
.indent()
.unwrap();
assert_eq!(indent.level, 3);
assert_eq!(indent.direction, ParagraphDirection::RightToLeft);
let line_spacing = paragraph(RichTextParagraphType::LineSpacing, &[1, 1.6_f32.to_bits()])
.line_spacing()
.unwrap();
assert_eq!(line_spacing.kind, LineSpacingType::Percent);
assert_eq!(line_spacing.value, 1.6);
let bullet = paragraph(RichTextParagraphType::Bullet, &[4, 2, 1, 1])
.bullet()
.unwrap();
assert_eq!(bullet.kind, BulletType::Digit);
assert_eq!(bullet.number, 2);
assert!(bullet.checked);
assert_eq!(bullet.initial_number, 1);
assert_eq!(
paragraph(RichTextParagraphType::SpacingBefore, &[20.0_f32.to_bits()]).spacing(),
Some(20.0)
);
let style = paragraph(RichTextParagraphType::PredefinedStyle, &[0, 3])
.predefined_style()
.unwrap();
assert_eq!(style.style, PredefinedTextStyle::Heading1);
assert_eq!(style.following_style, PredefinedTextStyle::Body1);
assert_eq!(
paragraph(RichTextParagraphType::ParsingState, &[1]).is_parsed(),
Some(true)
);
}
}