diff --git a/CMakeLists.txt b/CMakeLists.txt
index d37d8ee5e..a11aab175 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -130,6 +130,7 @@ set(ODR_SOURCE_FILES
"src/odr/internal/crypto/crypto_argon2.cpp"
"src/odr/internal/crypto/crypto_util.cpp"
+ "src/odr/internal/csv/csv_document.cpp"
"src/odr/internal/csv/csv_file.cpp"
"src/odr/internal/csv/csv_util.cpp"
diff --git a/android/src/androidTest/java/app/opendocument/core/DocumentTest.kt b/android/src/androidTest/java/app/opendocument/core/DocumentTest.kt
index 0bc5f8b91..230132485 100644
--- a/android/src/androidTest/java/app/opendocument/core/DocumentTest.kt
+++ b/android/src/androidTest/java/app/opendocument/core/DocumentTest.kt
@@ -116,9 +116,10 @@ class DocumentTest {
val file = Odr.open(TestFiles.csvFile(tempDir).toString())
val service = Html.translate(file, cache.toString(), HtmlConfig())
+ // a spreadsheet: a document view plus one per sheet
val views = service.listViews()
- assertEquals(1, views.size)
- assertTrue(views[0].writeHtml().html.contains("alpha"))
+ assertEquals(2, views.size)
+ assertTrue(views.last().writeHtml().html.contains("alpha"))
}
private fun read(path: Path): String = String(Files.readAllBytes(path), StandardCharsets.UTF_8)
diff --git a/apple/tests/OdrCoreTests.swift b/apple/tests/OdrCoreTests.swift
index f8a5522eb..5facde83e 100644
--- a/apple/tests/OdrCoreTests.swift
+++ b/apple/tests/OdrCoreTests.swift
@@ -71,8 +71,9 @@ final class DecodeTests: XCTestCase {
}
}
- /// A csv is a *text* file to odrcore, not a document — it has no element
- /// tree. Worth pinning: the extension suggests otherwise.
+ /// A csv is a *text* file to odrcore, not a document file. It does have an
+ /// element tree — `CsvFile.document()` is a second view of the same bytes —
+ /// but that does not move it out of `FileCategory.text`.
func testCsvIsTextRatherThanADocument() throws {
let path = try write("a,b\n1,2\n", as: "table.csv")
let decoded = try DecodedFile.decode(path: path)
diff --git a/jni/tests/app/opendocument/core/HtmlTest.java b/jni/tests/app/opendocument/core/HtmlTest.java
index f9a187276..b5b9bc86c 100644
--- a/jni/tests/app/opendocument/core/HtmlTest.java
+++ b/jni/tests/app/opendocument/core/HtmlTest.java
@@ -92,9 +92,11 @@ void translateText() throws IOException {
@Test
void translateCsv() throws IOException {
Html html = translateOffline(TestFiles.csvFile(tempDir));
- assertEquals(1, html.pages().size());
- String content = Files.readString(Path.of(html.pages().get(0).path));
+ // a spreadsheet: a document view plus one per sheet
+ assertEquals(2, html.pages().size());
+ String content = Files.readString(Path.of(html.pages().get(1).path));
assertTrue(content.contains("alpha"));
+ assertTrue(content.contains("
impl)
: DecodedFile(impl), m_impl{std::move(impl)} {}
+Document CsvFile::document() const { return Document(m_impl->document()); }
+
CsvOptions CsvFile::options() const { return m_impl->options(); }
CsvFile CsvFile::with_options(const CsvOptions &options) const {
diff --git a/src/odr/file.hpp b/src/odr/file.hpp
index b5065b4c7..0b8b77361 100644
--- a/src/odr/file.hpp
+++ b/src/odr/file.hpp
@@ -435,6 +435,11 @@ class CsvFile final : public DecodedFile {
explicit CsvFile(std::shared_ptr);
+ /// @brief The csv as a one-sheet spreadsheet. The other view of the same
+ /// bytes — a csv stays a text file, so @ref TextFile::text still works.
+ /// @throws UnsupportedTextEncoding if the encoding cannot be decoded.
+ [[nodiscard]] Document document() const;
+
/// @brief The options in use, every field resolved.
[[nodiscard]] CsvOptions options() const;
diff --git a/src/odr/html.cpp b/src/odr/html.cpp
index ab1a49db3..49f5c15d8 100644
--- a/src/odr/html.cpp
+++ b/src/odr/html.cpp
@@ -212,6 +212,11 @@ void HtmlResource::write_resource(std::ostream &os) const {
HtmlService html::translate(const DecodedFile &file, const HtmlConfig &config,
const Logger &logger) {
+ // before the text branch: a csv is a text file, and rendering one as a line
+ // list rather than a table is never what a viewer wants
+ if (file.is_csv_file()) {
+ return translate(file.as_csv_file().document(), config, logger);
+ }
if (file.is_text_file()) {
return translate(file.as_text_file(), config, logger);
}
diff --git a/src/odr/internal/abstract/file.hpp b/src/odr/internal/abstract/file.hpp
index 0dbe81bd9..8ab8cef43 100644
--- a/src/odr/internal/abstract/file.hpp
+++ b/src/odr/internal/abstract/file.hpp
@@ -67,16 +67,6 @@ class TextFile : public DecodedFile {
[[nodiscard]] virtual TextEncoding encoding() const noexcept = 0;
};
-class CsvFile : public TextFile {
-public:
- /// The options in use, every field resolved.
- [[nodiscard]] virtual CsvOptions options() const = 0;
-
- /// The same file read with @p options.
- [[nodiscard]] virtual std::shared_ptr
- with_options(const CsvOptions &options) const = 0;
-};
-
class ImageFile : public DecodedFile {
public:
[[nodiscard]] FileCategory file_category() const noexcept final {
@@ -106,6 +96,22 @@ class DocumentFile : public DecodedFile {
[[nodiscard]] virtual std::shared_ptr document() const = 0;
};
+/// A csv is a text file that can also be loaded as a document — a one-sheet
+/// spreadsheet. It stays in @ref FileCategory::text, so reading it as text
+/// needs no reopening; @ref document is the other view of the same bytes.
+class CsvFile : public TextFile {
+public:
+ /// The options in use, every field resolved.
+ [[nodiscard]] virtual CsvOptions options() const = 0;
+
+ /// The same file read with @p options.
+ [[nodiscard]] virtual std::shared_ptr
+ with_options(const CsvOptions &options) const = 0;
+
+ /// The csv as a one-sheet spreadsheet.
+ [[nodiscard]] virtual std::shared_ptr document() const = 0;
+};
+
class PdfFile : public DecodedFile {
public:
[[nodiscard]] FileType file_type() const noexcept final {
diff --git a/src/odr/internal/csv/AGENTS.md b/src/odr/internal/csv/AGENTS.md
new file mode 100644
index 000000000..131e68d73
--- /dev/null
+++ b/src/odr/internal/csv/AGENTS.md
@@ -0,0 +1,71 @@
+# AGENTS.md — `internal/csv`
+
+Read the root [`AGENTS.md`](../../../../AGENTS.md) first. This file covers what
+csv does differently, and why.
+
+## Cells are not elements
+
+The root `AGENTS.md` prescribes an `ElementRegistry`: a flat `std::vector` of
+elements, id = index + 1. **Csv does not use one**, deliberately.
+
+A registry costs an entry per element. A sheet has one per *cell*, so a file
+with a million rows would cost millions of entries before a single one is
+looked at — and `spreadsheet_limit` means the renderer will ask for ten
+thousand rows of them at most.
+
+`ElementIdentifier` is a `std::uint64_t`, which is room to spare:
+
+```
+63..61 kind root | sheet | cell | text
+60..24 row 37 bits
+23..0 column 24 bits
+```
+
+So an id *is* the coordinate, and the adapter decodes rather than looks up.
+`null_element_id` is zero, so no kind may be.
+
+The consequence to respect: **a sheet's cells are not reachable by walking**.
+`element_first_child` of a sheet is `null_element_id`; cells come from
+`SheetAdapter::sheet_cell(column, row)`, which is how the renderer asks for
+them anyway (`html/document_element.cpp:163`).
+
+## Everything goes through `cell` and `dimensions`
+
+`CsvDocument` holds the whole file decoded, and the adapter never touches that
+storage — it calls `cell(column, row)` and `dimensions()`. That is the seam a
+later streaming implementation needs: an index and a window can move in behind
+those two without the adapter noticing.
+
+## Detection rejects; the parser does not
+
+Two jobs, two places, and mixing them is the mistake this module already made
+once.
+
+- `probe` is detection. It scores a bounded sample and may say "not a csv".
+ Its rules — at least two columns, no dangling quote in a complete file — are
+ *heuristics for recognising an unknown file*, not statements about validity.
+- `RecordReader` is parsing. Given a separator it is total: ragged rows, one
+ column, an empty file and a truncated quoted field all read as some csv.
+
+So a one-column csv is perfectly legitimate and `CsvOptions{.separator = ','}`
+reads it. `NoCsvFile` is a detection failure only. An incoherent dialect — a
+separator equal to the quote, a line break as a separator — is
+`std::invalid_argument`, a caller mistake rather than bad input.
+
+## A csv is a text file that also loads as a document
+
+`FileCategory::text`, `DocumentType::spreadsheet` — so `is_text_file()` is true
+for a csv and `is_document_file()` is false. `abstract::CsvFile` derives from
+`abstract::TextFile`, and `CsvFile::document()` is the *other* view of the same
+bytes rather than the only one: `TextFile::text()` keeps working, so reading a
+csv as text needs no reopening. Opening it as `FileType::text_file` stays the
+escape hatch when detection was wrong about it being a csv at all.
+
+The one thing that costs: `html::translate` has to test `is_csv_file()` ahead of
+its text branch (`html.cpp:215`), because a csv answers `is_text_file()` and a
+line list is never what a viewer wants from a table.
+
+Text has to be UTF-8 by the time it reaches a cell: `Text::content()` returns
+`std::string` and every binding treats it as UTF-8. That is why an encoding
+`internal/encoding` cannot decode has no document at all, while the *text*
+rendering path stays open to it.
diff --git a/src/odr/internal/csv/csv_document.cpp b/src/odr/internal/csv/csv_document.cpp
new file mode 100644
index 000000000..031e52da8
--- /dev/null
+++ b/src/odr/internal/csv/csv_document.cpp
@@ -0,0 +1,317 @@
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+namespace odr::internal::csv {
+
+namespace {
+
+/// An id is the coordinate, not a registry index; `null_element_id` is zero,
+/// so no kind may be. See `AGENTS.md`.
+enum class Kind : std::uint64_t {
+ root = 1,
+ sheet = 2,
+ cell = 3,
+ text = 4,
+};
+
+constexpr std::uint64_t kind_shift = 61;
+constexpr std::uint64_t row_shift = 24;
+constexpr std::uint64_t column_mask = (std::uint64_t{1} << row_shift) - 1;
+constexpr std::uint64_t row_mask =
+ (std::uint64_t{1} << (kind_shift - row_shift)) - 1;
+
+ElementIdentifier make_id(const Kind kind, const std::uint32_t column = 0,
+ const std::uint32_t row = 0) {
+ return static_cast(kind) << kind_shift |
+ (static_cast(row) & row_mask) << row_shift |
+ (static_cast(column) & column_mask);
+}
+
+Kind kind_of(const ElementIdentifier element_id) {
+ return static_cast(element_id >> kind_shift);
+}
+
+std::uint32_t row_of(const ElementIdentifier element_id) {
+ return static_cast(element_id >> row_shift & row_mask);
+}
+
+std::uint32_t column_of(const ElementIdentifier element_id) {
+ return static_cast(element_id & column_mask);
+}
+
+class ElementAdapter final : public abstract::ElementAdapter,
+ public abstract::SheetAdapter,
+ public abstract::SheetCellAdapter,
+ public abstract::TextAdapter {
+public:
+ explicit ElementAdapter(const CsvDocument &document)
+ : m_document{&document} {}
+
+ [[nodiscard]] ElementType
+ element_type(const ElementIdentifier element_id) const override {
+ switch (kind_of(element_id)) {
+ case Kind::root:
+ return ElementType::root;
+ case Kind::sheet:
+ return ElementType::sheet;
+ case Kind::cell:
+ return ElementType::sheet_cell;
+ case Kind::text:
+ return ElementType::text;
+ default:
+ return ElementType::none;
+ }
+ }
+
+ [[nodiscard]] ElementIdentifier
+ element_parent(const ElementIdentifier element_id) const override {
+ switch (kind_of(element_id)) {
+ case Kind::sheet:
+ return make_id(Kind::root);
+ case Kind::cell:
+ return make_id(Kind::sheet);
+ case Kind::text:
+ return make_id(Kind::cell, column_of(element_id), row_of(element_id));
+ default:
+ return null_element_id;
+ }
+ }
+
+ [[nodiscard]] ElementIdentifier
+ element_first_child(const ElementIdentifier element_id) const override {
+ switch (kind_of(element_id)) {
+ case Kind::root:
+ return make_id(Kind::sheet);
+ case Kind::cell:
+ return make_id(Kind::text, column_of(element_id), row_of(element_id));
+ default:
+ // a sheet's cells are reached by coordinate, not by walking
+ return null_element_id;
+ }
+ }
+
+ [[nodiscard]] ElementIdentifier
+ element_last_child(const ElementIdentifier element_id) const override {
+ return element_first_child(element_id);
+ }
+
+ [[nodiscard]] ElementIdentifier element_previous_sibling(
+ [[maybe_unused]] const ElementIdentifier element_id) const override {
+ return null_element_id;
+ }
+ [[nodiscard]] ElementIdentifier element_next_sibling(
+ [[maybe_unused]] const ElementIdentifier element_id) const override {
+ return null_element_id;
+ }
+
+ [[nodiscard]] bool element_is_unique(
+ [[maybe_unused]] const ElementIdentifier element_id) const override {
+ return true;
+ }
+ [[nodiscard]] bool element_is_self_locatable(
+ [[maybe_unused]] const ElementIdentifier element_id) const override {
+ return true;
+ }
+ [[nodiscard]] bool element_is_editable(
+ [[maybe_unused]] const ElementIdentifier element_id) const override {
+ return false;
+ }
+ [[nodiscard]] DocumentPath
+ element_document_path(const ElementIdentifier element_id) const override {
+ return util::document::extract_path(*this, element_id, null_element_id);
+ }
+ [[nodiscard]] ElementIdentifier
+ element_navigate_path(const ElementIdentifier element_id,
+ const DocumentPath &path) const override {
+ return util::document::navigate_path(*this, element_id, path);
+ }
+
+ [[nodiscard]] const SheetAdapter *
+ sheet_adapter(const ElementIdentifier element_id) const override {
+ return kind_of(element_id) == Kind::sheet ? this : nullptr;
+ }
+ [[nodiscard]] const SheetCellAdapter *
+ sheet_cell_adapter(const ElementIdentifier element_id) const override {
+ return kind_of(element_id) == Kind::cell ? this : nullptr;
+ }
+ [[nodiscard]] const TextAdapter *
+ text_adapter(const ElementIdentifier element_id) const override {
+ return kind_of(element_id) == Kind::text ? this : nullptr;
+ }
+
+ // SheetAdapter
+
+ [[nodiscard]] std::string sheet_name(
+ [[maybe_unused]] const ElementIdentifier element_id) const override {
+ return "csv";
+ }
+ [[nodiscard]] TableDimensions sheet_dimensions(
+ [[maybe_unused]] const ElementIdentifier element_id) const override {
+ return m_document->dimensions();
+ }
+ [[nodiscard]] TableDimensions
+ sheet_content([[maybe_unused]] const ElementIdentifier element_id,
+ const std::optional range) const override {
+ const TableDimensions dimensions = m_document->dimensions();
+ if (!range.has_value()) {
+ return dimensions;
+ }
+ return {std::min(dimensions.rows, range->rows),
+ std::min(dimensions.columns, range->columns)};
+ }
+ [[nodiscard]] ElementIdentifier
+ sheet_cell([[maybe_unused]] const ElementIdentifier element_id,
+ const std::uint32_t column,
+ const std::uint32_t row) const override {
+ return make_id(Kind::cell, column, row);
+ }
+ [[nodiscard]] ElementIdentifier sheet_first_shape(
+ [[maybe_unused]] const ElementIdentifier element_id) const override {
+ return null_element_id;
+ }
+ [[nodiscard]] TableStyle sheet_style(
+ [[maybe_unused]] const ElementIdentifier element_id) const override {
+ return {};
+ }
+ [[nodiscard]] TableColumnStyle sheet_column_style(
+ [[maybe_unused]] const ElementIdentifier element_id,
+ [[maybe_unused]] const std::uint32_t column) const override {
+ return {};
+ }
+ [[nodiscard]] TableRowStyle
+ sheet_row_style([[maybe_unused]] const ElementIdentifier element_id,
+ [[maybe_unused]] const std::uint32_t row) const override {
+ return {};
+ }
+ [[nodiscard]] TableCellStyle
+ sheet_cell_style([[maybe_unused]] const ElementIdentifier element_id,
+ [[maybe_unused]] const std::uint32_t column,
+ [[maybe_unused]] const std::uint32_t row) const override {
+ return {};
+ }
+
+ // SheetCellAdapter
+
+ [[nodiscard]] TablePosition
+ sheet_cell_position(const ElementIdentifier element_id) const override {
+ // `TablePosition` is (column, row); `TableDimensions` is (rows, columns)
+ return TablePosition(column_of(element_id), row_of(element_id));
+ }
+ [[nodiscard]] bool sheet_cell_is_covered(
+ [[maybe_unused]] const ElementIdentifier element_id) const override {
+ return false;
+ }
+ [[nodiscard]] TableDimensions sheet_cell_span(
+ [[maybe_unused]] const ElementIdentifier element_id) const override {
+ return {1, 1};
+ }
+ [[nodiscard]] ValueType sheet_cell_value_type(
+ [[maybe_unused]] const ElementIdentifier element_id) const override {
+ return ValueType::string;
+ }
+
+ // TextAdapter
+
+ [[nodiscard]] std::string
+ text_content(const ElementIdentifier element_id) const override {
+ return std::string(
+ m_document->cell(column_of(element_id), row_of(element_id)));
+ }
+ void
+ text_set_content([[maybe_unused]] const ElementIdentifier element_id,
+ [[maybe_unused]] const std::string &text) const override {
+ throw UnsupportedOperation();
+ }
+ [[nodiscard]] TextStyle text_style(
+ [[maybe_unused]] const ElementIdentifier element_id) const override {
+ return {};
+ }
+
+private:
+ const CsvDocument *m_document;
+};
+
+} // namespace
+
+CsvDocument::CsvDocument(const abstract::File &file,
+ const TextEncoding encoding, const Dialect dialect,
+ const bool skip_first_line)
+ : internal::Document(FileType::comma_separated_values,
+ DocumentType::spreadsheet, nullptr) {
+ const std::unique_ptr in = file.stream();
+ std::string text = encoding::to_utf8(util::stream::read(*in), encoding);
+
+ std::string_view remainder = text;
+ if (skip_first_line) {
+ if (const std::size_t body = remainder.find_first_not_of(
+ "\r\n", remainder.find_first_of("\r\n"));
+ body != std::string_view::npos) {
+ remainder = remainder.substr(body);
+ } else {
+ remainder = {};
+ }
+ }
+
+ RecordReader reader(remainder, dialect);
+ std::vector fields;
+ std::uint32_t columns = 0;
+ while (reader.read(fields)) {
+ columns = std::max(columns, static_cast(fields.size()));
+ m_rows.push_back(fields);
+ }
+
+ m_dimensions = {static_cast(m_rows.size()), columns};
+
+ m_root_element = make_id(Kind::root);
+ m_element_adapter = std::make_unique(*this);
+}
+
+bool CsvDocument::is_editable() const noexcept { return false; }
+
+bool CsvDocument::is_savable(
+ [[maybe_unused]] const bool encrypted) const noexcept {
+ return false;
+}
+
+void CsvDocument::save([[maybe_unused]] const Path &path) const {
+ throw UnsupportedOperation();
+}
+
+void CsvDocument::save([[maybe_unused]] const Path &path,
+ [[maybe_unused]] const char *password) const {
+ throw UnsupportedOperation();
+}
+
+std::string_view CsvDocument::cell(const std::uint32_t column,
+ const std::uint32_t row) const {
+ if (row >= m_rows.size()) {
+ return {};
+ }
+ // the sheet is rectangular even where the file is not
+ const std::vector &fields = m_rows[row];
+ if (column >= fields.size()) {
+ return {};
+ }
+ return fields[column];
+}
+
+TableDimensions CsvDocument::dimensions() const noexcept {
+ return m_dimensions;
+}
+
+} // namespace odr::internal::csv
diff --git a/src/odr/internal/csv/csv_document.hpp b/src/odr/internal/csv/csv_document.hpp
new file mode 100644
index 000000000..e878e65ed
--- /dev/null
+++ b/src/odr/internal/csv/csv_document.hpp
@@ -0,0 +1,46 @@
+#pragma once
+
+#include
+#include
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+namespace odr::internal::abstract {
+class File;
+}
+
+namespace odr::internal::csv {
+
+/// A csv as a one-sheet spreadsheet.
+///
+/// Cells are not registry elements; an id encodes the coordinate. The whole
+/// file is held decoded and reached only through @ref cell and @ref dimensions,
+/// so what is behind those can change. See `AGENTS.md`.
+class CsvDocument final : public internal::Document {
+public:
+ CsvDocument(const abstract::File &file, TextEncoding encoding,
+ Dialect dialect, bool skip_first_line);
+
+ [[nodiscard]] bool is_editable() const noexcept override;
+ [[nodiscard]] bool is_savable(bool encrypted) const noexcept override;
+
+ void save(const Path &path) const override;
+ void save(const Path &path, const char *password) const override;
+
+ /// The cell's text, empty where a row stops short.
+ [[nodiscard]] std::string_view cell(std::uint32_t column,
+ std::uint32_t row) const;
+ [[nodiscard]] TableDimensions dimensions() const noexcept;
+
+private:
+ std::vector> m_rows;
+ TableDimensions m_dimensions;
+};
+
+} // namespace odr::internal::csv
diff --git a/src/odr/internal/csv/csv_file.cpp b/src/odr/internal/csv/csv_file.cpp
index 6fb7d6b7e..5ee04b87f 100644
--- a/src/odr/internal/csv/csv_file.cpp
+++ b/src/odr/internal/csv/csv_file.cpp
@@ -1,7 +1,9 @@
#include
#include
+#include
+#include
#include
#include
@@ -75,7 +77,17 @@ FileMeta CsvFile::file_meta() const noexcept {
return result;
}
-bool CsvFile::is_decodable() const noexcept { return false; }
+bool CsvFile::is_decodable() const noexcept {
+ return text_encoding_is_decodable(encoding());
+}
+
+std::shared_ptr CsvFile::document() const {
+ if (!is_decodable()) {
+ throw UnsupportedTextEncoding(encoding());
+ }
+ return std::make_shared(*m_file->file(), encoding(), m_dialect,
+ m_separator_directive);
+}
TextEncoding CsvFile::encoding() const noexcept { return m_file->encoding(); }
@@ -87,7 +99,8 @@ CsvOptions CsvFile::options() const {
std::shared_ptr
CsvFile::with_options(const CsvOptions &options) const {
- return std::make_shared(m_file->file(), options);
+ return std::static_pointer_cast(
+ std::make_shared(m_file->file(), options));
}
Dialect CsvFile::dialect() const noexcept { return m_dialect; }
diff --git a/src/odr/internal/csv/csv_file.hpp b/src/odr/internal/csv/csv_file.hpp
index a8525e230..ef9354613 100644
--- a/src/odr/internal/csv/csv_file.hpp
+++ b/src/odr/internal/csv/csv_file.hpp
@@ -28,6 +28,8 @@ class CsvFile final : public abstract::CsvFile {
[[nodiscard]] bool is_decodable() const noexcept override;
+ [[nodiscard]] std::shared_ptr document() const override;
+
[[nodiscard]] TextEncoding encoding() const noexcept override;
[[nodiscard]] CsvOptions options() const override;
diff --git a/src/odr/internal/file_type_table.cpp b/src/odr/internal/file_type_table.cpp
index e7b1b5db0..b1c262aac 100644
--- a/src/odr/internal/file_type_table.cpp
+++ b/src/odr/internal/file_type_table.cpp
@@ -418,7 +418,7 @@ constexpr std::array table{
csv_extensions,
csv_mimetypes,
FileCategory::text,
- DocumentType::unknown,
+ DocumentType::spreadsheet,
{.detect_by_content = true, .open = true, .translate_html = true}},
Row{FileType::javascript_object_notation,
"json"sv,
diff --git a/test/src/internal/csv/csv_file_test.cpp b/test/src/internal/csv/csv_file_test.cpp
index 1aadb5997..59dc213fa 100644
--- a/test/src/internal/csv/csv_file_test.cpp
+++ b/test/src/internal/csv/csv_file_test.cpp
@@ -1,5 +1,10 @@
+#include
+#include
+#include
#include
#include
+#include
+#include
#include
@@ -10,6 +15,7 @@
#include
#include
+#include
#include
#include
@@ -221,3 +227,107 @@ TEST(CsvOptions, a_decoded_csv_is_reachable_as_one) {
EXPECT_TRUE(decoded.is_csv_file());
EXPECT_EQ(decoded.as_csv_file().options().separator, ',');
}
+
+TEST(CsvDocument, a_csv_is_a_one_sheet_spreadsheet) {
+ const CsvFile file = CsvFile::from_file(
+ File::from_memory("a,b,c\n1,2,3\n4,5,6\n"), CsvOptions{});
+
+ const Document document = file.document();
+ EXPECT_EQ(document.document_type(), DocumentType::spreadsheet);
+ const Sheet sheet = (*document.root_element().children().begin()).as_sheet();
+
+ EXPECT_EQ(sheet.dimensions().rows, 3u);
+ EXPECT_EQ(sheet.dimensions().columns, 3u);
+ EXPECT_EQ((*sheet.cell(0, 0).children().begin()).as_text().content(), "a");
+ EXPECT_EQ((*sheet.cell(2, 2).children().begin()).as_text().content(), "6");
+}
+
+/// The sheet is rectangular even where the file is not: a short row pads, a
+/// long one widens.
+TEST(CsvDocument, ragged_rows_become_a_rectangle) {
+ const CsvFile file = CsvFile::from_file(File::from_memory("a,b\n1,2,3\n4\n"),
+ CsvOptions{.separator = ','});
+
+ const Document document = file.document();
+ const Sheet sheet = (*document.root_element().children().begin()).as_sheet();
+
+ EXPECT_EQ(sheet.dimensions().rows, 3u);
+ EXPECT_EQ(sheet.dimensions().columns, 3u);
+ EXPECT_EQ((*sheet.cell(2, 0).children().begin()).as_text().content(), "");
+ EXPECT_EQ((*sheet.cell(2, 1).children().begin()).as_text().content(), "3");
+}
+
+TEST(CsvDocument, the_separator_directive_is_not_data) {
+ const CsvFile file =
+ CsvFile::from_file(File::from_memory("sep=;\na;b\n1;2\n"), CsvOptions{});
+
+ const Document document = file.document();
+ const Sheet sheet = (*document.root_element().children().begin()).as_sheet();
+
+ EXPECT_EQ(sheet.dimensions().rows, 2u);
+ EXPECT_EQ((*sheet.cell(0, 0).children().begin()).as_text().content(), "a");
+}
+
+/// Nothing can read those bytes, so nothing can probe them either — the
+/// separator has to be declared, and there is still no document at the end.
+TEST(CsvDocument, an_undecodable_encoding_has_no_document) {
+ const File bytes = File::from_memory("a,b\n1,2\n");
+
+ EXPECT_THROW(
+ (void)CsvFile::from_file(bytes, {.encoding = TextEncoding::shift_jis}),
+ NoCsvFile);
+
+ const CsvFile file = CsvFile::from_file(
+ bytes, {.encoding = TextEncoding::shift_jis, .separator = ','});
+ EXPECT_FALSE(file.is_decodable());
+ EXPECT_THROW((void)file.document(), UnsupportedTextEncoding);
+}
+
+TEST(CsvDocument, renders_as_a_table) {
+ const CsvFile file =
+ CsvFile::from_file(File::from_memory("a,b\n1,2\n"), CsvOptions{});
+
+ const HtmlService service =
+ html::translate(file.document(), "", HtmlConfig());
+ std::ostringstream out;
+ service.list_views().back().write_html(out);
+
+ EXPECT_THAT(out.str(), testing::HasSubstr("a<"));
+ EXPECT_THAT(out.str(), testing::HasSubstr(">2<"));
+}
+
+/// Cells are not reachable by walking, so the generic path machinery has to
+/// get at them the other way — through `sheet_cell`.
+TEST(CsvDocument, a_cell_path_round_trips) {
+ const CsvFile file =
+ CsvFile::from_file(File::from_memory("a,b\n1,2\n3,4\n"), CsvOptions{});
+ const Document document = file.document();
+ const Sheet sheet = (*document.root_element().children().begin()).as_sheet();
+
+ const SheetCell cell = sheet.cell(1, 2);
+ const DocumentPath path = cell.document_path();
+
+ const Element found = document.root_element().navigate_path(path);
+ EXPECT_EQ(found.type(), ElementType::sheet_cell);
+ EXPECT_EQ((*found.as_sheet_cell().children().begin()).as_text().content(),
+ "4");
+}
+
+/// The whole point: a csv handed to the renderer comes out as a table, not a
+/// line list.
+TEST(CsvDocument, translating_the_decoded_file_yields_a_table) {
+ const File bytes = File::from_memory("a,b\n1,2\n");
+ const DecodedFile decoded(bytes, FileType::comma_separated_values);
+
+ // a csv stays a text file and is rendered as a table anyway
+ EXPECT_TRUE(decoded.is_text_file());
+
+ const HtmlService service = html::translate(decoded, HtmlConfig());
+ std::ostringstream out;
+ service.list_views().back().write_html(out);
+
+ EXPECT_THAT(out.str(), testing::HasSubstr("