Skip to main content

rust_docx/
zip.rs

1use std::collections::HashMap;
2use std::sync::LazyLock;
3
4use miniz_oxide::inflate::decompress_to_vec_with_limit;
5
6use crate::package::{OoxmlPackage, OoxmlPart};
7
8const ZIP_LOCAL_FILE_HEADER_SIGNATURE: u32 = 0x0403_4b50;
9const ZIP_CENTRAL_DIRECTORY_SIGNATURE: u32 = 0x0201_4b50;
10const ZIP_EOCD_SIGNATURE: u32 = 0x0605_4b50;
11const UTF8_FLAG: u16 = 0x0800;
12const STORE_COMPRESSION: u16 = 0;
13const DEFLATE_COMPRESSION: u16 = 8;
14
15const DEFAULT_DOCUMENT_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
16<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
17  <w:body>
18    <w:p><w:r><w:t/></w:r></w:p>
19    <w:sectPr>
20      <w:pgSz w:w="12240" w:h="15840"/>
21      <w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/>
22    </w:sectPr>
23  </w:body>
24</w:document>"#;
25
26const DEFAULT_CONTENT_TYPES_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
27<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
28  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
29  <Default Extension="xml" ContentType="application/xml"/>
30  <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
31</Types>"#;
32
33const DEFAULT_ROOT_RELS_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
34<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
35  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
36</Relationships>"#;
37
38const DEFAULT_DOCUMENT_RELS_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
39<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"/>"#;
40
41struct ZipEntry {
42    name: String,
43    compression_method: u16,
44    compressed_size: u32,
45    uncompressed_size: u32,
46    local_header_offset: u32,
47}
48
49fn read_u16_le(bytes: &[u8], offset: usize) -> u16 {
50    u16::from_le_bytes([bytes[offset], bytes[offset + 1]])
51}
52
53fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
54    u32::from_le_bytes([
55        bytes[offset],
56        bytes[offset + 1],
57        bytes[offset + 2],
58        bytes[offset + 3],
59    ])
60}
61
62fn write_u16_le(out: &mut [u8], offset: usize, value: u16) {
63    out[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
64}
65
66fn write_u32_le(out: &mut [u8], offset: usize, value: u32) {
67    out[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
68}
69
70fn find_eocd_offset(bytes: &[u8]) -> Result<usize, String> {
71    const MINIMUM_LENGTH: usize = 22;
72    if bytes.len() < MINIMUM_LENGTH {
73        return Err("Invalid DOCX ZIP: too small to contain EOCD".to_string());
74    }
75
76    let max_comment_length = 0xffff;
77    let search_start = bytes
78        .len()
79        .saturating_sub(MINIMUM_LENGTH + max_comment_length);
80
81    let mut index = bytes.len().saturating_sub(MINIMUM_LENGTH);
82    while index >= search_start {
83        if read_u32_le(bytes, index) == ZIP_EOCD_SIGNATURE {
84            return Ok(index);
85        }
86        if index == 0 {
87            break;
88        }
89        index -= 1;
90    }
91
92    Err("Invalid DOCX ZIP: end of central directory not found".to_string())
93}
94
95fn parse_central_directory(bytes: &[u8]) -> Result<Vec<ZipEntry>, String> {
96    let eocd_offset = find_eocd_offset(bytes)?;
97    let total_entries = read_u16_le(bytes, eocd_offset + 10) as usize;
98    let central_directory_offset = read_u32_le(bytes, eocd_offset + 16) as usize;
99
100    let mut entries = Vec::with_capacity(total_entries);
101    let mut cursor = central_directory_offset;
102
103    for _ in 0..total_entries {
104        if cursor.checked_add(46).is_none_or(|end| end > bytes.len())
105            || read_u32_le(bytes, cursor) != ZIP_CENTRAL_DIRECTORY_SIGNATURE
106        {
107            return Err("Invalid DOCX ZIP: malformed central directory".to_string());
108        }
109
110        let compression_method = read_u16_le(bytes, cursor + 10);
111        let compressed_size = read_u32_le(bytes, cursor + 20);
112        let uncompressed_size = read_u32_le(bytes, cursor + 24);
113        let file_name_length = read_u16_le(bytes, cursor + 28) as usize;
114        let extra_field_length = read_u16_le(bytes, cursor + 30) as usize;
115        let file_comment_length = read_u16_le(bytes, cursor + 32) as usize;
116        let local_header_offset = read_u32_le(bytes, cursor + 42);
117
118        let file_name_start = cursor + 46;
119        let file_name_end = file_name_start + file_name_length;
120        if file_name_end
121            .checked_add(extra_field_length + file_comment_length)
122            .is_none_or(|end| end > bytes.len())
123        {
124            return Err("Invalid DOCX ZIP: truncated directory entry".into());
125        }
126        let name = String::from_utf8_lossy(&bytes[file_name_start..file_name_end]).into_owned();
127
128        entries.push(ZipEntry {
129            name,
130            compression_method,
131            compressed_size,
132            uncompressed_size,
133            local_header_offset,
134        });
135
136        cursor = file_name_end + extra_field_length + file_comment_length;
137    }
138
139    Ok(entries)
140}
141
142fn inflate_raw(data: &[u8]) -> Result<Vec<u8>, String> {
143    decompress_to_vec_with_limit(data, usize::MAX)
144        .map_err(|error| format!("Failed to inflate DOCX ZIP entry: {error:?}"))
145}
146
147#[derive(Default)]
148struct ZipInflater {
149    #[cfg(all(feature = "native-deflate", not(target_arch = "wasm32")))]
150    native: libdeflater::Decompressor,
151}
152
153impl ZipInflater {
154    fn decode(&mut self, compressed: &[u8], output: &mut [u8]) -> Option<usize> {
155        #[cfg(all(feature = "native-deflate", not(target_arch = "wasm32")))]
156        if let Ok(length) = self.native.deflate_decompress(compressed, output) {
157            return Some(length);
158        }
159        let (decoded, status) = zlib_rs::decompress_slice(
160            output,
161            compressed,
162            zlib_rs::InflateConfig { window_bits: -15 },
163        );
164        (status == zlib_rs::ReturnCode::Ok).then_some(decoded.len())
165    }
166}
167
168fn extract_entry_data(
169    bytes: &[u8],
170    entry: &ZipEntry,
171    inflater: &mut ZipInflater,
172) -> Result<Vec<u8>, String> {
173    let local_header_offset = entry.local_header_offset as usize;
174
175    if local_header_offset
176        .checked_add(30)
177        .is_none_or(|end| end > bytes.len())
178        || read_u32_le(bytes, local_header_offset) != ZIP_LOCAL_FILE_HEADER_SIGNATURE
179    {
180        return Err(format!(
181            "Invalid DOCX ZIP: bad local header for {}",
182            entry.name
183        ));
184    }
185
186    let file_name_length = read_u16_le(bytes, local_header_offset + 26) as usize;
187    let extra_field_length = read_u16_le(bytes, local_header_offset + 28) as usize;
188    let payload_offset = local_header_offset + 30 + file_name_length + extra_field_length;
189    let payload_end = payload_offset
190        .checked_add(entry.compressed_size as usize)
191        .ok_or("Invalid DOCX ZIP: entry size overflow")?;
192    let compressed_data = bytes
193        .get(payload_offset..payload_end)
194        .ok_or("Invalid DOCX ZIP: truncated entry")?;
195
196    if entry.compression_method == STORE_COMPRESSION {
197        return Ok(compressed_data.to_vec());
198    }
199
200    if entry.compression_method == DEFLATE_COMPRESSION {
201        let expected = entry.uncompressed_size as usize;
202        if expected <= 64 * 1024 * 1024
203            && expected <= compressed_data.len().saturating_mul(512).max(1024 * 1024)
204        {
205            let mut output = vec![0; expected];
206            if let Some(length) = inflater.decode(compressed_data, &mut output) {
207                output.truncate(length);
208                return Ok(output);
209            }
210        }
211        return inflate_raw(compressed_data);
212    }
213
214    Err(format!(
215        "Unsupported DOCX ZIP compression method {} for {}",
216        entry.compression_method, entry.name
217    ))
218}
219
220pub fn is_text_part(part_name: &str) -> bool {
221    if part_name == "[Content_Types].xml" {
222        return true;
223    }
224
225    match part_name.rsplit_once('.') {
226        Some((_, extension)) => {
227            extension.eq_ignore_ascii_case("xml")
228                || extension.eq_ignore_ascii_case("rels")
229                || extension.eq_ignore_ascii_case("txt")
230        }
231        None => false,
232    }
233}
234
235fn decode_xml_part(bytes: Vec<u8>) -> Result<String, String> {
236    let big_endian = bytes.starts_with(&[0xfe, 0xff]) || bytes.starts_with(&[0, b'<', 0, b'?']);
237    let little_endian = bytes.starts_with(&[0xff, 0xfe]) || bytes.starts_with(&[b'<', 0, b'?', 0]);
238    if big_endian || little_endian {
239        let skip = if bytes.starts_with(&[0xfe, 0xff]) || bytes.starts_with(&[0xff, 0xfe]) {
240            2
241        } else {
242            0
243        };
244        let body = &bytes[skip..];
245        if body.len() % 2 != 0 {
246            return Err("Invalid UTF-16 XML byte length".into());
247        }
248        let units = body
249            .chunks_exact(2)
250            .map(|pair| {
251                if big_endian {
252                    u16::from_be_bytes([pair[0], pair[1]])
253                } else {
254                    u16::from_le_bytes([pair[0], pair[1]])
255                }
256            })
257            .collect::<Vec<_>>();
258        let xml =
259            String::from_utf16(&units).map_err(|error| format!("Invalid UTF-16 XML: {error}"))?;
260        static ENCODING: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
261            regex::Regex::new(
262                r#"(?i)encoding[\x09-\x0D\x20]*=[\x09-\x0D\x20]*["']UTF-16(?:LE|BE)?["']"#,
263            )
264            .unwrap()
265        });
266        let encoding = &*ENCODING;
267        return Ok(encoding.replace(&xml, "encoding=\"UTF-8\"").into_owned());
268    }
269    String::from_utf8(bytes)
270        .map_err(|error| format!("Invalid or unsupported XML encoding: {error}"))
271}
272
273#[cfg(not(target_arch = "wasm32"))]
274fn extract_entry_group(
275    bytes: &[u8],
276    entries: &[ZipEntry],
277    group: &[usize],
278) -> Vec<(usize, Result<Vec<u8>, String>)> {
279    let mut inflater = ZipInflater::default();
280    group.iter().map(|&index| {
281        (index, extract_entry_data(bytes, &entries[index], &mut inflater))
282    }).collect()
283}
284
285#[cfg(not(target_arch = "wasm32"))]
286#[cold]
287fn parallel_entry_data(
288    bytes: &[u8],
289    entries: &[ZipEntry],
290) -> Option<Vec<Option<Result<Vec<u8>, String>>>> {
291    if bytes.len() < 16 * 1024
292        || entries.iter().map(|entry| u64::from(entry.uncompressed_size)).sum::<u64>() < 64 * 1024
293        || entries.iter().filter(|entry| {
294            !entry.name.ends_with('/')
295                && entry.compression_method == DEFLATE_COMPRESSION
296                && entry.uncompressed_size >= 16 * 1024
297        }).take(2).count() < 2
298    {
299        return None;
300    }
301    let pool = crate::parse::worker_pool()?;
302    let mut jobs: Vec<_> = entries.iter().enumerate()
303        .filter_map(|(index, entry)| (!entry.name.ends_with('/')).then_some(index))
304        .collect();
305    jobs.sort_unstable_by_key(|&index| std::cmp::Reverse(entries[index].uncompressed_size));
306    let mut groups: [Vec<usize>; 4] = std::array::from_fn(|_| Vec::new());
307    let mut weights = [0u64; 4];
308    for index in jobs {
309        let group = (0..4).min_by_key(|&group| weights[group]).unwrap();
310        weights[group] += u64::from(entries[index].uncompressed_size);
311        groups[group].push(index);
312    }
313    let mut outputs: [Option<Vec<(usize, Result<Vec<u8>, String>)>>; 4] = std::array::from_fn(|_| None);
314    let (caller, workers) = outputs.split_first_mut().unwrap();
315    pool.in_place_scope(|scope| {
316        for (slot, group) in workers.iter_mut().zip(&groups[1..]) {
317            scope.spawn(move |_| *slot = Some(extract_entry_group(bytes, entries, group)));
318        }
319        *caller = Some(extract_entry_group(bytes, entries, &groups[0]));
320    });
321    let mut result: Vec<_> = (0..entries.len()).map(|_| None).collect();
322    for group in outputs.into_iter().flatten() {
323        for (index, value) in group {
324            result[index] = Some(value);
325        }
326    }
327    Some(result)
328}
329
330#[cfg(not(target_arch = "wasm32"))]
331#[cold]
332fn package_from_parallel_entries(
333    entries: Vec<ZipEntry>,
334    results: Vec<Option<Result<Vec<u8>, String>>>,
335) -> Result<OoxmlPackage, String> {
336    let mut parts = HashMap::new();
337    let mut binary_assets = HashMap::new();
338    for (entry, data) in entries.into_iter().zip(results) {
339        if entry.name.ends_with('/') {
340            continue;
341        }
342        let data = data.expect("scheduled ZIP entry")?;
343        if is_text_part(&entry.name) {
344            parts.insert(entry.name.clone(), OoxmlPart {
345                name: entry.name,
346                content: decode_xml_part(data)?,
347            });
348        } else {
349            binary_assets.insert(entry.name, data);
350        }
351    }
352    let package = OoxmlPackage { parts, binary_assets, warnings: Vec::new() };
353    let main_part = crate::package::main_document_part_name(&package);
354    if !package.parts.contains_key(&main_part) {
355        return Err(format!("Invalid DOCX: missing {main_part}"));
356    }
357    Ok(package)
358}
359
360pub fn parse_docx(bytes: &[u8]) -> Result<OoxmlPackage, String> {
361    if bytes.is_empty() {
362        return Err("DOCX input cannot be empty".to_string());
363    }
364
365    let entries = parse_central_directory(bytes)?;
366    #[cfg(not(target_arch = "wasm32"))]
367    if bytes.len() >= 16 * 1024 {
368        if let Some(parallel) = parallel_entry_data(bytes, &entries) {
369            return package_from_parallel_entries(entries, parallel);
370        }
371    }
372    let mut parts = HashMap::new();
373    let mut binary_assets = HashMap::new();
374    let mut inflater = ZipInflater::default();
375
376    for entry in entries {
377        if entry.name.ends_with('/') {
378            continue;
379        }
380
381        let file_bytes = extract_entry_data(bytes, &entry, &mut inflater)?;
382
383        if is_text_part(&entry.name) {
384            parts.insert(
385                entry.name.clone(),
386                OoxmlPart {
387                    name: entry.name,
388                    content: decode_xml_part(file_bytes)?,
389                },
390            );
391            continue;
392        }
393
394        binary_assets.insert(entry.name, file_bytes);
395    }
396
397    let package = OoxmlPackage {
398        parts,
399        binary_assets,
400        warnings: Vec::new(),
401    };
402    let main_part = crate::package::main_document_part_name(&package);
403    if !package.parts.contains_key(&main_part) {
404        return Err(format!("Invalid DOCX: missing {main_part}"));
405    }
406    Ok(package)
407}
408
409fn create_crc32_table() -> [u32; 256] {
410    let mut table = [0u32; 256];
411    for index in 0..256 {
412        let mut value = index as u32;
413        for _ in 0..8 {
414            value = if value & 1 == 1 {
415                (value >> 1) ^ 0xedb8_8320
416            } else {
417                value >> 1
418            };
419        }
420        table[index] = value;
421    }
422    table
423}
424
425static CRC32_TABLE: LazyLock<[u32; 256]> = LazyLock::new(create_crc32_table);
426
427pub(crate) fn crc32(bytes: &[u8]) -> u32 {
428    let mut value = 0xffff_ffffu32;
429    for byte in bytes {
430        value = (value >> 8) ^ CRC32_TABLE[((value ^ u32::from(*byte)) & 0xff) as usize];
431    }
432    value ^ 0xffff_ffff
433}
434
435#[cfg(test)]
436fn concat_bytes(chunks: &[&[u8]]) -> Vec<u8> {
437    let total_length: usize = chunks.iter().map(|chunk| chunk.len()).sum();
438    let mut output = Vec::with_capacity(total_length);
439    for chunk in chunks {
440        output.extend_from_slice(chunk);
441    }
442    output
443}
444
445/// Media formats that are already compressed; DEFLATE rarely shrinks them.
446const PRECOMPRESSED_EXTENSIONS: &[&str] = &[
447    "7z", "avi", "avif", "docm", "docx", "gif", "gz", "hdp", "heic", "heif", "jpe", "jpeg", "jpg",
448    "jxr", "m4a", "m4v", "mov", "mp3", "mp4", "png", "pptm", "pptx", "wdp", "webp", "wma", "wmv",
449    "xlsm", "xlsx", "zip",
450];
451
452fn is_precompressed(name: &str) -> bool {
453    name.rsplit_once('.').is_some_and(|(_, extension)| {
454        PRECOMPRESSED_EXTENSIONS
455            .iter()
456            .any(|known| known.eq_ignore_ascii_case(extension))
457    })
458}
459
460fn zip_u32(value: usize, what: &str) -> Result<u32, String> {
461    u32::try_from(value).map_err(|_| format!("{what} exceeds the 4 GiB non-ZIP64 limit"))
462}
463
464/// Writes the package as a ZIP archive.
465///
466/// Entries are written straight into one output buffer in name order. XML and
467/// uncompressed binaries use DEFLATE; already-compressed media, and any entry
468/// that DEFLATE does not shrink, are stored.
469pub fn package_to_bytes(pkg: &OoxmlPackage) -> Result<Vec<u8>, String> {
470    let mut entries: Vec<(&str, &[u8])> = pkg
471        .parts
472        .values()
473        .map(|part| (part.name.as_str(), part.content.as_bytes()))
474        .chain(
475            pkg.binary_assets
476                .iter()
477                .map(|(name, data)| (name.as_str(), data.as_slice())),
478        )
479        .collect();
480
481    entries.sort_by(|left, right| left.0.cmp(right.0));
482
483    if entries.is_empty() {
484        return Err("Cannot create DOCX ZIP from an empty package".to_string());
485    }
486
487    if entries.len() > 0xffff {
488        return Err("Too many ZIP entries for non-ZIP64 writer".to_string());
489    }
490
491    let stored_media: usize = entries
492        .iter()
493        .filter(|(name, _)| is_precompressed(name))
494        .map(|(name, payload)| 30 + name.len() + payload.len())
495        .sum();
496    let mut output = Vec::with_capacity(stored_media + 64 * 1024);
497    let mut central_directory = Vec::with_capacity(entries.iter().map(|(name, _)| 46 + name.len()).sum());
498
499    for (name, payload) in &entries {
500        let name_bytes = name.as_bytes();
501        let crc = crc32(payload);
502        let offset = zip_u32(output.len(), "ZIP archive")?;
503        let uncompressed_size = zip_u32(payload.len(), name)?;
504
505        let deflated = (!is_precompressed(name) && !payload.is_empty())
506            .then(|| miniz_oxide::deflate::compress_to_vec(payload, 6))
507            .filter(|deflated| deflated.len() < payload.len());
508        let (method, data): (u16, &[u8]) = match &deflated {
509            Some(deflated) => (DEFLATE_COMPRESSION, deflated),
510            None => (STORE_COMPRESSION, payload),
511        };
512        let compressed_size = zip_u32(data.len(), name)?;
513
514        let mut local_header = [0u8; 30];
515        write_u32_le(&mut local_header, 0, ZIP_LOCAL_FILE_HEADER_SIGNATURE);
516        write_u16_le(&mut local_header, 4, 20);
517        write_u16_le(&mut local_header, 6, UTF8_FLAG);
518        write_u16_le(&mut local_header, 8, method);
519        write_u32_le(&mut local_header, 14, crc);
520        write_u32_le(&mut local_header, 18, compressed_size);
521        write_u32_le(&mut local_header, 22, uncompressed_size);
522        write_u16_le(&mut local_header, 26, name_bytes.len() as u16);
523        output.extend_from_slice(&local_header);
524        output.extend_from_slice(name_bytes);
525        output.extend_from_slice(data);
526        drop(deflated);
527
528        let mut central_header = [0u8; 46];
529        write_u32_le(&mut central_header, 0, ZIP_CENTRAL_DIRECTORY_SIGNATURE);
530        write_u16_le(&mut central_header, 4, 20);
531        write_u16_le(&mut central_header, 6, 20);
532        write_u16_le(&mut central_header, 8, UTF8_FLAG);
533        write_u16_le(&mut central_header, 10, method);
534        write_u32_le(&mut central_header, 16, crc);
535        write_u32_le(&mut central_header, 20, compressed_size);
536        write_u32_le(&mut central_header, 24, uncompressed_size);
537        write_u16_le(&mut central_header, 28, name_bytes.len() as u16);
538        write_u32_le(&mut central_header, 42, offset);
539        central_directory.extend_from_slice(&central_header);
540        central_directory.extend_from_slice(name_bytes);
541    }
542
543    let central_directory_offset = zip_u32(output.len(), "ZIP archive")?;
544    let central_directory_size = zip_u32(central_directory.len(), "ZIP central directory")?;
545    output.extend_from_slice(&central_directory);
546    drop(central_directory);
547
548    let mut eocd = [0u8; 22];
549    write_u32_le(&mut eocd, 0, ZIP_EOCD_SIGNATURE);
550    write_u16_le(&mut eocd, 8, entries.len() as u16);
551    write_u16_le(&mut eocd, 10, entries.len() as u16);
552    write_u32_le(&mut eocd, 12, central_directory_size);
553    write_u32_le(&mut eocd, 16, central_directory_offset);
554    output.extend_from_slice(&eocd);
555    Ok(output)
556}
557
558pub fn create_minimal_docx_package(document_xml: Option<&str>) -> OoxmlPackage {
559    let document_xml = document_xml.unwrap_or(DEFAULT_DOCUMENT_XML);
560
561    let mut parts = HashMap::new();
562    parts.insert(
563        "[Content_Types].xml".to_string(),
564        OoxmlPart {
565            name: "[Content_Types].xml".to_string(),
566            content: DEFAULT_CONTENT_TYPES_XML.to_string(),
567        },
568    );
569    parts.insert(
570        "_rels/.rels".to_string(),
571        OoxmlPart {
572            name: "_rels/.rels".to_string(),
573            content: DEFAULT_ROOT_RELS_XML.to_string(),
574        },
575    );
576    parts.insert(
577        "word/document.xml".to_string(),
578        OoxmlPart {
579            name: "word/document.xml".to_string(),
580            content: document_xml.to_string(),
581        },
582    );
583    parts.insert(
584        "word/_rels/document.xml.rels".to_string(),
585        OoxmlPart {
586            name: "word/_rels/document.xml.rels".to_string(),
587            content: DEFAULT_DOCUMENT_RELS_XML.to_string(),
588        },
589    );
590
591    OoxmlPackage {
592        parts,
593        binary_assets: HashMap::new(),
594        warnings: Vec::new(),
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use crate::package::get_part;
602
603    const DOCUMENT_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Hello DOCX</w:t></w:r></w:p></w:body></w:document>"#;
604
605    struct ZipSourceEntry<'a> {
606        name: &'a str,
607        content: &'a str,
608        deflate: bool,
609    }
610
611    fn create_zip(entries: &[ZipSourceEntry<'_>]) -> Vec<u8> {
612        let mut local_chunks: Vec<Vec<u8>> = Vec::new();
613        let mut central_chunks: Vec<Vec<u8>> = Vec::new();
614        let mut offset = 0u32;
615
616        for entry in entries {
617            let name_bytes = entry.name.as_bytes();
618            let uncompressed = entry.content.as_bytes();
619            let compression_method = if entry.deflate {
620                DEFLATE_COMPRESSION
621            } else {
622                STORE_COMPRESSION
623            };
624            let compressed = if entry.deflate {
625                miniz_oxide::deflate::compress_to_vec(uncompressed, 6)
626            } else {
627                uncompressed.to_vec()
628            };
629            let crc = crc32(uncompressed);
630
631            let mut local = vec![0u8; 30 + name_bytes.len() + compressed.len()];
632            write_u32_le(&mut local, 0, ZIP_LOCAL_FILE_HEADER_SIGNATURE);
633            write_u16_le(&mut local, 4, 20);
634            write_u16_le(&mut local, 6, UTF8_FLAG);
635            write_u16_le(&mut local, 8, compression_method);
636            write_u16_le(&mut local, 10, 0);
637            write_u16_le(&mut local, 12, 0);
638            write_u32_le(&mut local, 14, crc);
639            write_u32_le(&mut local, 18, compressed.len() as u32);
640            write_u32_le(&mut local, 22, uncompressed.len() as u32);
641            write_u16_le(&mut local, 26, name_bytes.len() as u16);
642            write_u16_le(&mut local, 28, 0);
643            local[30..30 + name_bytes.len()].copy_from_slice(name_bytes);
644            local[30 + name_bytes.len()..].copy_from_slice(&compressed);
645            local_chunks.push(local.clone());
646
647            let mut central = vec![0u8; 46 + name_bytes.len()];
648            write_u32_le(&mut central, 0, ZIP_CENTRAL_DIRECTORY_SIGNATURE);
649            write_u16_le(&mut central, 4, 20);
650            write_u16_le(&mut central, 6, 20);
651            write_u16_le(&mut central, 8, UTF8_FLAG);
652            write_u16_le(&mut central, 10, compression_method);
653            write_u16_le(&mut central, 12, 0);
654            write_u16_le(&mut central, 14, 0);
655            write_u32_le(&mut central, 16, crc);
656            write_u32_le(&mut central, 20, compressed.len() as u32);
657            write_u32_le(&mut central, 24, uncompressed.len() as u32);
658            write_u16_le(&mut central, 28, name_bytes.len() as u16);
659            write_u16_le(&mut central, 30, 0);
660            write_u16_le(&mut central, 32, 0);
661            write_u16_le(&mut central, 34, 0);
662            write_u16_le(&mut central, 36, 0);
663            write_u32_le(&mut central, 38, 0);
664            write_u32_le(&mut central, 42, offset);
665            central[46..].copy_from_slice(name_bytes);
666            central_chunks.push(central);
667
668            offset += local.len() as u32;
669        }
670
671        let local_data: Vec<u8> = local_chunks.into_iter().flatten().collect();
672        let central_data: Vec<u8> = central_chunks.into_iter().flatten().collect();
673
674        let mut eocd = vec![0u8; 22];
675        write_u32_le(&mut eocd, 0, ZIP_EOCD_SIGNATURE);
676        write_u16_le(&mut eocd, 4, 0);
677        write_u16_le(&mut eocd, 6, 0);
678        write_u16_le(&mut eocd, 8, entries.len() as u16);
679        write_u16_le(&mut eocd, 10, entries.len() as u16);
680        write_u32_le(&mut eocd, 12, central_data.len() as u32);
681        write_u32_le(&mut eocd, 16, local_data.len() as u32);
682        write_u16_le(&mut eocd, 20, 0);
683
684        concat_bytes(&[&local_data, &central_data, &eocd])
685    }
686
687    #[test]
688    fn throws_on_empty_input() {
689        let error = parse_docx(&[]).unwrap_err();
690        assert!(error.contains("cannot be empty"));
691    }
692
693    #[test]
694    fn parses_stored_zip_entries() {
695        let zip = create_zip(&[
696            ZipSourceEntry {
697                name: "[Content_Types].xml",
698                content: "<Types/>",
699                deflate: false,
700            },
701            ZipSourceEntry {
702                name: "word/document.xml",
703                content: DOCUMENT_XML,
704                deflate: false,
705            },
706        ]);
707        let pkg = parse_docx(&zip).expect("parse stored zip");
708
709        assert!(get_part(&pkg, "word/document.xml")
710            .expect("document part")
711            .content
712            .contains("Hello DOCX"));
713    }
714
715    #[test]
716    fn parses_deflated_zip_entries() {
717        let zip = create_zip(&[
718            ZipSourceEntry {
719                name: "[Content_Types].xml",
720                content: "<Types/>",
721                deflate: true,
722            },
723            ZipSourceEntry {
724                name: "word/document.xml",
725                content: DOCUMENT_XML,
726                deflate: true,
727            },
728        ]);
729        let pkg = parse_docx(&zip).expect("parse deflated zip");
730
731        assert!(get_part(&pkg, "word/document.xml")
732            .expect("document part")
733            .content
734            .contains("Hello DOCX"));
735    }
736
737    #[test]
738    fn writes_and_reparses_docx_zip_archives() {
739        let source = create_minimal_docx_package(Some(DOCUMENT_XML));
740        let zip = package_to_bytes(&source).expect("package to bytes");
741        let reparsed = parse_docx(&zip).expect("reparse docx");
742
743        assert!(reparsed
744            .parts
745            .get("word/document.xml")
746            .expect("document part")
747            .content
748            .contains("Hello DOCX"));
749    }
750    #[test]
751    fn writes_deflated_xml_and_stored_media_that_reparse_exactly() {
752        let mut source = create_minimal_docx_package(Some(DOCUMENT_XML));
753        let photo: Vec<u8> = (0..200_000u32).map(|value| (value.wrapping_mul(2_654_435_761) >> 24) as u8).collect();
754        let metafile = vec![7u8; 100_000];
755        source.binary_assets.insert("word/media/photo.png".into(), photo.clone());
756        source.binary_assets.insert("word/media/figure.emf".into(), metafile.clone());
757        source.binary_assets.insert("word/media/empty.bin".into(), Vec::new());
758        let zip = package_to_bytes(&source).expect("package to bytes");
759
760        let entries = parse_central_directory(&zip).expect("central directory");
761        let method = |name: &str| entries.iter().find(|entry| entry.name == name).map(|entry| entry.compression_method);
762        assert_eq!(method("word/document.xml"), Some(DEFLATE_COMPRESSION));
763        assert_eq!(method("word/media/figure.emf"), Some(DEFLATE_COMPRESSION));
764        assert_eq!(method("word/media/photo.png"), Some(STORE_COMPRESSION));
765        assert_eq!(method("word/media/empty.bin"), Some(STORE_COMPRESSION));
766        assert!(zip.len() < photo.len() + 20_000, "metafile and XML should be compressed");
767
768        let reparsed = parse_docx(&zip).expect("reparse docx");
769        assert_eq!(reparsed.binary_assets["word/media/photo.png"], photo);
770        assert_eq!(reparsed.binary_assets["word/media/figure.emf"], metafile);
771        assert!(reparsed.binary_assets["word/media/empty.bin"].is_empty());
772        assert_eq!(reparsed.parts["word/document.xml"].content, source.parts["word/document.xml"].content);
773    }
774
775    #[cfg(not(target_arch = "wasm32"))]
776    #[test]
777    fn parallel_entries_preserve_payloads_duplicates_and_error_order() {
778        let mut state = 123456789u32;
779        let payload: String = (0..512 * 1024).map(|_| {
780            state ^= state << 13;
781            state ^= state >> 17;
782            state ^= state << 5;
783            char::from(32 + (state % 95) as u8)
784        }).collect();
785        let mut zip = create_zip(&[
786            ZipSourceEntry { name: "word/document.xml", content: DOCUMENT_XML, deflate: false },
787            ZipSourceEntry { name: "payload/a.bin", content: &payload, deflate: true },
788            ZipSourceEntry { name: "payload/b.bin", content: &payload, deflate: true },
789            ZipSourceEntry { name: "payload/a.bin", content: "replacement", deflate: true },
790            ZipSourceEntry { name: "payload/", content: "", deflate: false },
791        ]);
792        let entries = parse_central_directory(&zip).unwrap();
793        let mut inflater = ZipInflater::default();
794        let expected: Vec<_> = entries.iter().map(|entry| {
795            (!entry.name.ends_with('/')).then(|| extract_entry_data(&zip, entry, &mut inflater))
796        }).collect();
797        assert_eq!(parallel_entry_data(&zip, &entries).unwrap(), expected);
798        let parsed = parse_docx(&zip).unwrap();
799        assert_eq!(parsed.binary_assets["payload/a.bin"], b"replacement");
800        assert_eq!(parsed.binary_assets["payload/b.bin"], payload.as_bytes());
801        assert!(!parsed.binary_assets.contains_key("payload/"));
802        let main = entries[0].local_header_offset as usize;
803        let content = main + 30 + read_u16_le(&zip, main + 26) as usize;
804        zip[content] = 0xff;
805        let later = entries[2].local_header_offset as usize;
806        zip[later..later + 4].fill(0);
807        assert!(parse_docx(&zip).unwrap_err().starts_with("Invalid or unsupported XML encoding:"));
808    }
809
810}