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("a<")); +}