text-document-io 1.10.2

Import/export for text-document: plain text, Markdown, HTML, LaTeX, DOCX
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! Feature tests for round-trip **marks** in the DOCX writer (`export_docx_uc.rs`'s
//! `prepare_marks` and the `SpanEmit::Mark` half of the shared `PreparedSpan` machinery).
//!
//! The OOXML twin of `odt_mark_export_tests.rs`, proving the same contract through a very
//! different mechanism: ODF spells a bookmark's name on both halves of a pair and offers a
//! self-closing form for a point, while OOXML names only the start, closes by numeric id, and
//! has no self-closing spelling at all — a point mark is a start immediately followed by its
//! end. Those differences are exactly where a second implementation of one idea goes wrong, so
//! each is asserted here rather than assumed to mirror the ODF side.
//!
//! The last test packs a real `.docx` and runs it through LibreOffice, which is the claim that
//! actually matters: not "this crate can re-read what it wrote", but "the identity survives an
//! editor". `document_ingest`'s `roundtrip_marks.rs` pins the same fact from a fixture; this
//! pins it from *this writer's* output.

extern crate text_document_io as document_io;

use common::parser_tools::{
    DocumentComment, DocumentComments, DocumentMark, DocumentMarks, DocxExportOptions,
};
use document_io::{ExportDocxDto, ImportDjotDto, document_io_controller};
use test_harness::{DbContext, EventHub, setup};

use std::io::{Cursor, Read};
use std::sync::Arc;

// --- harness -----------------------------------------------------------------------------

fn import_djot(db: &DbContext, ev: &Arc<EventHub>, djot: &str) {
    let mut mgr = common::long_operation::LongOperationManager::new();
    let op = document_io_controller::import_djot(
        db,
        ev,
        &mut mgr,
        &ImportDjotDto {
            djot_text: djot.to_string(),
            options: Default::default(),
        },
    )
    .expect("import_djot");
    while let Some(common::long_operation::OperationStatus::Running) = mgr.get_operation_status(&op)
    {
        std::thread::sleep(std::time::Duration::from_millis(2));
    }
    assert_eq!(
        mgr.get_operation_status(&op),
        Some(common::long_operation::OperationStatus::Completed),
        "import of {djot:?} did not complete"
    );
}

fn char_range_of(db: &DbContext, needle: &str) -> (u32, u32) {
    for bid in test_harness::get_all_block_ids(db).expect("block ids") {
        let block = test_harness::block_controller::get(db, &bid)
            .expect("get block")
            .expect("block exists");
        let text = test_harness::block_text_dto(db, &block);
        if let Some(byte_idx) = text.find(needle) {
            let char_offset = text[..byte_idx].chars().count() as u32;
            let start = block.document_position as u32 + char_offset;
            let end = start + needle.chars().count() as u32;
            return (start, end);
        }
    }
    panic!("no block contains {needle:?}");
}

fn comment(uid: &str, range: (u32, u32), body: &str) -> DocumentComment {
    DocumentComment {
        start: range.0,
        end: range.1,
        uid: uid.to_string(),
        author: "Editor".to_string(),
        author_initials: "E".to_string(),
        date: "2026-01-01T00:00:00Z".to_string(),
        resolved: false,
        body: body.to_string(),
        replies: Vec::new(),
    }
}

fn marks_of(iter: impl IntoIterator<Item = DocumentMark>) -> DocumentMarks {
    let mut out = DocumentMarks::new();
    for m in iter {
        out.insert(m);
    }
    out
}

/// Pack a real `.docx` through the production path and return its bytes.
fn build(
    db: &DbContext,
    comments: DocumentComments,
    marks: DocumentMarks,
) -> anyhow::Result<Vec<u8>> {
    let xml_docx = document_io_controller::build_docx_xml_document(
        db,
        &ExportDocxDto {
            output_path: "unused.docx".to_string(),
            options: DocxExportOptions {
                comments,
                marks,
                ..Default::default()
            },
        },
    )?;
    let mut bytes = Vec::new();
    xml_docx.pack(Cursor::new(&mut bytes))?;
    Ok(bytes)
}

fn part(bytes: &[u8], name: &str) -> String {
    let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).expect("packed docx is a valid zip");
    let mut file = archive
        .by_name(name)
        .unwrap_or_else(|e| panic!("{name} missing: {e}"));
    let mut out = String::new();
    file.read_to_string(&mut out).expect("part is valid utf-8");
    out
}

/// The `w:id` of the `w:bookmarkStart` carrying `name`.
fn bookmark_id(document_xml: &str, name: &str) -> String {
    let needle = format!(r#"w:name="{name}""#);
    let at = document_xml
        .find(&needle)
        .unwrap_or_else(|| panic!("no bookmark named {name} in {document_xml}"));
    let tag_start = document_xml[..at].rfind('<').expect("name is inside a tag");
    let tag = &document_xml[tag_start..at];
    let id_at = tag
        .find(r#"w:id=""#)
        .expect("a bookmarkStart carries an id")
        + r#"w:id=""#.len();
    let id_end = tag[id_at..].find('"').expect("id is closed") + id_at;
    tag[id_at..id_end].to_string()
}

// --- shape -------------------------------------------------------------------------------

#[test]
fn a_point_mark_is_a_start_immediately_followed_by_its_end() {
    let (db, ev, _) = setup().expect("setup");
    import_djot(&db, &ev, "She turned the corner and the street was gone.");
    let (at, _) = char_range_of(&db, "She turned");

    let xml = part(
        &build(
            &db,
            DocumentComments::new(),
            marks_of([DocumentMark::point(
                at,
                "skrb_r0000000000000001_aaaaaaaaaaaa",
            )]),
        )
        .expect("export"),
        "word/document.xml",
    );

    let id = bookmark_id(&xml, "skrb_r0000000000000001_aaaaaaaaaaaa");
    let start =
        format!(r#"<w:bookmarkStart w:id="{id}" w:name="skrb_r0000000000000001_aaaaaaaaaaaa" />"#);
    let end = format!(r#"<w:bookmarkEnd w:id="{id}" />"#);
    let start_at = xml
        .find(&start)
        .unwrap_or_else(|| panic!("bookmarkStart not found in {xml}"));
    let end_at = xml
        .find(&end)
        .unwrap_or_else(|| panic!("bookmarkEnd not found in {xml}"));

    assert!(
        end_at > start_at,
        "a zero-length bookmark closes after it opens"
    );
    // Nothing at all between them: OOXML has no self-closing bookmark, so "point" means the two
    // halves are adjacent. Anything in between would mean the mark had accidentally acquired an
    // extent, and a reader resolving it would report characters the host never marked.
    assert_eq!(
        &xml[start_at + start.len()..end_at],
        "",
        "a point mark must not span any content"
    );
}

#[test]
fn a_range_mark_brackets_exactly_its_characters() {
    let (db, ev, _) = setup().expect("setup");
    import_djot(&db, &ev, "She turned the corner and the street was gone.");
    let (start, end) = char_range_of(&db, "the street");

    let xml = part(
        &build(
            &db,
            DocumentComments::new(),
            marks_of([DocumentMark::range(start, end, "skrb_c000000000000c001")]),
        )
        .expect("export"),
        "word/document.xml",
    );

    let id = bookmark_id(&xml, "skrb_c000000000000c001");
    let open_at = xml.find(r#"w:name="skrb_c000000000000c001""#).unwrap();
    let close = format!(r#"<w:bookmarkEnd w:id="{id}" />"#);
    let close_at = xml.find(&close).expect("the range closes");
    assert!(close_at > open_at, "the pair is ordered");

    let between = &xml[open_at..close_at];
    assert!(
        between.contains("the street"),
        "the pair does not bracket its own text: {between}"
    );
    assert!(
        !between.contains("She turned"),
        "the pair reaches back over text it never named: {between}"
    );
}

/// OOXML names a bookmark only on its start — this is what forces a reader to keep an id table,
/// and it is the difference from ODF most likely to be papered over by a copy-paste.
#[test]
fn only_the_start_carries_the_name() {
    let (db, ev, _) = setup().expect("setup");
    import_djot(&db, &ev, "She turned the corner and the street was gone.");
    let (start, end) = char_range_of(&db, "the street");

    let xml = part(
        &build(
            &db,
            DocumentComments::new(),
            marks_of([DocumentMark::range(start, end, "skrb_c000000000000c001")]),
        )
        .expect("export"),
        "word/document.xml",
    );

    assert_eq!(
        xml.matches(r#"w:name="skrb_c000000000000c001""#).count(),
        1,
        "the name appears once, on the start"
    );
}

// --- interaction with comments -----------------------------------------------------------

#[test]
fn a_point_mark_opens_ahead_of_a_comment_starting_on_the_same_character() {
    let (db, ev, _) = setup().expect("setup");
    import_djot(&db, &ev, "She turned the corner and the street was gone.");
    let range = char_range_of(&db, "She turned");

    let mut comments = DocumentComments::new();
    comments.insert(comment("c-1", range, "A note on the opening."));

    let xml = part(
        &build(
            &db,
            comments,
            marks_of([DocumentMark::point(
                range.0,
                "skrb_r0000000000000001_aaaaaaaaaaaa",
            )]),
        )
        .expect("export"),
        "word/document.xml",
    );

    let mark_at = xml.find("<w:bookmarkStart").expect("the mark is written");
    let comment_at = xml
        .find("<w:commentRangeStart")
        .expect("the comment is written");
    assert!(
        mark_at < comment_at,
        "the row's mark must sit at the front of its paragraph, not inside the comment's range"
    );
}

/// Marks share the prepared list with comments, and `word/comments.xml` is patched by counting
/// what is in that list. Miscount it and the patch step refuses the whole export — which is how
/// this was found the first time.
#[test]
fn marks_do_not_disturb_the_comments_part() {
    let (db, ev, _) = setup().expect("setup");
    import_djot(&db, &ev, "She turned the corner and the street was gone.");
    let comment_range = char_range_of(&db, "the street");
    let (mark_at, _) = char_range_of(&db, "She turned");

    let mut comments = DocumentComments::new();
    comments.insert(comment("c-1", comment_range, "Is this the right word?"));

    let bytes = build(
        &db,
        comments,
        marks_of([
            DocumentMark::point(mark_at, "skrb_r0000000000000001_aaaaaaaaaaaa"),
            DocumentMark::range(comment_range.0, comment_range.1, "skrb_c000000000000c001"),
        ]),
    )
    .expect("marks alongside comments must not upset the comments patch");

    let comments_xml = part(&bytes, "word/comments.xml");
    assert_eq!(
        comments_xml.matches("<w:comment ").count(),
        1,
        "exactly the one comment, no marks leaking in: {comments_xml}"
    );
    assert!(
        comments_xml.contains(r#"skrb:uid="c-1""#),
        "the comment still carries its uid: {comments_xml}"
    );
    assert!(
        !comments_xml.contains("skrb_r"),
        "a mark must not appear in the comments part: {comments_xml}"
    );
}

// --- failure modes -----------------------------------------------------------------------

#[test]
fn an_invalid_mark_name_fails_the_export_rather_than_disappearing() {
    let (db, ev, _) = setup().expect("setup");
    import_djot(&db, &ev, "She turned the corner.");
    let (at, _) = char_range_of(&db, "She");

    let err = build(
        &db,
        DocumentComments::new(),
        marks_of([DocumentMark::point(at, "skrb-row-1")]),
    )
    .expect_err("an unusable mark name must not be written silently");

    let msg = format!("{err:#}");
    assert!(msg.contains("skrb-row-1"), "{msg}");
    assert!(msg.contains("round-trip mark"), "{msg}");
}

#[test]
fn a_mark_that_cannot_be_anchored_does_not_fail_the_export() {
    let (db, ev, _) = setup().expect("setup");
    import_djot(&db, &ev, "She turned the corner.");

    let bytes = build(
        &db,
        DocumentComments::new(),
        marks_of([DocumentMark::point(
            9_000,
            "skrb_r0000000000000009_ffffffffffff",
        )]),
    )
    .expect("an unplaceable mark must not cost the writer their export");

    let xml = part(&bytes, "word/document.xml");
    assert!(
        xml.contains("She turned the corner."),
        "the manuscript is still fully written"
    );
    assert!(
        !xml.contains("skrb_r0000000000000009_ffffffffffff"),
        "an unplaceable mark is simply absent, not written at a guessed position"
    );
}

// --- the claim that matters: survival ----------------------------------------------------

fn soffice_path() -> Option<std::path::PathBuf> {
    std::process::Command::new("which")
        .arg("soffice")
        .output()
        .ok()
        .filter(|o| o.status.success())
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
        .filter(|s| !s.is_empty())
        .map(std::path::PathBuf::from)
}

/// Both mark kinds, written by *this* writer, survive being opened and saved by a real editor.
///
/// This is the whole reason marks are bookmarks. The same file's `skrb:uid` on `<w:comment>` —
/// the carrier this writer used before — does not survive, and that asymmetry is asserted here
/// too, because a future change that "simplifies" identity back onto the attribute would
/// otherwise pass every other test in this crate.
#[test]
fn both_mark_kinds_survive_a_real_editor_saving_the_file() {
    let Some(_soffice) = soffice_path() else {
        eprintln!("skipping: soffice not found on PATH");
        return;
    };

    let (db, ev, _) = setup().expect("setup");
    import_djot(
        &db,
        &ev,
        "This manuscript opens with a sentence that needs review.\n\n\
         A second, unrelated paragraph follows.",
    );
    let comment_range = char_range_of(&db, "needs review");
    let (row_at, _) = char_range_of(&db, "This manuscript");

    let mut comments = DocumentComments::new();
    comments.insert(comment("c-1", comment_range, "Please tighten this phrase."));

    let bytes = build(
        &db,
        comments,
        marks_of([
            DocumentMark::point(row_at, "skrb_r0000000000000001_aaaaaaaaaaaa"),
            DocumentMark::range(comment_range.0, comment_range.1, "skrb_c000000000000c001"),
        ]),
    )
    .expect("export");

    let dir = std::env::temp_dir().join(format!("docx_mark_export_{}", std::process::id()));
    std::fs::create_dir_all(&dir).expect("create temp dir");
    let docx_path = dir.join("marks.docx");
    std::fs::write(&docx_path, &bytes).expect("write docx");

    // Sandboxed profile per invocation, so a concurrently-running soffice never contends over
    // the same profile lock — the same precaution `docx_comment_export_tests.rs` takes.
    let profile_dir = dir.join("lo_profile");
    let output = std::process::Command::new("soffice")
        .args([
            "--headless",
            "--norestore",
            &format!("-env:UserInstallation=file://{}", profile_dir.display()),
            "--convert-to",
            "docx:MS Word 2007 XML",
            "--outdir",
        ])
        .arg(dir.join("out"))
        .arg(&docx_path)
        .output()
        .expect("run soffice");
    assert!(
        output.status.success(),
        "soffice round trip failed: stdout={} stderr={}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let returned = std::fs::read(dir.join("out").join("marks.docx")).expect("read returned docx");
    let document_xml = part(&returned, "word/document.xml");

    for name in [
        "skrb_r0000000000000001_aaaaaaaaaaaa",
        "skrb_c000000000000c001",
    ] {
        assert!(
            document_xml.contains(name),
            "{name} did not survive a real editor's save"
        );
    }

    let comments_xml = part(&returned, "word/comments.xml");
    assert!(
        !comments_xml.contains("skrb:uid"),
        "the private attribute survived — if this ever becomes true, revisit whether marks are \
         still the only usable identity carrier: {comments_xml}"
    );
}