From aa59d1a0419512d17cc823ebccb78a52a221a7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Sat, 16 May 2026 12:22:51 +0200 Subject: [PATCH] ODS writer: drop duplicate XML declaration; harden sheet-name sanitiser. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LibreOffice rejected the generated `.ods` with `Format error at 2,39 in content.xml`. Root cause: `pugi::format_no_declaration` suppresses only the *implicit* declaration auto-added at save time — the explicit `node_declaration` I had appended to the document still got serialised, on top of a manual `` string prepend in the output. Two declarations back-to-back, invalid XML. Fix: let pugixml emit the explicit declaration node, drop the manual prepend. Also harden the sheet-name sanitiser in the export action: ODS / Excel also forbid `< > &` in raw cell or table names, so the default connection name `bp/J20 <-> payload1/P0` made content.xml entity- escape `<` to `<`, which a few viewers handle but Excel rejects. Clip to 31 chars too (Excel's hard limit) so multi-name connections don't blow up the open. Verified by `soffice --headless --convert-to csv` round-tripping the output without errors. Co-Authored-By: Claude Opus 4.7 --- src/imports/ods_writer.cpp | 13 ++++++++----- src/tui/commands_export.cpp | 12 +++++++++--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/imports/ods_writer.cpp b/src/imports/ods_writer.cpp index 91faa3e..66d459d 100644 --- a/src/imports/ods_writer.cpp +++ b/src/imports/ods_writer.cpp @@ -73,11 +73,14 @@ std::string build_content_xml(const std::vector &sheets) { } std::ostringstream oss; - doc.save(oss, " ", pugi::format_no_declaration | pugi::format_raw); - // pugi's `format_no_declaration` drops the `` line; we want - // it. Re-emit explicitly to control encoding. - return std::string(R"()" "\n") - + oss.str(); + // We added the declaration node explicitly above, so pugi will emit + // it. `format_no_declaration` only suppresses *implicit* injection; + // setting it here would leave the explicit node in place and silently + // duplicate would-be auto-emission elsewhere. Keep format_raw to + // avoid pretty-print overhead — the file is read by software, not + // humans. + doc.save(oss, "", pugi::format_raw); + return oss.str(); } // Minimum META-INF/manifest.xml that satisfies the spec. diff --git a/src/tui/commands_export.cpp b/src/tui/commands_export.cpp index 2fe7f20..7c4a3f7 100644 --- a/src/tui/commands_export.cpp +++ b/src/tui/commands_export.cpp @@ -98,12 +98,18 @@ void Tui::RegisterExportCommands() { int total = 0; for (auto &ckv : *sys->connections()) { Connection *c = ckv.second; - // Sheet names: Excel rejects /\?*:[] characters - // (LibreOffice tolerates them but stays portable). + // Sheet names: Excel rejects /\?*:[] characters, + // ODS forbids < > & in raw cell/table names + // (they'd need entity-escaping but a few viewers + // choke on them). Sanitise to underscores. std::string sname = c->name; for (char &ch : sname) if (ch == '/' || ch == '\\' || ch == '?' || ch == '*' - || ch == ':' || ch == '[' || ch == ']') ch = '_'; + || ch == ':' || ch == '[' || ch == ']' + || ch == '<' || ch == '>' || ch == '&') ch = '_'; + // Sheet names also can't exceed 31 chars in + // Excel; clip aggressively to stay portable. + if (sname.size() > 31) sname = sname.substr(0, 31); OdsSheet *s = w.add_sheet(sname); const char *hdr[] = { "transform",