From d6bdc8ee6d9323c57878078a94f44dbd37147e36 Mon Sep 17 00:00:00 2001 From: L4XB Date: Wed, 16 Sep 2026 21:24:22 +0200 Subject: [PATCH 1/2] 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() From fcf78d0d39be419cc6f56850c298febb4c3557cc Mon Sep 17 00:00:00 2001 From: L4XB Date: Fri, 18 Sep 2026 10:11:40 +0200 Subject: [PATCH 2/2] test(skills): write the CSV fixture with an explicit newline Path.write_text without a newline argument translates "\n" to os.linesep, so on Windows the fixture held CRLF while both assertions required LF, and the test failed there although the parser was doing the right thing: preserving the bytes it was given. The fixture now writes with newline="" so its contents are the same on every platform, and a companion case covers a genuinely CRLF file end to end, from the reader through the workbook cell. --- tests/test_builtin_office_skills.py | 31 ++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/tests/test_builtin_office_skills.py b/tests/test_builtin_office_skills.py index 52fb694e58..437dcc106c 100644 --- a/tests/test_builtin_office_skills.py +++ b/tests/test_builtin_office_skills.py @@ -79,7 +79,11 @@ def test_spreadsheet_skill_keeps_a_line_break_inside_a_quoted_cell( ) -> 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") + # newline="" so the file holds the bytes written on every platform: the default + # translation would make this CRLF on Windows, and the reader preserves what it finds. + source.write_text( + 'ID,Note\n1,"line one\nline two"\n2,plain\n', encoding="utf-8", newline="" + ) output = tmp_path / "notes.xlsx" inspected = _run_script(SPREADSHEET_SCRIPTS / "inspect_workbook.py", source) @@ -99,6 +103,31 @@ def test_spreadsheet_skill_keeps_a_line_break_inside_a_quoted_cell( workbook.close() +def test_spreadsheet_skill_keeps_a_crlf_line_break_inside_a_quoted_cell( + tmp_path: Path, +) -> None: + """A CRLF break inside a cell is part of the value too, and is not translated.""" + source = tmp_path / "crlf.csv" + source.write_text( + 'ID,Note\r\n1,"line one\r\nline two"\r\n2,plain\r\n', encoding="utf-8", newline="" + ) + output = tmp_path / "crlf.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 + assert json.loads(inspected.stdout)["sample"] == [ + ["ID", "Note"], + ["1", "line one\r\nline two"], + ["2", "plain"], + ] + workbook = load_workbook(output) + assert workbook.active["B2"].value == "line one\r\nline two" + workbook.close() + + def test_spreadsheet_skill_rejects_broken_formula_reference(tmp_path: Path) -> None: path = tmp_path / "broken.xlsx" workbook = Workbook()