From d6bdc8ee6d9323c57878078a94f44dbd37147e36 Mon Sep 17 00:00:00 2001 From: L4XB Date: Wed, 16 Sep 2026 21:24:22 +0200 Subject: [PATCH] fix(skills): keep a line break that is inside a quoted CSV cell All three spreadsheet scripts read a delimited file with `csv.reader(text.splitlines(), ...)`. The reader does join a quoted cell that spans lines, but `splitlines()` has already thrown the break away, so the words on either side of it are glued together: ID,Note 1,"line one line two" inspect sample -> ['1', 'line oneline two'] csv_to_xlsx B2 -> 'line oneline two' A cell that spans lines is ordinary in an exported sheet -- an address, a description, a note -- and the conversion writes the damage into the .xlsx it produces. Read from `io.StringIO(text, newline="")` instead, which is the form the csv docs ask for and which keeps the break inside the field. --- .../spreadsheets/scripts/csv_to_xlsx.py | 6 ++++- .../spreadsheets/scripts/inspect_workbook.py | 6 ++++- .../spreadsheets/scripts/validate_workbook.py | 6 ++++- tests/test_builtin_office_skills.py | 25 +++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/csv_to_xlsx.py b/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/csv_to_xlsx.py index 0609fdace6..ee67bc462a 100644 --- a/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/csv_to_xlsx.py +++ b/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/csv_to_xlsx.py @@ -5,6 +5,7 @@ import argparse import csv +import io import re import unicodedata from datetime import date, datetime @@ -121,7 +122,10 @@ def convert_csv_to_xlsx( except csv.Error: delimiter = "\t" if input_path.suffix.lower() == ".tsv" else "," - rows = list(csv.reader(text.splitlines(), delimiter=delimiter)) + # Not text.splitlines(): the reader joins a quoted cell that spans + # lines, but the line break itself is gone with the split, so the + # words on either side of it are glued together. + rows = list(csv.reader(io.StringIO(text, newline=""), delimiter=delimiter)) if not rows or not any(rows): raise ValueError("Delimited input contains no cells.") column_count = max(len(row) for row in rows) diff --git a/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py b/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py index 11fc0e3457..54a78b125d 100644 --- a/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py +++ b/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/inspect_workbook.py @@ -5,6 +5,7 @@ import argparse import csv +import io import json import zipfile from pathlib import Path @@ -45,7 +46,10 @@ def _inspect_delimited(path: Path, sample_rows: int, sample_cols: int) -> dict: delimiter = dialect.delimiter except csv.Error: delimiter = "\t" if path.suffix.lower() == ".tsv" else "," - rows = list(csv.reader(text.splitlines(), delimiter=delimiter)) + # Not text.splitlines(): the reader joins a quoted cell that spans + # lines, but the line break itself is gone with the split, so the + # words on either side of it are glued together. + rows = list(csv.reader(io.StringIO(text, newline=""), delimiter=delimiter)) return { "kind": "delimited", "encoding": encoding, diff --git a/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/validate_workbook.py b/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/validate_workbook.py index d4bde27373..5f4fa0f494 100644 --- a/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/validate_workbook.py +++ b/astrbot/builtin_stars/astrbot/skills/spreadsheets/scripts/validate_workbook.py @@ -5,6 +5,7 @@ import argparse import csv +import io import json import zipfile from pathlib import Path @@ -42,7 +43,10 @@ def _validate_delimited(path: Path, errors: list[str], warnings: list[str]) -> d delimiter = dialect.delimiter except csv.Error: delimiter = "\t" if path.suffix.lower() == ".tsv" else "," - rows = list(csv.reader(text.splitlines(), delimiter=delimiter)) + # Not text.splitlines(): the reader joins a quoted cell that spans + # lines, but the line break itself is gone with the split, so the + # words on either side of it are glued together. + rows = list(csv.reader(io.StringIO(text, newline=""), delimiter=delimiter)) if not rows: errors.append("The file contains no rows.") return {"rows": 0, "columns": 0} diff --git a/tests/test_builtin_office_skills.py b/tests/test_builtin_office_skills.py index aae86d47c5..52fb694e58 100644 --- a/tests/test_builtin_office_skills.py +++ b/tests/test_builtin_office_skills.py @@ -74,6 +74,31 @@ def test_spreadsheet_skill_converts_inspects_and_validates_csv(tmp_path: Path) - assert json.loads(validated.stdout)["valid"] is True +def test_spreadsheet_skill_keeps_a_line_break_inside_a_quoted_cell( + tmp_path: Path, +) -> None: + """A cell may span lines, and the break is part of the value.""" + source = tmp_path / "notes.csv" + source.write_text('ID,Note\n1,"line one\nline two"\n2,plain\n', encoding="utf-8") + output = tmp_path / "notes.xlsx" + + inspected = _run_script(SPREADSHEET_SCRIPTS / "inspect_workbook.py", source) + converted = _run_script(SPREADSHEET_SCRIPTS / "csv_to_xlsx.py", source, output) + + assert inspected.returncode == 0, inspected.stderr + assert converted.returncode == 0, converted.stderr + + # "line oneline two" before this, in the sample and in the workbook. + assert json.loads(inspected.stdout)["sample"] == [ + ["ID", "Note"], + ["1", "line one\nline two"], + ["2", "plain"], + ] + workbook = load_workbook(output) + assert workbook.active["B2"].value == "line one\nline two" + workbook.close() + + def test_spreadsheet_skill_rejects_broken_formula_reference(tmp_path: Path) -> None: path = tmp_path / "broken.xlsx" workbook = Workbook()