diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..549962d1a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 517113d0f..409e27935 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -18,6 +18,7 @@ mod smooth_roundtrip; mod trendline_roundtrip; mod xls_tests; mod xlsb_tests; +mod zip_order; use std::io::Write; diff --git a/crates/xlsx/src/tests/zip_order.rs b/crates/xlsx/src/tests/zip_order.rs new file mode 100644 index 000000000..939b268d7 --- /dev/null +++ b/crates/xlsx/src/tests/zip_order.rs @@ -0,0 +1,33 @@ +//! libmagic (PHP `finfo`, `file`) detects an OOXML package from the first zip entries (#1090). + +use gridcraft_model::Workbook; + +use crate::write_xlsx; + +fn find(hay: &[u8], needle: &[u8]) -> Option { + hay.windows(needle.len()).position(|w| w == needle) +} + +#[test] +fn package_starts_with_content_types_rels_and_workbook_before_any_large_part() { + let mut wb = Workbook::new(); + // A long, hard-to-compress description makes docProps/core.xml big. + wb.props.description = + (0..20_000).map(|i| char::from(b'a' + (i * 7 % 26) as u8)).chain((0..20_000).map(|i| char::from(b'0' + (i * i % 10) as u8))).collect(); + let bytes = write_xlsx(&wb).unwrap(); + + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let names: Vec = (0..zip.len()).filter_map(|i| zip.by_index(i).ok().map(|f| f.name().to_string())).collect(); + assert_eq!(names.first().map(String::as_str), Some("[Content_Types].xml"), "{names:?}"); + assert_eq!(names.get(1).map(String::as_str), Some("_rels/.rels"), "{names:?}"); + assert_eq!(names.get(2).map(String::as_str), Some("xl/_rels/workbook.xml.rels"), "{names:?}"); + assert_eq!(names.get(3).map(String::as_str), Some("xl/workbook.xml"), "{names:?}"); + + // No data descriptor (general-purpose bit 3) on the first local header, entry name at offset 30. + assert_eq!(bytes.get(..4), Some(&b"PK\x03\x04"[..])); + assert_eq!(bytes.get(6).map(|b| b & 0x08), Some(0)); + assert_eq!(bytes.get(30..49), Some(&b"[Content_Types].xml"[..])); + // libmagic looks for an `xl/` entry name within the first few KB: keep it well inside that. + let at = find(&bytes, b"xl/_rels/workbook.xml.rels"); + assert!(at.is_some_and(|p| p < 2048), "xl/ entry at {at:?}"); +} diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index 06761f01d..c580f0a32 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -452,13 +452,15 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result, IoError> { ct.push_str(""); let mut zip = ZipOut::new(); zip.add("[Content_Types].xml", ct.as_bytes())?; - // Conventional order: rels, docProps, workbook, the rest. + // Excel's order: rels, workbook rels, workbook, the rest, document properties last. libmagic (PHP + // `finfo`, `file`) recognises an OOXML package only when an `xl/` entry name appears within the + // first few KB, so no user-sized part (document properties, a long description) may precede it. let rank = |n: &str| match n { "_rels/.rels" => 0, - n if n.starts_with("docProps/") => 1, + "xl/_rels/workbook.xml.rels" => 1, "xl/workbook.xml" => 2, - "xl/_rels/workbook.xml.rels" => 3, - _ => 4, + n if n.starts_with("docProps/") => 4, + _ => 3, }; let mut parts = std::mem::take(&mut out.parts); parts.sort_by_key(|(n, _)| rank(n));