dxpdf 0.4.0

A fast DOCX-to-PDF converter powered by Skia
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
//! §M.1.2 — one `<mc:AlternateContent>`, one live branch, one renderer-wide
//! answer about which.
//!
//! Exactly one branch of the element is live: a consumer takes the first
//! `<mc:Choice>` whose requirements it can meet and otherwise the
//! `<mc:Fallback>`, never both, because both describe the *same* object — the
//! Choice in DrawingML, the Fallback in VML for clients that predate it.
//!
//! The failure this file pins is what happens when the answer is computed more
//! than once. The DrawingML float walkers asked one predicate, the VML float
//! walkers asked none at all (they skipped the element outright), and the
//! inline collector asked a third, narrower one. A Choice this renderer cannot
//! draw plus a Fallback holding VML therefore produced *no float*: the
//! fallback's text still reached the page through the collector, but its
//! geometry reached nothing.
//!
//! These tests are written against the page, not against a predicate, because
//! the bug was never visible in any single predicate's answer — it lived in the
//! disagreement between them.

use std::io::Write;

use dxpdf::render::layout::draw_command::{DrawCommand, LayoutedPage};

/// A 1×1 red PNG. Small enough to inline, real enough to survive image
/// pre-loading — a fixture whose bytes never decode would give `rId7` no media
/// entry and quietly turn every image assertion below into a tautology.
const RED_DOT_PNG: &[u8] = &[
    137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0,
    0, 0, 144, 119, 83, 222, 0, 0, 0, 12, 73, 68, 65, 84, 120, 218, 99, 248, 207, 192, 0, 0, 3, 1,
    1, 0, 247, 3, 65, 67, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
];

fn make_docx(document_xml: &str) -> Vec<u8> {
    let mut buf = Vec::new();
    {
        let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
        let o = zip::write::SimpleFileOptions::default()
            .compression_method(zip::CompressionMethod::Deflated);

        zip.start_file("[Content_Types].xml", o).unwrap();
        zip.write_all(
            br#"<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
  <Default Extension="xml" ContentType="application/xml"/>
  <Default Extension="png" ContentType="image/png"/>
  <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>"#,
        )
        .unwrap();

        zip.start_file("_rels/.rels", o).unwrap();
        zip.write_all(
            br#"<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>"#,
        )
        .unwrap();

        zip.start_file("word/_rels/document.xml.rels", o).unwrap();
        zip.write_all(
            br#"<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/>
</Relationships>"#,
        )
        .unwrap();

        zip.start_file("word/media/image1.png", o).unwrap();
        zip.write_all(RED_DOT_PNG).unwrap();

        zip.start_file("word/document.xml", o).unwrap();
        zip.write_all(document_xml.as_bytes()).unwrap();
        zip.finish().unwrap();
    }
    buf
}

/// Wrap `run_body` — whatever a `<w:r>` may contain — in a one-paragraph
/// document with every namespace these fixtures use declared on the root.
fn document(run_body: &str) -> String {
    format!(
        r#"<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
            xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
            xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
            xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
            xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
            xmlns:v="urn:schemas-microsoft-com:vml"
            xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">
  <w:body>
    <w:p><w:r>{run_body}</w:r></w:p>
  </w:body>
</w:document>"#
    )
}

/// A `<w:pict>` holding one absolutely-positioned `<v:rect>` whose text box
/// says `label`.
///
/// This is what a `<mc:Fallback>` carries in the wild, and — pasted outside an
/// `<mc:AlternateContent>` — it is also the control: the same markup, with
/// nobody to select a branch, has to render the same way.
fn vml_rect(label: &str) -> String {
    format!(
        r##"<w:pict>
              <v:rect style="position:absolute;margin-left:100pt;margin-top:20pt;width:120pt;height:50pt"
                      fillcolor="#DDDDDD">
                <v:textbox><w:txbxContent>
                  <w:p><w:r><w:t>{label}</w:t></w:r></w:p>
                </w:txbxContent></v:textbox>
              </v:rect>
            </w:pict>"##
    )
}

/// A DrawingML `wps:wsp` anchored shape whose text body says `label` — the
/// modern half of the pair, and the branch a `Requires="wps"` Choice carries.
fn wps_shape(label: &str) -> String {
    format!(
        r#"<w:drawing>
             <wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0"
                        relativeHeight="1" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1">
               <wp:simplePos x="0" y="0"/>
               <wp:positionH relativeFrom="margin"><wp:posOffset>1270000</wp:posOffset></wp:positionH>
               <wp:positionV relativeFrom="paragraph"><wp:posOffset>254000</wp:posOffset></wp:positionV>
               <wp:extent cx="1524000" cy="635000"/>
               <wp:wrapNone/>
               <wp:docPr id="1" name="Box"/>
               <a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">
                 <wps:wsp>
                   <wps:cNvSpPr/>
                   <wps:spPr>
                     <a:xfrm><a:off x="0" y="0"/><a:ext cx="1524000" cy="635000"/></a:xfrm>
                     <a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
                     <a:solidFill><a:srgbClr val="DDDDDD"/></a:solidFill>
                   </wps:spPr>
                   <wps:txbx><w:txbxContent>
                     <w:p><w:r><w:t>{label}</w:t></w:r></w:p>
                   </w:txbxContent></wps:txbx>
                   <wps:bodyPr rot="0" vert="horz" wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t"/>
                 </wps:wsp>
               </a:graphicData></a:graphic>
             </wp:anchor>
           </w:drawing>"#
    )
}

/// The same rect with no `position:absolute`, so it resolves to no float at
/// all — a `<w:pict>` that parses but yields no geometry. Its text box is
/// still collected inline, which is the Tier-0 placeholder behaviour.
fn unpositioned_vml_rect(label: &str) -> String {
    format!(
        r##"<w:pict>
              <v:rect style="width:120pt;height:50pt" fillcolor="#DDDDDD">
                <v:textbox><w:txbxContent>
                  <w:p><w:r><w:t>{label}</w:t></w:r></w:p>
                </w:txbxContent></v:textbox>
              </v:rect>
            </w:pict>"##
    )
}

/// §14.1.2.19 `<v:shape type="#_x0000_t75">` wrapping a `<v:imagedata>` — the
/// standard pre-DrawingML way to place an image, and the other half of what a
/// `<mc:Fallback>` carries. It reaches the page through a *different* walker
/// than the rect above, so the two need separate coverage.
fn vml_image() -> String {
    r##"<w:pict>
          <v:shape type="#_x0000_t75"
                   style="position:absolute;margin-left:60pt;margin-top:10pt;width:40pt;height:40pt">
            <v:imagedata r:id="rId7"/>
          </v:shape>
        </w:pict>"##
        .to_string()
}

/// A `wps:wsp` in a `<wp:inline>`, not a `<wp:anchor>` — a drawing this
/// renderer parses fine and still places no float, because liveness asks
/// whether the branch yields an *anchored* object.
fn inline_wps_shape() -> String {
    r#"<w:drawing>
         <wp:inline distT="0" distB="0" distL="0" distR="0">
           <wp:extent cx="1524000" cy="635000"/>
           <wp:docPr id="2" name="InlineBox"/>
           <a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape">
             <wps:wsp>
               <wps:cNvSpPr/>
               <wps:spPr>
                 <a:xfrm><a:off x="0" y="0"/><a:ext cx="1524000" cy="635000"/></a:xfrm>
                 <a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
               </wps:spPr>
             </wps:wsp>
           </a:graphicData></a:graphic>
         </wp:inline>
       </w:drawing>"#
        .to_string()
}

/// An `<mc:AlternateContent>` with one Choice declaring `requires`.
///
/// `requires="wps"` is a namespace this renderer understands, so the Choice
/// survives parsing and its *content* decides whether the branch is live.
/// An unknown token (`"x99"`) drops the whole Choice at parse per §M.2.2 —
/// the shortest honest way to write "a Choice this consumer cannot meet".
///
/// §M.1.2's content model for both branches is `drawing | pict`, which is why
/// nothing here nests an `<mc:AlternateContent>` inside another: the parser
/// rejects it, so the nesting `live_mc_branch` recurses through is pinned in
/// its own unit tests rather than from a document.
fn alternate_content(requires: &str, choice: &str, fallback: &str) -> String {
    format!(
        r#"<mc:AlternateContent>
             <mc:Choice Requires="{requires}">{choice}</mc:Choice>
             <mc:Fallback>{fallback}</mc:Fallback>
           </mc:AlternateContent>"#
    )
}

fn layout(run_body: &str) -> Vec<LayoutedPage> {
    let doc = dxpdf::docx::parse(&make_docx(&document(run_body))).expect("parse");
    dxpdf::render::resolve_and_layout(doc).1
}

/// How many times `label` is drawn anywhere on the document.
fn text_count(pages: &[LayoutedPage], label: &str) -> usize {
    pages
        .iter()
        .flat_map(|p| p.commands.iter())
        .filter(|c| matches!(c, DrawCommand::Text { text, .. } if text.trim() == label))
        .count()
}

/// How many filled/stroked paths are drawn — one per shape that reached the
/// page as geometry.
fn path_count(pages: &[LayoutedPage]) -> usize {
    pages
        .iter()
        .flat_map(|p| p.commands.iter())
        .filter(|c| matches!(c, DrawCommand::Path { .. }))
        .count()
}

/// How many placed images are drawn.
fn image_count(pages: &[LayoutedPage]) -> usize {
    pages
        .iter()
        .flat_map(|p| p.commands.iter())
        .filter(|c| matches!(c, DrawCommand::Image { .. }))
        .count()
}

// --- The control: a fallback is a `w:pict`, and must render like one ---------

/// The same VML rect outside any `<mc:AlternateContent>`. Whatever branch
/// selection does, it cannot do *better* than this — so this is the number the
/// fallback cases are measured against, not a hand-written constant.
#[test]
fn a_bare_vml_rect_draws_its_geometry_and_its_text_once() {
    let pages = layout(&vml_rect("Fallback"));

    assert_eq!(path_count(&pages), 1, "the rect's own geometry");
    assert_eq!(text_count(&pages, "Fallback"), 1, "its text box, once");
}

// --- No drawable Choice: the Fallback is live, for every walker -------------

/// The bug. A Choice this renderer cannot meet, and a Fallback holding VML.
/// The VML float walkers used to answer `AlternateContent(_) => {}`, so the
/// rect's geometry reached nothing at all — while the inline collector
/// happily emitted its text, leaving a floating label over blank page.
#[test]
fn an_unmeetable_choice_hands_the_whole_fallback_over() {
    let pages = layout(&alternate_content("x99", "", &vml_rect("Fallback")));

    assert_eq!(
        path_count(&pages),
        1,
        "the Fallback's rect is the live branch — its geometry must be drawn",
    );
    assert_eq!(
        text_count(&pages, "Fallback"),
        1,
        "and its text exactly once"
    );
}

/// The same, for the other VML float walker. Shape geometry and image
/// placement are extracted by two separate passes over the fallback, and both
/// used to skip `<mc:AlternateContent>` outright; a test that only covered the
/// rect would leave half the fix unpinned.
#[test]
fn an_unmeetable_choice_hands_over_a_vml_image_too() {
    let bare = layout(&vml_image());
    let wrapped = layout(&alternate_content("x99", "", &vml_image()));

    assert_eq!(
        image_count(&bare),
        1,
        "precondition: the bare pict places it"
    );
    assert_eq!(
        image_count(&wrapped),
        1,
        "the Fallback's image is the live branch",
    );
}

/// …and a drawable Choice keeps that image inert, so the fix to the image
/// walker cannot reintroduce the double render on the other side.
#[test]
fn a_drawable_choice_leaves_a_vml_fallback_image_inert() {
    let pages = layout(&alternate_content(
        "wps",
        &wps_shape("Choice"),
        &vml_image(),
    ));

    assert_eq!(image_count(&pages), 0, "the Fallback's image is not live");
    assert_eq!(text_count(&pages, "Choice"), 1);
}

/// Geometry and text of one VML fallback come from *different* walkers — the
/// float extractor draws the rect, the inline collector draws its text box —
/// and that is not a double render, it is the two halves of one. Pinned
/// against the bare-pict control so a future "the fallback has one owner"
/// refactor cannot quietly drop either half.
#[test]
fn a_live_fallback_renders_exactly_like_the_bare_pict_it_is() {
    let bare = layout(&vml_rect("Fallback"));
    let wrapped = layout(&alternate_content("x99", "", &vml_rect("Fallback")));

    assert_eq!(path_count(&wrapped), path_count(&bare));
    assert_eq!(
        text_count(&wrapped, "Fallback"),
        text_count(&bare, "Fallback"),
    );
}

/// A Choice whose namespace we *do* understand but whose content yields no
/// anchored object is still not drawable — liveness is a question about
/// content, not about the `Requires` attribute. Widening it to a namespace
/// check would light up this Choice and strand the Fallback's rect again.
#[test]
fn a_meetable_choice_with_nothing_drawable_still_yields_to_the_fallback() {
    let pages = layout(&alternate_content(
        "wps",
        &inline_wps_shape(),
        &vml_rect("Fallback"),
    ));

    assert_eq!(text_count(&pages, "Fallback"), 1, "the Fallback is live");
    assert!(
        path_count(&pages) >= 1,
        "and its rect reaches the page as geometry",
    );
}

/// A Fallback with no anchored content at all keeps its long-standing
/// behaviour: the text reaches the page inline, at the host paragraph, as the
/// Tier-0 placeholder it has always been. Nothing about extending the float
/// walkers may cost this case its text.
#[test]
fn a_fallback_with_no_anchored_content_still_reaches_the_page_inline() {
    let pages = layout(&alternate_content(
        "x99",
        "",
        &unpositioned_vml_rect("Fallback"),
    ));

    assert_eq!(path_count(&pages), 0, "there is no geometry to draw");
    assert_eq!(text_count(&pages, "Fallback"), 1, "but the text survives");
}

// --- A drawable Choice: the Fallback is inert, for every walker -------------

/// The invariant a naive fix breaks. Both branches describe one rectangle;
/// giving the VML walkers a Fallback arm without asking about the Choice draws
/// it twice — once from each branch.
#[test]
fn a_drawable_choice_leaves_its_vml_fallback_completely_inert() {
    let pages = layout(&alternate_content(
        "wps",
        &wps_shape("Choice"),
        &vml_rect("Fallback"),
    ));

    assert_eq!(path_count(&pages), 1, "one rectangle, drawn once");
    assert_eq!(text_count(&pages, "Choice"), 1, "the Choice's text body");
    assert_eq!(
        text_count(&pages, "Fallback"),
        0,
        "the Fallback describes the same object — none of it is live",
    );
}

// --- Neither branch drawable ------------------------------------------------

#[test]
fn neither_branch_drawable_draws_nothing_and_does_not_panic() {
    let pages = layout(&alternate_content("x99", "", ""));

    assert_eq!(path_count(&pages), 0);
    assert!(!pages.is_empty(), "the document still lays out");
}

/// No `<mc:Fallback>` element at all — the `Neither` case, reached through a
/// different route than an empty one.
#[test]
fn an_unmeetable_choice_with_no_fallback_draws_nothing() {
    let pages = layout(
        r#"<mc:AlternateContent><mc:Choice Requires="x99"></mc:Choice></mc:AlternateContent>"#,
    );

    assert_eq!(path_count(&pages), 0);
    assert!(!pages.is_empty());
}