From ebe6db2d0c4a7d970f34e258dadd555bc120b3b2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 26 Mar 2026 19:50:23 +0700 Subject: [PATCH] fix: XLSX export producing corrupted files in Excel (#464) --- CHANGELOG.md | 4 ++++ Plugins/XLSXExportPlugin/XLSXWriter.swift | 18 +++++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2efb5c3b..c901775d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- XLSX export producing corrupted files that Excel cannot open (#464) + ### Added - Enum/set picker support for PostgreSQL custom enums, ClickHouse Enum8/Enum16, and DuckDB ENUM types diff --git a/Plugins/XLSXExportPlugin/XLSXWriter.swift b/Plugins/XLSXExportPlugin/XLSXWriter.swift index 0e45be1f..89370cdc 100644 --- a/Plugins/XLSXExportPlugin/XLSXWriter.swift +++ b/Plugins/XLSXExportPlugin/XLSXWriter.swift @@ -265,7 +265,9 @@ final class XLSXWriter { d.appendUTF8("") d.appendUTF8("") d.appendUTF8("") - d.appendUTF8("") + d.appendUTF8("") + d.appendUTF8("") + d.appendUTF8("") return d } @@ -309,7 +311,9 @@ private extension Data { } ?? self.append(contentsOf: string.utf8) } - /// Append XML-escaped text directly to Data without creating intermediate Strings + /// Append XML-escaped text directly to Data without creating intermediate Strings. + /// Strips XML 1.0 illegal control characters (0x00–0x08, 0x0B, 0x0C, 0x0E–0x1F) + /// that can appear in binary/hex database columns and would produce malformed XML. mutating func appendXMLEscaped(_ text: String) { for byte in text.utf8 { switch byte { @@ -323,6 +327,10 @@ private extension Data { append(contentsOf: [0x26, 0x71, 0x75, 0x6F, 0x74, 0x3B]) // " case 0x27: // ' append(contentsOf: [0x26, 0x61, 0x70, 0x6F, 0x73, 0x3B]) // ' + case 0x09, 0x0A, 0x0D: // Tab, LF, CR — allowed in XML 1.0 + append(byte) + case 0x00...0x08, 0x0B, 0x0C, 0x0E...0x1F: // Illegal XML 1.0 control chars + break // Strip silently default: append(byte) } @@ -360,7 +368,7 @@ private enum ZipBuilder { // Local file header output.append(contentsOf: [0x50, 0x4B, 0x03, 0x04]) // Signature - output.appendUInt16(20) // Version needed + output.appendUInt16(10) // Version needed (1.0 for store) output.appendUInt16(0) // Flags output.appendUInt16(0) // Compression: stored output.appendUInt16(0) // Mod time @@ -375,8 +383,8 @@ private enum ZipBuilder { // Central directory entry centralDirectory.append(contentsOf: [0x50, 0x4B, 0x01, 0x02]) - centralDirectory.appendUInt16(20) - centralDirectory.appendUInt16(20) + centralDirectory.appendUInt16(20) // Version made by (2.0) + centralDirectory.appendUInt16(10) // Version needed (1.0 for store) centralDirectory.appendUInt16(0) centralDirectory.appendUInt16(0) centralDirectory.appendUInt16(0)