sdocx 1.1.0

Reverse-engineered tooling and SDK for converting Samsung Notes (.sdocx) files
Documentation
#![cfg(feature = "render")]

use sdocx::{
    BoundingBox, Document, DocumentMetadata, ObjectSpanLayoutConstraint, ObjectSpanLayoutOption,
    ObjectType, Page, RenderedPage, RichTextBox, RichTextCodeBlock, RichTextObjectContent,
    RichTextObjectSpan, RichTextSection,
};

const A_ADVANCE: f64 = (652.0_f32 / 100.0) as f64;
const CODE_BODY: &str = "AAAAAAAAAAAAAAAAAAAAAA";

fn bounds(width: f64, height: f64) -> BoundingBox {
    BoundingBox {
        x_min: 0.0,
        y_min: 0.0,
        x_max: width,
        y_max: height,
    }
}

fn text(source: &str, font_size: f32) -> RichTextBox {
    RichTextBox {
        text_area_type: None,
        bbox: BoundingBox::default(),
        rotation_degrees: None,
        text: source.into(),
        color: None,
        highlight_color: None,
        underline: false,
        font_size: Some(font_size),
        runs: vec![],
        spans: vec![],
        paragraphs: vec![],
        object_spans: vec![],
        text_sections: vec![],
        margins: None,
        gravity: None,
    }
}

fn metadata(maximum_width: f32) -> Vec<u8> {
    let mut fixed = 5500_u32.to_le_bytes().to_vec();
    fixed.extend(0_u16.to_le_bytes());
    fixed.extend(0_i64.to_le_bytes());
    fixed.extend(
        [0.0_f64, 0.0, 300.0, 100.0]
            .into_iter()
            .flat_map(f64::to_le_bytes),
    );
    fixed.extend([0; 5]);
    let offset = 15 + fixed.len();
    let mut data = ((offset + 8) as u32).to_le_bytes().to_vec();
    data.extend(0_i16.to_le_bytes());
    data.extend((offset as u32).to_le_bytes());
    data.extend([1, 8, 2, 0, 1]);
    data.extend(fixed);
    data.extend(maximum_width.to_le_bytes());
    data.extend(500.0_f32.to_le_bytes());
    data
}

fn document(constraint: ObjectSpanLayoutConstraint, maximum_width: f32) -> Document {
    let mut body = text("Z\nA\u{fffc}B\nQ\nR", 10.0);
    body.text_sections = vec![
        RichTextSection {
            start_utf16: 0,
            length_utf16: 2,
        },
        RichTextSection {
            start_utf16: 2,
            length_utf16: 5,
        },
    ];
    body.object_spans.push(RichTextObjectSpan {
        object_type: ObjectType::CodeBlock,
        object_data: metadata(maximum_width),
        content: Some(RichTextObjectContent::CodeBlock(Box::new(
            RichTextCodeBlock {
                bbox: bounds(300.0, 100.0),
                rotation_degrees: None,
                title: Some(text("T", 1.0)),
                body: Some(text(CODE_BODY, 10.0)),
            },
        ))),
        text_index_utf16: 3,
        layout_option: ObjectSpanLayoutOption::Inline,
        layout_constraint: constraint,
    });
    Document {
        pages: (0..2)
            .map(|index| Page {
                uuid: format!("code-width-{index}"),
                width: 500,
                height: 500,
                content_bbox: bounds(500.0, 500.0),
                background_color: None,
                template: None,
                background: Default::default(),
                objects: vec![],
            })
            .collect(),
        metadata: DocumentMetadata {
            note_text: Some(body),
            page_mode: Some(0),
            default_page_dimensions: Some((360, 500)),
            orientation: Some(0),
            flow_page_padding: Some((0, 0)),
            ..Default::default()
        },
    }
}

fn render(document: &Document, replay: bool) -> RenderedPage {
    let layout = sdocx::layout_document(document);
    let window = layout.pages[1]
        .body_text_slice()
        .unwrap()
        .capture_window
        .as_ref()
        .unwrap();
    assert_eq!(window.first_page_index, 1);
    assert_eq!(window.saved_source_range, 2..7);
    let page = if replay {
        sdocx::render_layout_page_replay_svg(document, &layout, 1, &Default::default())
    } else {
        sdocx::render_layout_page_svg(document, &layout, 1, &Default::default())
    }
    .unwrap();
    assert!(
        page.text_diagnostics.is_empty(),
        "{:?}",
        page.text_diagnostics
    );
    assert!(
        page.object_diagnostics.is_empty(),
        "{:?}",
        page.object_diagnostics
    );
    page
}

fn point(node: roxmltree::Node<'_, '_>) -> (f64, f64) {
    let coordinate = |key| {
        node.ancestors()
            .find_map(|ancestor| ancestor.attribute(key))
            .unwrap()
            .split_whitespace()
            .next()
            .unwrap()
            .parse::<f64>()
            .unwrap()
    };
    let mut point = (coordinate("x"), coordinate("y"));
    for ancestor in node.ancestors() {
        if let Some(transform) = ancestor.attribute("transform") {
            let transform: svgtypes::Transform = transform.parse().unwrap();
            point = (
                transform.a * point.0 + transform.c * point.1 + transform.e,
                transform.b * point.0 + transform.d * point.1 + transform.f,
            );
        }
    }
    point
}

fn close(actual: f64, expected: f64) {
    assert!((actual - expected).abs() < 0.0001, "{actual} != {expected}");
}

fn assert_geometry(page: &RenderedPage, panel_width: f64, normal: bool) {
    let xml = roxmltree::Document::parse(&page.svg).unwrap();
    assert!(!xml.descendants().any(|node| node.has_tag_name("image")));
    let glyph = |source| {
        point(
            xml.descendants()
                .find(|node| node.has_tag_name("tspan") && node.text() == Some(source))
                .unwrap(),
        )
    };
    let a = glyph("A");
    let b = glyph("B");
    close(a.0, 0.0);
    close(
        b.0,
        A_ADVANCE
            + if normal || panel_width == 300.0 {
                308.0
            } else {
                panel_width
            },
    );
    close(a.1, if normal { 110.001 } else { 87.501 });
    close(b.1, a.1);
    close(glyph("Q").1, if normal { 123.501 } else { 101.001 });
    let panel = xml
        .descendants()
        .find(|node| node.has_tag_name("rect") && node.attribute("fill") == Some("#efefef"))
        .unwrap();
    assert!((point(panel).0 - (A_ADVANCE + 4.0)).abs() < 0.005);
    close(
        panel.attribute("width").unwrap().parse().unwrap(),
        panel_width,
    );
    close(
        panel.attribute("height").unwrap().parse().unwrap(),
        if panel_width == 150.0 { 91.0 } else { 77.5 },
    );
    let body_lines: Vec<_> = xml
        .descendants()
        .filter(|node| {
            node.has_tag_name("tspan")
                && node.text().is_some_and(|source| {
                    source.len() > 1 && source.bytes().all(|byte| byte == b'A')
                })
        })
        .collect();
    let expected = if panel_width == 150.0 {
        vec!["AAAAAAAAAAAAAAAAAA", "AAAA"]
    } else {
        vec![CODE_BODY]
    };
    assert_eq!(
        body_lines
            .iter()
            .map(|node| node.text().unwrap())
            .collect::<Vec<_>>(),
        expected
    );
    for (index, line) in body_lines.iter().enumerate() {
        close(point(*line).1, 64.001 + index as f64 * 13.5);
    }
}

#[test]
fn detached_code_cap_updates_inline_advance_and_drawing_without_replacing_parent_height() {
    const {
        assert!(18.0 * A_ADVANCE <= 150.0 - 32.0);
        assert!(19.0 * A_ADVANCE > 150.0 - 32.0);
    }
    let face = sdocx::fonts::FontBook::default()
        .resolve("Roboto", false, false)
        .unwrap();
    let mut buffer = sdocx::fonts::UnicodeBuffer::new();
    buffer.push_str("A");
    buffer.set_direction(sdocx::fonts::Direction::LeftToRight);
    let shaped = face.shape(buffer, &[]).unwrap();
    assert_eq!(shaped.advance_x(), 1336);
    assert_eq!(shaped.metrics.units_per_em, 2048);
    for replay in [false, true] {
        assert_geometry(
            &render(&document(ObjectSpanLayoutConstraint::Normal, 150.0), replay),
            300.0,
            true,
        );
        for constraint in [
            ObjectSpanLayoutConstraint::OverPagesOverlapPadding,
            ObjectSpanLayoutConstraint::OverPages,
        ] {
            for maximum_width in [150.0, 200.0, 300.0] {
                assert_geometry(
                    &render(&document(constraint, maximum_width), replay),
                    f64::from(maximum_width),
                    false,
                );
            }
        }
    }
}

#[cfg(feature = "pdf")]
#[path = "support/pdf_geometry.rs"]
mod pdf_geometry;

#[cfg(feature = "pdf")]
#[test]
fn detached_capped_code_exports_selectable_vector_pdf() {
    for constraint in [
        ObjectSpanLayoutConstraint::OverPagesOverlapPadding,
        ObjectSpanLayoutConstraint::OverPages,
    ] {
        let page = render(&document(constraint, 150.0), false);
        let bytes = sdocx::render_svg_pages_pdf(&[page], &Default::default()).unwrap();
        let pdf = pdf_geometry::read(&bytes, 96.0);
        assert_eq!(pdf.image_resources, 0);
        assert!(pdf.images.is_empty());
        assert_eq!(pdf.source.matches(CODE_BODY).count(), 1);
        assert_eq!(
            pdf.extracted_text
                .chars()
                .filter(|&character| character == 'A')
                .count(),
            23
        );
        let b = pdf
            .text
            .iter()
            .find(|(source, _, _)| source == "B")
            .unwrap();
        assert!((b.1 - (A_ADVANCE + 150.0)).abs() < 0.0002);
        assert!((b.2 - 87.501).abs() < 0.0002);
    }
}