sdocx 1.1.0

Reverse-engineered tooling and SDK for converting Samsung Notes (.sdocx) files
Documentation
use super::*;
use crate::fonts::FontBook;
use krilla::Document;
use krilla::geom::Size;
use krilla::page::PageSettings;
use krilla::paint::FillRule;
use krilla::tagging::TagTree;
use lopdf::content::Content;
use serde::Deserialize;
use sha2::{Digest, Sha256};

#[derive(Deserialize)]
struct Capture {
    page_height: f32,
    rotation: f32,
    cases: Vec<Case>,
}

#[derive(Deserialize)]
struct Case {
    name: String,
    world_clip: Option<[f32; 4]>,
    pdf_scale: f32,
    backend_clip: bool,
    path_point_bits: Vec<[u32; 2]>,
}

fn clip_path([left, top, right, bottom]: [f32; 4]) -> Path {
    let mut builder = PathBuilder::new();
    builder.move_to(left, top);
    builder.line_to(right, top);
    builder.line_to(right, bottom);
    builder.line_to(left, bottom);
    builder.close();
    builder.finish().unwrap()
}

fn text(face: &ResolvedFace, source: &str, origin: [f64; 2]) -> NativeTextBlock {
    let font_data = face.shared_data();
    let parsed = ttf_parser::Face::parse(font_data.as_ref().as_ref(), face.index).unwrap();
    NativeTextBlock {
        source: Arc::from(source),
        runs: vec![NativeGlyphRun {
            face: face.clone(),
            font_size: 12.0,
            paint: NativeTextPaint {
                color: Color { r: 0, g: 0, b: 0 },
                alpha: 255,
                bold: false,
                skew_x: 0.0,
            },
            glyphs: vec![NativeGlyph {
                glyph_id: u32::from(
                    parsed
                        .glyph_index(source.chars().next().unwrap())
                        .unwrap()
                        .0,
                ),
                origin,
                advance: [8.0, 0.0],
                source: crate::text_index::TextIndex::new(source)
                    .source(0..source.chars().count())
                    .unwrap(),
            }],
            variable: false,
        }],
    }
}

#[derive(Clone, Copy)]
struct GraphicsState {
    matrix: [f64; 6],
    clipped: bool,
}

impl GraphicsState {
    fn transform(self, [x, y]: [f64; 2]) -> [f64; 2] {
        let [a, b, c, d, e, f] = self.matrix;
        [a * x + c * y + e, b * x + d * y + f]
    }

    fn concat(&mut self, next: [f64; 6]) {
        let [a, b, c, d, e, f] = self.matrix;
        let [na, nb, nc, nd, ne, nf] = next;
        self.matrix = [
            a * na + c * nb,
            b * na + d * nb,
            a * nc + c * nd,
            b * nc + d * nd,
            a * ne + c * nf + e,
            b * ne + d * nf + f,
        ];
    }
}

fn bounds(points: &[[f64; 2]]) -> [f64; 4] {
    assert!(!points.is_empty());
    points.iter().fold(
        [
            f64::INFINITY,
            f64::INFINITY,
            f64::NEG_INFINITY,
            f64::NEG_INFINITY,
        ],
        |[left, top, right, bottom], [x, y]| {
            [left.min(*x), top.min(*y), right.max(*x), bottom.max(*y)]
        },
    )
}

#[test]
fn supplied_native_text_clips_survive_vector_pdf_transport() {
    let bytes = include_bytes!("../../../../../conformance/table-text-clip-paths.json");
    assert_eq!(
        format!("{:x}", Sha256::digest(bytes)),
        "e5f6d442e39aedf995d807a276d86ab7ff877c31d044a5597b620adb4eaa6bdc"
    );
    let capture: Capture = serde_json::from_slice(bytes).unwrap();
    assert_eq!(capture.cases.len(), 132);
    assert_eq!(capture.rotation, 0.0);
    assert_eq!(capture.page_height, 800.0);
    let native_coordinate_tolerance =
        f64::from(f32::from_bits(capture.page_height.to_bits() + 2) - capture.page_height);
    let face = FontBook::default().resolve("Roboto", false, false).unwrap();
    let mut document = Document::new();
    let mut painter = NativePdfPainter::default();
    let mut tags = Vec::new();
    for case in &capture.cases {
        let mut page = document.start_page_with(PageSettings::new(
            Size::from_wh(600.0, capture.page_height).unwrap(),
        ));
        let mut surface = page.surface();
        surface.push_transform(&Transform::from_scale(case.pdf_scale, case.pdf_scale));
        let parent_transform = surface.ctm();
        if case.backend_clip {
            surface.push_clip_path(&clip_path(case.world_clip.unwrap()), &FillRule::NonZero);
        }
        tags.push(
            painter
                .paint(&text(&face, "A", [40.0, 80.0]), &mut surface)
                .unwrap()
                .into(),
        );
        assert_eq!(surface.ctm(), parent_transform);
        if case.backend_clip {
            surface.pop();
        }
        assert_eq!(surface.ctm(), parent_transform);
        tags.push(
            painter
                .paint(&text(&face, "B", [300.0, 400.0]), &mut surface)
                .unwrap()
                .into(),
        );
        assert_eq!(surface.ctm(), parent_transform);
        surface.pop();
        surface.finish();
        page.finish();
    }
    document.set_tag_tree(TagTree::from(tags));
    let pdf = lopdf::Document::load_mem(&document.finish().unwrap()).unwrap();
    assert!(pdf.objects.values().any(|object| {
        object
            .as_dict()
            .is_ok_and(|dictionary| dictionary.has(b"FontFile2"))
    }));
    assert!(!pdf.objects.values().any(|object| {
        object.as_dict().is_ok_and(|dictionary| {
            dictionary
                .get(b"Subtype")
                .is_ok_and(|value| value.as_name().is_ok_and(|name| name == b"Image"))
        })
    }));
    let pages = pdf.get_pages();
    assert_eq!(pages.len(), capture.cases.len());
    let mut clip_count = 0;
    for ((page_number, page_id), case) in pages.into_iter().zip(&capture.cases) {
        let operations = Content::decode(&pdf.get_page_content(page_id).unwrap())
            .unwrap()
            .operations;
        let mut state = GraphicsState {
            matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
            clipped: false,
        };
        let mut stack = Vec::new();
        let mut path = Vec::new();
        let mut path_closed = false;
        let mut clips = Vec::new();
        let mut text_clips = Vec::new();
        let mut logical_text = Vec::new();
        for operation in operations {
            let numeric = || {
                operation
                    .operands
                    .iter()
                    .map(|value| f64::from(value.as_float().unwrap()))
                    .collect::<Vec<_>>()
            };
            match operation.operator.as_str() {
                "q" => stack.push(state),
                "Q" => state = stack.pop().unwrap(),
                "cm" => state.concat(numeric().try_into().unwrap()),
                "m" => {
                    path_closed = false;
                    path.push(state.transform(numeric().try_into().unwrap()));
                }
                "l" => path.push(state.transform(numeric().try_into().unwrap())),
                "h" => path_closed = true,
                "re" => {
                    let [x, y, width, height]: [f64; 4] = numeric().try_into().unwrap();
                    path.extend(
                        [
                            [x, y],
                            [x + width, y],
                            [x + width, y + height],
                            [x, y + height],
                        ]
                        .map(|point| state.transform(point)),
                    );
                    path_closed = true;
                }
                "W" => {
                    assert!(path_closed, "{}: unclosed PDF clip path", case.name);
                    clips.push(bounds(&path));
                    state.clipped = true;
                }
                "n" => {
                    path.clear();
                    path_closed = false;
                }
                "BT" => text_clips.push(state.clipped),
                "BDC" => {
                    if let Ok(properties) = operation.operands[1].as_dict()
                        && let Ok(source) = properties.get(b"ActualText")
                    {
                        logical_text.push(source.as_str().unwrap().to_vec());
                    }
                }
                "Do" | "BI" | "W*" => {
                    panic!("unexpected vector transport operator in {}", case.name)
                }
                _ => {}
            }
        }
        assert!(stack.is_empty(), "{}", case.name);
        assert_eq!(text_clips, [case.backend_clip, false], "{}", case.name);
        assert_eq!(logical_text.first().unwrap(), b"A", "{}", case.name);
        assert_eq!(logical_text.last().unwrap(), b"B", "{}", case.name);
        assert_eq!(
            pdf.extract_text(&[page_number])
                .unwrap()
                .split_whitespace()
                .collect::<String>(),
            "AB",
            "{}",
            case.name
        );
        if case.backend_clip {
            assert_eq!(clips.len(), 1, "{}", case.name);
            let native_points = case
                .path_point_bits
                .iter()
                .map(|point| point.map(|bits| f64::from(f32::from_bits(bits))))
                .collect::<Vec<_>>();
            let expected = bounds(&native_points);
            for (actual, expected) in clips[0].into_iter().zip(expected) {
                let error = (actual - expected).abs();
                assert!(
                    error <= native_coordinate_tolerance,
                    "{}: PDF clip {actual} differs from native {expected} by {error}",
                    case.name
                );
            }
            clip_count += 1;
        } else {
            assert!(clips.is_empty(), "{}", case.name);
            assert!(case.path_point_bits.is_empty(), "{}", case.name);
        }
    }
    assert_eq!(clip_count, 69);
}