sdocx 1.1.0

Reverse-engineered tooling and SDK for converting Samsung Notes (.sdocx) files
Documentation
use crate::{BoundingBox, NativeShape, RichTextBox};

mod native;
mod polygon;
mod rounded;
mod triangle;

use native::NativeRect;

#[derive(Clone, Copy, Debug, PartialEq)]
struct TextInsets {
    left: f32,
    top: f32,
    right: f32,
    bottom: f32,
}

impl TextInsets {
    fn symmetric(horizontal: f32, vertical: f32) -> Self {
        Self {
            left: horizontal,
            top: vertical,
            right: horizontal,
            bottom: vertical,
        }
    }
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct TextRotation {
    pub degrees: f64,
    pub center: [f64; 2],
}

#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct PlacedTextFrame {
    pub bounds: BoundingBox,
    pub background_bounds: BoundingBox,
    pub rotation: Option<TextRotation>,
    measured_size: Option<[i32; 2]>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ShapeTextFrameIssue {
    UnsupportedTemplate,
    InvalidGeometry,
}

impl PlacedTextFrame {
    pub fn from_text_box(text: &RichTextBox) -> Self {
        let bounds = text.bbox;
        Self {
            bounds,
            background_bounds: bounds,
            measured_size: None,
            rotation: text.rotation_degrees.map(|degrees| TextRotation {
                degrees,
                center: [
                    bounds.x_min + (bounds.x_max - bounds.x_min) / 2.0,
                    bounds.y_min + (bounds.y_max - bounds.y_min) / 2.0,
                ],
            }),
        }
    }

    pub fn measurement_size(&self) -> Option<[i32; 2]> {
        self.measured_size
    }

    pub fn for_shape(shape: &NativeShape) -> Result<Self, ShapeTextFrameIssue> {
        if matches!(shape.shape_type, 2 | 3 | 5 | 6 | 11) {
            let rect = NativeRect::new(shape.geometry_bbox)?;
            let insets = match shape.shape_type {
                2 | 3 => triangle::insets(shape, &rect)?,
                5 => rounded::insets(shape, &rect)?,
                6 | 11 => polygon::insets(shape, &rect)?,
                _ => unreachable!(),
            };
            return Self::from_native_rect(rect, insets, shape.rotation_degrees);
        }
        Self::from_shape_geometry(
            shape.shape_type,
            shape.geometry_bbox,
            shape.rotation_degrees,
        )
    }

    fn from_shape_geometry(
        template: u32,
        geometry: BoundingBox,
        rotation_degrees: f32,
    ) -> Result<Self, ShapeTextFrameIssue> {
        if !matches!(template, 1 | 4 | 8) {
            return Err(ShapeTextFrameIssue::UnsupportedTemplate);
        }
        let rect = NativeRect::new(geometry)?;
        let width = rect.width;
        let height = rect.height;
        let [horizontal, vertical] = match template {
            1 => [(width * 3.0) / 20.0, (height * 3.0) / 20.0],
            4 => [0.0, 0.0],
            8 => [
                width * f32::from_bits(0x3e85_1eb8),
                height * f32::from_bits(0x3e85_1eb8),
            ],
            _ => unreachable!(),
        };
        Self::from_native_rect(
            rect,
            TextInsets::symmetric(horizontal, vertical),
            rotation_degrees,
        )
    }

    fn from_native_rect(
        rect: NativeRect,
        insets: TextInsets,
        rotation_degrees: f32,
    ) -> Result<Self, ShapeTextFrameIssue> {
        let [left, top, right, bottom] = [
            rect.left + insets.left,
            rect.top + insets.top,
            rect.right - insets.right,
            rect.bottom - insets.bottom,
        ];
        let width = right - left;
        let height = bottom - top;
        if ![left, top, right, bottom, width, height, rotation_degrees]
            .into_iter()
            .all(f32::is_finite)
            || width <= 0.0
            || height <= 0.0
        {
            return Err(ShapeTextFrameIssue::InvalidGeometry);
        }
        let bounds = BoundingBox {
            x_min: f64::from(left),
            y_min: f64::from(top),
            x_max: f64::from(right),
            y_max: f64::from(bottom),
        };
        Ok(Self {
            bounds,
            background_bounds: rect.bounds(),
            measured_size: Some([width.ceil() as i32, height.ceil() as i32]),
            rotation: Some(TextRotation {
                degrees: f64::from(rotation_degrees),
                center: rect.center.map(f64::from),
            }),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn bounds(left: f64, top: f64, right: f64, bottom: f64) -> BoundingBox {
        BoundingBox {
            x_min: left,
            y_min: top,
            x_max: right,
            y_max: bottom,
        }
    }

    #[test]
    fn native_templates_inset_the_unrotated_frame() {
        for (template, expected, size) in [
            (1, bounds(30.0, 15.0, 170.0, 85.0), [140, 70]),
            (4, bounds(0.0, 0.0, 200.0, 100.0), [200, 100]),
            (8, bounds(52.0, 26.0, 148.0, 74.0), [96, 48]),
        ] {
            let frame = PlacedTextFrame::from_shape_geometry(
                template,
                bounds(0.0, 0.0, 200.0, 100.0),
                30.0,
            )
            .unwrap();
            assert_eq!(frame.bounds, expected);
            assert_eq!(frame.measurement_size(), Some(size));
            assert_eq!(
                frame.rotation,
                Some(TextRotation {
                    degrees: 30.0,
                    center: [100.0, 50.0]
                })
            );
        }
    }

    #[test]
    fn native_endpoint_subtraction_precedes_ceiled_layout_dimensions() {
        let frame =
            PlacedTextFrame::from_shape_geometry(4, bounds(1.0, 0.25, 201.000001, 100.250001), 0.0)
                .unwrap();
        assert_eq!(frame.bounds, bounds(1.0, 0.25, 201.0, 100.25));
        assert_eq!(frame.measurement_size(), Some([200, 100]));
        let frame =
            PlacedTextFrame::from_shape_geometry(4, bounds(1.0, 0.25, 201.00001, 100.25001), 0.0)
                .unwrap();
        assert_eq!(
            frame.bounds,
            bounds(1.0, 0.25, 201.000_015_258_789_06, 100.250_007_629_394_53)
        );
        assert_eq!(frame.measurement_size(), Some([201, 101]));
    }

    #[test]
    fn native_measurement_dimensions_ceil_to_saturating_signed_integers() {
        let frame = PlacedTextFrame::from_shape_geometry(
            4,
            bounds(0.0, 0.0, 30_000_000_000.0, 30_000_000_000.0),
            0.0,
        )
        .unwrap();
        assert_eq!(frame.measurement_size(), Some([i32::MAX, i32::MAX]));
        let tiny =
            PlacedTextFrame::from_shape_geometry(4, bounds(0.0, 0.0, 0.000001, 0.0000001), 0.0)
                .unwrap();
        assert_eq!(tiny.measurement_size(), Some([1, 1]));
    }

    #[test]
    fn enormous_origins_keep_native_measurement_size_independent_of_endpoints() {
        let frame =
            PlacedTextFrame::from_shape_geometry(4, bounds(1e30, 0.0, 1.000001e30, 100.0), 0.0)
                .unwrap();
        assert_eq!(frame.measurement_size(), Some([i32::MAX, 100]));
        assert!(frame.bounds.x_max > frame.bounds.x_min);
        assert_eq!(frame.bounds.x_min + f64::from(i32::MAX), frame.bounds.x_min);
    }

    #[test]
    fn rotation_pivot_uses_original_native_endpoint_sum_then_half() {
        let frame = PlacedTextFrame::from_shape_geometry(
            4,
            bounds(16_777_216.0, 0.0, 16_777_218.0, 100.0),
            45.0,
        )
        .unwrap();
        assert_eq!(frame.rotation.unwrap().center, [16_777_216.0, 50.0]);
        let ellipse =
            PlacedTextFrame::from_shape_geometry(1, bounds(20.0, -10.0, 220.0, 90.0), -90.0)
                .unwrap();
        assert_eq!(ellipse.rotation.unwrap().center, [120.0, 40.0]);
    }

    #[test]
    fn unsupported_templates_are_explicit() {
        for template in [0, 2, 3, 5, 9, u32::MAX] {
            assert_eq!(
                PlacedTextFrame::from_shape_geometry(template, bounds(0.0, 0.0, 200.0, 100.0), 0.0),
                Err(ShapeTextFrameIssue::UnsupportedTemplate)
            );
        }
    }

    #[test]
    fn invalid_native_geometry_and_intermediate_overflow_are_rejected() {
        for (template, geometry, rotation) in [
            (4, bounds(0.0, 0.0, 0.0, 100.0), 0.0),
            (4, bounds(0.0, 0.0, 200.0, -1.0), 0.0),
            (4, bounds(16_777_216.0, 0.0, 16_777_217.0, 100.0), 0.0),
            (4, bounds(0.0, 0.0, 200.0, 100.0), f32::INFINITY),
            (4, bounds(0.0, 0.0, f64::INFINITY, 100.0), 0.0),
            (
                4,
                bounds(-f64::from(f32::MAX), 0.0, f64::from(f32::MAX), 100.0),
                0.0,
            ),
            (
                4,
                bounds(f64::from(f32::MAX / 2.0), 0.0, f64::from(f32::MAX), 100.0),
                0.0,
            ),
            (1, bounds(0.0, 0.0, f64::from(f32::MAX), 100.0), 0.0),
        ] {
            assert_eq!(
                PlacedTextFrame::from_shape_geometry(template, geometry, rotation),
                Err(ShapeTextFrameIssue::InvalidGeometry)
            );
        }
    }

    #[test]
    fn ordinary_text_keeps_its_original_f64_frame_and_pivot() {
        let text = RichTextBox {
            text_area_type: None,
            bbox: bounds(1.00000001, 2.25, 201.000001, 102.25),
            rotation_degrees: Some(30.0),
            text: "A".into(),
            color: None,
            highlight_color: None,
            underline: false,
            font_size: None,
            runs: Vec::new(),
            spans: Vec::new(),
            paragraphs: Vec::new(),
            object_spans: Vec::new(),
            text_sections: Vec::new(),
            margins: None,
            gravity: None,
        };
        let frame = PlacedTextFrame::from_text_box(&text);
        assert_eq!(frame.bounds, text.bbox);
        assert_eq!(frame.measurement_size(), None);
        assert_eq!(
            frame.rotation.unwrap().center,
            [101.000_000_504_999_99, 52.25]
        );
    }
}