changeset 1835:804dd0c06f0e

Merge from branch sensible-delimited-data-strings
author Chris Cannam
date Mon, 06 Apr 2020 13:55:44 +0100
parents 7c92c644db20 (current diff) 735b0ccd3f4a (diff)
children bfdf0f7f9448 1b688ab5f1b3
files
diffstat 25 files changed, 365 insertions(+), 269 deletions(-) [+]
line wrap: on
line diff
--- a/base/Event.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/base/Event.h	Mon Apr 06 13:55:44 2020 +0100
@@ -366,9 +366,9 @@
         return n;
     }
 
-    QString getDelimitedDataHeaderLine(QString delimiter,
-                                       DataExportOptions opts,
-                                       ExportNameOptions nameOpts) const {
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions opts,
+                           ExportNameOptions nameOpts) const {
 
         QStringList list;
 
@@ -400,13 +400,18 @@
         }
         
         list << "label";
+
+        QVector<QString> sv;
+        for (QString s: list) {
+            sv.push_back(s.toUpper());
+        }
+        return sv;
+    }
+
+    QVector<QString>
+    toStringExportRow(DataExportOptions opts,
+                      sv_samplerate_t sampleRate) const {
         
-        return list.join(delimiter).toUpper();
-    }
-    
-    QString toDelimitedDataString(QString delimiter,
-                                  DataExportOptions opts,
-                                  sv_samplerate_t sampleRate) const {
         QStringList list;
 
         if (opts & DataExportWriteTimeInFrames) {
@@ -441,8 +446,8 @@
         // facility for the user to customise it
         if (m_uri != "") list << m_uri;
         if (m_label != "") list << m_label;
-        
-        return list.join(delimiter);
+
+        return list.toVector();
     }
     
     uint hash(uint seed = 0) const {
--- a/base/EventSeries.cpp	Fri Apr 03 12:12:02 2020 +0100
+++ b/base/EventSeries.cpp	Mon Apr 06 13:55:44 2020 +0100
@@ -16,6 +16,9 @@
 
 #include <QMutexLocker>
 
+using std::vector;
+using std::string;
+
 EventSeries::EventSeries(const EventSeries &other) :
     EventSeries(other, QMutexLocker(&other.m_mutex))
 {
@@ -597,32 +600,28 @@
     out << indent << "</dataset>\n";
 }
 
-QString
-EventSeries::getDelimitedDataHeaderLine(QString delimiter,
-                                        DataExportOptions opts,
-                                        Event::ExportNameOptions nopts) const
+QVector<QString>
+EventSeries::getStringExportHeaders(DataExportOptions opts,
+                                    Event::ExportNameOptions nopts) const
 {
     if (m_events.empty()) {
-        return QString();
+        return {};
     } else {
-        return m_events.begin()->getDelimitedDataHeaderLine(delimiter,
-                                                            opts,
-                                                            nopts);
+        return m_events.begin()->getStringExportHeaders(opts, nopts);
     }
 }
 
-QString
-EventSeries::toDelimitedDataString(QString delimiter,
-                                   DataExportOptions options,
-                                   sv_frame_t startFrame,
-                                   sv_frame_t duration,
-                                   sv_samplerate_t sampleRate,
-                                   sv_frame_t resolution,
-                                   Event fillEvent) const
+QVector<QVector<QString>>
+EventSeries::toStringExportRows(DataExportOptions options,
+                                sv_frame_t startFrame,
+                                sv_frame_t duration,
+                                sv_samplerate_t sampleRate,
+                                sv_frame_t resolution,
+                                Event fillEvent) const
 {
     QMutexLocker locker(&m_mutex);
 
-    QString s;
+    QVector<QVector<QString>> rows;
 
     const sv_frame_t end = startFrame + duration;
 
@@ -632,10 +631,7 @@
     if (!(options & DataExportFillGaps)) {
         
         while (pitr != m_events.end() && pitr->getFrame() < end) {
-            s += pitr->toDelimitedDataString(delimiter,
-                                             options,
-                                             sampleRate);
-            s += "\n";
+            rows.push_back(pitr->toStringExportRow(options, sampleRate));
             ++pitr;
         }
 
@@ -659,22 +655,19 @@
         // distance) or a default fill point
         while (f < end) {
             if (pitr != m_events.end() && pitr->getFrame() <= f) {
-                s += pitr->toDelimitedDataString
-                    (delimiter,
-                     options & ~DataExportFillGaps,
-                     sampleRate);
+                rows.push_back(pitr->toStringExportRow
+                               (options & ~DataExportFillGaps,
+                                sampleRate));
                 ++pitr;
             } else {
-                s += fillEvent.withFrame(f).toDelimitedDataString
-                    (delimiter,
-                     options & ~DataExportFillGaps,
-                     sampleRate);
+                rows.push_back(fillEvent.withFrame(f).toStringExportRow
+                               (options & ~DataExportFillGaps,
+                                sampleRate));
             }
-            s += "\n";
             f += resolution;
         }
     }
     
-    return s;
+    return rows;
 }
 
--- a/base/EventSeries.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/base/EventSeries.h	Mon Apr 06 13:55:44 2020 +0100
@@ -19,6 +19,8 @@
 #include "XmlExportable.h"
 
 #include <set>
+#include <string>
+#include <vector>
 #include <functional>
 
 #include <QMutex>
@@ -221,24 +223,24 @@
                Event::ExportNameOptions) const;
 
     /**
-     * Emit a label for each column that would be written by
-     * toDelimitedDataString, separated by the given delimiter.
+     * Return a label for each column that would be written by
+     * toStringExportRows.
      */
-    QString getDelimitedDataHeaderLine(QString delimiter,
-                                       DataExportOptions options,
-                                       Event::ExportNameOptions) const;
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions options,
+                           Event::ExportNameOptions) const;
     
     /**
-     * Emit events starting within the given range to a delimited
-     * (e.g. comma-separated) data format.
+     * Emit events starting within the given range as string rows
+     * ready for conversion to an e.g. comma-separated data format.
      */
-    QString toDelimitedDataString(QString delimiter,
-                                  DataExportOptions options,
-                                  sv_frame_t startFrame,
-                                  sv_frame_t duration,
-                                  sv_samplerate_t sampleRate,
-                                  sv_frame_t resolution,
-                                  Event fillEvent) const;
+    QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions options,
+                       sv_frame_t startFrame,
+                       sv_frame_t duration,
+                       sv_samplerate_t sampleRate,
+                       sv_frame_t resolution,
+                       Event fillEvent) const;
     
 private:
     mutable QMutex m_mutex;
--- a/base/StringBits.cpp	Fri Apr 03 12:12:02 2020 +0100
+++ b/base/StringBits.cpp	Mon Apr 06 13:55:44 2020 +0100
@@ -151,3 +151,20 @@
     }
 }
 
+QString
+StringBits::joinDelimited(QVector<QString> row, QString delimiter)
+{
+    QString s;
+    for (auto col: row) {
+        if (s != "") {
+            s += delimiter;
+        }
+        if (col.contains(delimiter)) {
+            col.replace("\"", "\"\"");
+            col = "\"" + col + "\"";
+        }
+        s += col;
+    }
+    return s;    
+}
+
--- a/base/StringBits.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/base/StringBits.h	Mon Apr 06 13:55:44 2020 +0100
@@ -55,6 +55,16 @@
      * analogous to the behaviour of splitQuoted).
      */
     static QStringList split(QString s, QChar separator, bool quoted);
+
+    /**
+     * Join a vector of strings into a single string, with the
+     * delimiter as the joining string. If a string contains the
+     * delimiter already, quote it with double-quotes, replacing any
+     * existing double-quotes within it by a pair of double-quotes, as
+     * specified in RFC 4180 Common Format and MIME Type for
+     * Comma-Separated Values (CSV) Files.
+     */
+    static QString joinDelimited(QVector<QString> row, QString delimiter);
 };
 
 #endif
--- a/data/fileio/CSVFileWriter.cpp	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/fileio/CSVFileWriter.cpp	Mon Apr 06 13:55:44 2020 +0100
@@ -86,7 +86,9 @@
         QTextStream out(&file);
 
         if (m_options & DataExportIncludeHeader) {
-            out << m_model->getDelimitedDataHeaderLine(m_delimiter, m_options)
+            out << StringBits::joinDelimited
+                (m_model->getStringExportHeaders(m_options),
+                 m_delimiter)
                 << endl;
         }
         
--- a/data/fileio/CSVStreamWriter.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/fileio/CSVStreamWriter.h	Mon Apr 06 13:55:44 2020 +0100
@@ -20,7 +20,9 @@
 #include "base/Selection.h"
 #include "base/ProgressReporter.h"
 #include "base/DataExportOptions.h"
+#include "base/StringBits.h"
 #include "data/model/Model.h"
+
 #include <QString>
 #include <algorithm>
 #include <numeric>
@@ -68,20 +70,21 @@
 
             const auto start = readPtr;
             const auto end = std::min(start + blockSize, endFrame);
-            const auto data = model.toDelimitedDataString(
-                delimiter,
+            const auto data = model.toStringExportRows(
                 options,
                 start,
                 end - start
-            ).trimmed();
+            );
 
-            if ( data != "" ) {
-                if (started) {
-                    oss << "\n";
-                } else {
-                    started = true;
+            if (!data.empty()) {
+                for (const auto &row: data) {
+                    if (started) {
+                        oss << "\n";
+                    } else {
+                        started = true;
+                    }
+                    oss << StringBits::joinDelimited(row, delimiter);
                 }
-                oss << data;
             }
 
             nFramesWritten += end - start;
@@ -120,7 +123,7 @@
     };
     MultiSelection regions;
     regions.addSelection(all);
-    return CSVStreamWriter::writeInChunks(
+    return writeInChunks(
         oss,
         model,
         regions,
@@ -141,7 +144,7 @@
               const sv_frame_t blockSize = 16384)
 {
     const Selection empty;
-    return CSVStreamWriter::writeInChunks(
+    return writeInChunks(
         oss,
         model,
         empty,
--- a/data/fileio/test/CSVStreamWriterTest.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/fileio/test/CSVStreamWriterTest.h	Mon Apr 06 13:55:44 2020 +0100
@@ -94,6 +94,7 @@
 
         std::ostringstream oss;
         const auto result = CSVStreamWriter::writeInChunks(oss, mwm);
+        
         QVERIFY( oss.str() == getExpectedString() );
         QVERIFY( result );
     }
@@ -303,9 +304,12 @@
 //        qDebug("Create Expected Output\n");
 
         // NB. removed end line break
-        const auto expectedOutput =
-            notes.toDelimitedDataString(",", {}, 0, notes.getEndFrame())
-            .trimmed();
+        QString expectedOutput;
+        auto rows = notes.toStringExportRows({}, 0, notes.getEndFrame());
+        for (auto row: rows) {
+            expectedOutput += StringBits::joinDelimited(row, ",") + "\n";
+        }
+        expectedOutput = expectedOutput.trimmed();
 
         StubReporter reporter { []() -> bool { return false; } };
         std::ostringstream oss;
@@ -326,6 +330,70 @@
         QVERIFY( oss.str() != std::string() );
         QVERIFY( oss.str() == expectedOutput.toStdString() );
     }
+
+    void writeWithQuotingRequired()
+    {
+        QString commaLabel =
+            "This label contains punctuation, specifically, commas";
+        QString quoteSpaceLabel =
+            "This label contains spaces and \"double quotes\"";
+        
+        NoteModel notes(8, 4);
+        notes.add({ 0, 64, 4, 1.f, commaLabel });
+        notes.add({ 16, 64, 6, 1.f, quoteSpaceLabel });
+
+        QString expectedWithCommaSeparator =
+            QString("0.000000000,64,0.500000000,1,\"") +
+            commaLabel +
+            QString("\"\n") +
+            QString("2.000000000,64,0.750000000,1,") +
+            quoteSpaceLabel;
+
+        QString expectedWithSpaceSeparator =
+            QString("0.000000000 64 0.500000000 1 \"") +
+            commaLabel +
+            QString("\"\n") +
+            QString("2.000000000 64 0.750000000 1 \"") +
+            QString("This label contains spaces and \"\"double quotes\"\"") +
+            QString("\"");
+
+        StubReporter reporter { []() -> bool { return false; } };
+        std::ostringstream oss;
+        auto wroteSparseModel = CSVStreamWriter::writeInChunks(
+            oss,
+            notes,
+            &reporter,
+            ",",
+            DataExportDefaults,
+            2
+        );
+
+        QVERIFY( wroteSparseModel == true );
+        QVERIFY( oss.str() != std::string() );
+
+        cerr << oss.str() << endl;
+        cerr << expectedWithCommaSeparator << endl;
+        
+        QVERIFY( oss.str() == expectedWithCommaSeparator.toStdString() );
+
+        std::ostringstream oss2;
+        wroteSparseModel = CSVStreamWriter::writeInChunks(
+            oss2,
+            notes,
+            &reporter,
+            " ",
+            DataExportDefaults,
+            2
+        );
+
+        QVERIFY( wroteSparseModel == true );
+        QVERIFY( oss2.str() != std::string() );
+
+        cerr << oss2.str() << endl;
+        cerr << expectedWithSpaceSeparator << endl;
+        
+        QVERIFY( oss2.str() == expectedWithSpaceSeparator.toStdString() );
+    }
 };
 
 #endif
--- a/data/model/AlignmentModel.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/AlignmentModel.h	Mon Apr 06 13:55:44 2020 +0100
@@ -77,13 +77,14 @@
                QString indent = "",
                QString extraAttributes = "") const override;
 
-    QString getDelimitedDataHeaderLine(QString, DataExportOptions) const override {
-        return "";
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions) const override {
+        return {};
     }
-    
-    QString toDelimitedDataString(QString, DataExportOptions,
-                                  sv_frame_t, sv_frame_t) const override {
-        return "";
+
+    QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions, sv_frame_t, sv_frame_t) const override {
+        return {};
     }
 
 signals:
--- a/data/model/BasicCompressedDenseThreeDimensionalModel.cpp	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/BasicCompressedDenseThreeDimensionalModel.cpp	Mon Apr 06 13:55:44 2020 +0100
@@ -498,37 +498,40 @@
     return m_completion;
 }
 
-QString
-BasicCompressedDenseThreeDimensionalModel::getDelimitedDataHeaderLine(QString delimiter,
-                                                                      DataExportOptions) const
+QVector<QString>
+BasicCompressedDenseThreeDimensionalModel::getStringExportHeaders(DataExportOptions)
+    const
 {
-    QStringList list;
+    QVector<QString> sv;
     for (int i = 0; i < m_yBinCount; ++i) {
-        list << QString("Bin%1").arg(i+1);
+        sv.push_back(QString("Bin%1").arg(i+1));
     }
-    return list.join(delimiter);
+    return sv;
 }    
-
-QString
-BasicCompressedDenseThreeDimensionalModel::toDelimitedDataString(QString delimiter,
-                                                          DataExportOptions,
-                                                          sv_frame_t startFrame,
-                                                          sv_frame_t duration) const
+    
+QVector<QVector<QString>>
+BasicCompressedDenseThreeDimensionalModel::toStringExportRows(DataExportOptions,
+                                                              sv_frame_t startFrame,
+                                                              sv_frame_t duration)
+    const
 {
     QReadLocker locker(&m_lock);
-    QString s;
+
+    QVector<QVector<QString>> rows;
+
     for (int i = 0; in_range_for(m_data, i); ++i) {
         Column c = getColumn(i);
         sv_frame_t fr = m_startFrame + i * m_resolution;
         if (fr >= startFrame && fr < startFrame + duration) {
-            QStringList list;
+            QVector<QString> row;
             for (int j = 0; in_range_for(c, j); ++j) {
-                list << QString("%1").arg(c.at(j));
+                row << QString("%1").arg(c.at(j));
             }
-            s += list.join(delimiter) + "\n";
+            rows.push_back(row);
         }
     }
-    return s;
+    
+    return rows;
 }
 
 void
--- a/data/model/BasicCompressedDenseThreeDimensionalModel.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/BasicCompressedDenseThreeDimensionalModel.h	Mon Apr 06 13:55:44 2020 +0100
@@ -181,13 +181,13 @@
 
     QString getTypeName() const override { return tr("Editable Dense 3-D"); }
 
-    QString getDelimitedDataHeaderLine(QString delimiter,
-                                       DataExportOptions opts) const override;
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions options) const override;
 
-    QString toDelimitedDataString(QString delimiter,
-                                  DataExportOptions options,
-                                  sv_frame_t startFrame,
-                                  sv_frame_t duration) const override;
+    QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions options,
+                       sv_frame_t startFrame,
+                       sv_frame_t duration) const override;
 
     void toXml(QTextStream &out,
                        QString indent = "",
--- a/data/model/BoxModel.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/BoxModel.h	Mon Apr 06 13:55:44 2020 +0100
@@ -348,8 +348,9 @@
         m_events.toXml(out, indent, QString("dimensions=\"2\""), options);
     }
 
-    QString getDelimitedDataHeaderLine(QString delimiter,
-                                       DataExportOptions opts) const override {
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions opts) const override {
+
         QStringList list;
 
         // These are considered API rather than human-readable text -
@@ -363,23 +364,27 @@
 
         list << "extent start" << "extent end" << "label";
 
-        return list.join(delimiter).toUpper();
+        QVector<QString> sv;
+        for (QString s: list) {
+            sv.push_back(s.toUpper());
+        }
+        return sv;
     }
     
-    QString toDelimitedDataString(QString delimiter,
-                                  DataExportOptions opts,
-                                  sv_frame_t startFrame,
-                                  sv_frame_t duration) const override {
+    QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions opts,
+                       sv_frame_t startFrame,
+                       sv_frame_t duration) const override {
 
         // We need a custom format here
 
         EventVector ee = m_events.getEventsSpanning(startFrame, duration);
 
-        QString s;
+        QVector<QVector<QString>> rows;
         
         for (auto e: ee) {
 
-            QStringList list;
+            QVector<QString> list;
 
             if (opts & DataExportWriteTimeInFrames) {
                 
@@ -405,10 +410,10 @@
                 list << e.getLabel();
             }
 
-            s += list.join(delimiter) + "\n";
+            rows.push_back(list);
         }
 
-        return s;
+        return rows;
     }
 
 protected:
--- a/data/model/Dense3DModelPeakCache.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/Dense3DModelPeakCache.h	Mon Apr 06 13:55:44 2020 +0100
@@ -119,13 +119,14 @@
         return source ? source->getCompletion() : 100;
     }
 
-    QString getDelimitedDataHeaderLine(QString, DataExportOptions) const override {
-        return "";
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions) const override {
+        return {};
     }
-    
-    QString toDelimitedDataString(QString, DataExportOptions,
-                                  sv_frame_t, sv_frame_t) const override {
-        return "";
+
+    QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions, sv_frame_t, sv_frame_t) const override {
+        return {};
     }
 
 protected slots:
--- a/data/model/DenseTimeValueModel.cpp	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/DenseTimeValueModel.cpp	Mon Apr 06 13:55:44 2020 +0100
@@ -17,41 +17,42 @@
 
 #include <QStringList>
 
-QString
-DenseTimeValueModel::getDelimitedDataHeaderLine(QString delimiter,
-                                                DataExportOptions) const
+using namespace std;
+
+QVector<QString>
+DenseTimeValueModel::getStringExportHeaders(DataExportOptions) const
 {
     int ch = getChannelCount();
-    QStringList list;
+    QVector<QString> sv;
     for (int i = 0; i < ch; ++i) {
-        list << QString("Channel%1").arg(i+1);
+        sv.push_back(QString("Channel%1").arg(i+1));
     }
-    return list.join(delimiter);
+    return sv;
 }
 
-QString
-DenseTimeValueModel::toDelimitedDataString(QString delimiter,
-                                           DataExportOptions,
-                                           sv_frame_t startFrame,
-                                           sv_frame_t duration) const
+QVector<QVector<QString>>
+DenseTimeValueModel::toStringExportRows(DataExportOptions,
+                                        sv_frame_t startFrame,
+                                        sv_frame_t duration) const
 {
     int ch = getChannelCount();
 
-    if (duration <= 0) return "";
+    if (duration <= 0) return {};
 
     auto data = getMultiChannelData(0, ch - 1, startFrame, duration);
 
-    if (data.empty() || data[0].empty()) return "";
+    if (data.empty() || data[0].empty()) return {};
     
-    QStringList list;
+    QVector<QVector<QString>> rows;
+
     for (sv_frame_t i = 0; in_range_for(data[0], i); ++i) {
-        QStringList parts;
-        parts << QString("%1").arg(startFrame + i);
+        QVector<QString> row;
+        row.push_back(QString("%1").arg(startFrame + i));
         for (int c = 0; in_range_for(data, c); ++c) {
-            parts << QString("%1").arg(data[c][i]);
+            row.push_back(QString("%1").arg(data[c][i]));
         }
-        list << parts.join(delimiter);
+        rows.push_back(row);
     }
 
-    return list.join("\n");
+    return rows;
 }
--- a/data/model/DenseTimeValueModel.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/DenseTimeValueModel.h	Mon Apr 06 13:55:44 2020 +0100
@@ -82,13 +82,13 @@
     bool canPlay() const override { return true; }
     QString getDefaultPlayClipId() const override { return ""; }
 
-    QString getDelimitedDataHeaderLine(QString delimiter,
-                                       DataExportOptions options) const override;
-    
-    QString toDelimitedDataString(QString delimiter,
-                                  DataExportOptions options,
-                                  sv_frame_t startFrame,
-                                  sv_frame_t duration) const override;
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions options) const override;
+
+    QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions options,
+                       sv_frame_t startFrame,
+                       sv_frame_t duration) const override;
 
     QString getTypeName() const override { return tr("Dense Time-Value"); }
 };
--- a/data/model/EditableDenseThreeDimensionalModel.cpp	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/EditableDenseThreeDimensionalModel.cpp	Mon Apr 06 13:55:44 2020 +0100
@@ -351,36 +351,38 @@
     return m_completion;
 }
 
-QString
-EditableDenseThreeDimensionalModel::getDelimitedDataHeaderLine(QString delimiter,
-                                                               DataExportOptions) const
+QVector<QString>
+EditableDenseThreeDimensionalModel::getStringExportHeaders(DataExportOptions)
+    const
 {
-    QStringList list;
+    QVector<QString> sv;
     for (int i = 0; i < m_yBinCount; ++i) {
-        list << QString("Bin%1").arg(i+1);
+        sv.push_back(QString("Bin%1").arg(i+1));
     }
-    return list.join(delimiter);
+    return sv;
 }    
     
-QString
-EditableDenseThreeDimensionalModel::toDelimitedDataString(QString delimiter,
-                                                          DataExportOptions,
-                                                          sv_frame_t startFrame,
-                                                          sv_frame_t duration) const
+QVector<QVector<QString>>
+EditableDenseThreeDimensionalModel::toStringExportRows(DataExportOptions,
+                                                       sv_frame_t startFrame,
+                                                       sv_frame_t duration)
+    const
 {
     QMutexLocker locker(&m_mutex);
-    QString s;
+
+    QVector<QVector<QString>> rows;
+
     for (int i = 0; in_range_for(m_data, i); ++i) {
         sv_frame_t fr = m_startFrame + i * m_resolution;
         if (fr >= startFrame && fr < startFrame + duration) {
-            QStringList list;
+            QVector<QString> row;
             for (int j = 0; in_range_for(m_data.at(i), j); ++j) {
-                list << QString("%1").arg(m_data.at(i).at(j));
+                row.push_back(QString("%1").arg(m_data.at(i).at(j)));
             }
-            s += list.join(delimiter) + "\n";
+            rows.push_back(row);
         }
     }
-    return s;
+    return rows;
 }
 
 void
--- a/data/model/EditableDenseThreeDimensionalModel.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/EditableDenseThreeDimensionalModel.h	Mon Apr 06 13:55:44 2020 +0100
@@ -172,13 +172,13 @@
 
     QString getTypeName() const override { return tr("Editable Dense 3-D"); }
 
-    QString getDelimitedDataHeaderLine(QString delimiter,
-                                       DataExportOptions opts) const override;
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions options) const override;
 
-    QString toDelimitedDataString(QString delimiter,
-                                  DataExportOptions options,
-                                  sv_frame_t startFrame,
-                                  sv_frame_t duration) const override;
+    QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions options,
+                       sv_frame_t startFrame,
+                       sv_frame_t duration) const override;
 
     void toXml(QTextStream &out,
                        QString indent = "",
--- a/data/model/FFTModel.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/FFTModel.h	Mon Apr 06 13:55:44 2020 +0100
@@ -99,12 +99,14 @@
     float getBinValue(int n) const override;
     QString getBinName(int n) const override;
 
-    QString getDelimitedDataHeaderLine(QString, DataExportOptions) const override {
-        return "";
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions) const override {
+        return {};
     }
-    QString toDelimitedDataString(QString, DataExportOptions,
-                                  sv_frame_t, sv_frame_t) const override {
-        return "";
+
+    QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions, sv_frame_t, sv_frame_t) const override {
+        return {};
     }
 
     // FFTModel methods:
--- a/data/model/ImageModel.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/ImageModel.h	Mon Apr 06 13:55:44 2020 +0100
@@ -275,27 +275,23 @@
         m_events.toXml(out, indent, QString("dimensions=\"1\""), options);
     }
 
-    QString getDelimitedDataHeaderLine(QString delimiter,
-                                       DataExportOptions options) const override {
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions options) const override {
         Event::ExportNameOptions nameOpts;
         nameOpts.uriAttributeName = "image";
-
-        return m_events.getDelimitedDataHeaderLine(delimiter,
-                                                   options,
-                                                   nameOpts);
+        return m_events.getStringExportHeaders(options, nameOpts);
     }
     
-    QString toDelimitedDataString(QString delimiter,
-                                  DataExportOptions options,
-                                  sv_frame_t startFrame,
-                                  sv_frame_t duration) const override {
-        return m_events.toDelimitedDataString(delimiter,
-                                              options,
-                                              startFrame,
-                                              duration,
-                                              m_sampleRate,
-                                              m_resolution,
-                                              Event().withValue(0.f));
+    QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions options,
+                       sv_frame_t startFrame,
+                       sv_frame_t duration) const override {
+        return m_events.toStringExportRows(options,
+                                           startFrame,
+                                           duration,
+                                           m_sampleRate,
+                                           m_resolution,
+                                           Event().withValue(0.f));
     }
     
 protected:
--- a/data/model/Model.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/Model.h	Mon Apr 06 13:55:44 2020 +0100
@@ -275,20 +275,20 @@
                QString extraAttributes = "") const override;
 
     /**
-     * Emit a label for each column that would be written by
-     * toDelimitedDataString, separated by the given delimiter.
+     * Return a label for each column that would be written by
+     * toStringExportRows.
      */
-    virtual QString getDelimitedDataHeaderLine(QString delimiter,
-                                               DataExportOptions options) const = 0;
+    virtual QVector<QString>
+    getStringExportHeaders(DataExportOptions options) const = 0;
     
     /**
-     * Emit the contents of the model within the given range to a
-     * delimited (e.g. comma-separated) data format.
+     * Emit events starting within the given range as string rows
+     * ready for conversion to an e.g. comma-separated data format.
      */
-    virtual QString toDelimitedDataString(QString delimiter,
-                                          DataExportOptions options,
-                                          sv_frame_t startFrame,
-                                          sv_frame_t duration) const = 0;
+    virtual QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions options,
+                       sv_frame_t startFrame,
+                       sv_frame_t duration) const = 0;
 
 signals:
     /**
--- a/data/model/NoteModel.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/NoteModel.h	Mon Apr 06 13:55:44 2020 +0100
@@ -408,20 +408,17 @@
         m_events.toXml(out, indent, QString("dimensions=\"3\""));
     }
 
-    QString getDelimitedDataHeaderLine(QString delimiter,
-                                       DataExportOptions options) const override {
-        return m_events.getDelimitedDataHeaderLine(delimiter,
-                                                   options,
-                                                   Event::ExportNameOptions());
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions options) const override {
+        return m_events.getStringExportHeaders(options, {});
     }
     
-    QString toDelimitedDataString(QString delimiter,
-                                  DataExportOptions options,
-                                  sv_frame_t startFrame,
-                                  sv_frame_t duration) const override {
-        return m_events.toDelimitedDataString
-            (delimiter,
-             options,
+    QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions options,
+                       sv_frame_t startFrame,
+                       sv_frame_t duration) const override {
+        return m_events.toStringExportRows
+            (options,
              startFrame,
              duration,
              m_sampleRate,
--- a/data/model/RegionModel.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/RegionModel.h	Mon Apr 06 13:55:44 2020 +0100
@@ -340,20 +340,17 @@
         m_events.toXml(out, indent, QString("dimensions=\"3\""));
     }
 
-    QString getDelimitedDataHeaderLine(QString delimiter,
-                                       DataExportOptions options) const override {
-        return m_events.getDelimitedDataHeaderLine(delimiter,
-                                                   options,
-                                                   Event::ExportNameOptions());
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions options) const override {
+        return m_events.getStringExportHeaders(options, {});
     }
     
-    QString toDelimitedDataString(QString delimiter,
-                                  DataExportOptions options,
-                                  sv_frame_t startFrame,
-                                  sv_frame_t duration) const override {
-        return m_events.toDelimitedDataString
-            (delimiter,
-             options,
+    QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions options,
+                       sv_frame_t startFrame,
+                       sv_frame_t duration) const override {
+        return m_events.toStringExportRows
+            (options,
              startFrame,
              duration,
              m_sampleRate,
--- a/data/model/SparseOneDimensionalModel.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/SparseOneDimensionalModel.h	Mon Apr 06 13:55:44 2020 +0100
@@ -308,24 +308,21 @@
         m_events.toXml(out, indent, QString("dimensions=\"1\""));
     }
 
-    QString getDelimitedDataHeaderLine(QString delimiter,
-                                       DataExportOptions options) const override {
-        return m_events.getDelimitedDataHeaderLine(delimiter,
-                                                   options,
-                                                   Event::ExportNameOptions());
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions options) const override {
+        return m_events.getStringExportHeaders(options, {});
     }
     
-    QString toDelimitedDataString(QString delimiter,
-                                  DataExportOptions options,
-                                  sv_frame_t startFrame,
-                                  sv_frame_t duration) const override {
-        return m_events.toDelimitedDataString(delimiter,
-                                              options,
-                                              startFrame,
-                                              duration,
-                                              m_sampleRate,
-                                              m_resolution,
-                                              Event());
+    QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions options,
+                       sv_frame_t startFrame,
+                       sv_frame_t duration) const override {
+        return m_events.toStringExportRows(options,
+                                           startFrame,
+                                           duration,
+                                           m_sampleRate,
+                                           m_resolution,
+                                           {});
     }
     
 protected:
--- a/data/model/SparseTimeValueModel.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/SparseTimeValueModel.h	Mon Apr 06 13:55:44 2020 +0100
@@ -347,24 +347,21 @@
         m_events.toXml(out, indent, QString("dimensions=\"2\""));
     }
 
-    QString getDelimitedDataHeaderLine(QString delimiter,
-                                       DataExportOptions options) const override {
-        return m_events.getDelimitedDataHeaderLine(delimiter,
-                                                   options,
-                                                   Event::ExportNameOptions());
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions options) const override {
+        return m_events.getStringExportHeaders(options, {});
     }
     
-    QString toDelimitedDataString(QString delimiter,
-                                  DataExportOptions options,
-                                  sv_frame_t startFrame,
-                                  sv_frame_t duration) const override {
-        return m_events.toDelimitedDataString(delimiter,
-                                              options,
-                                              startFrame,
-                                              duration,
-                                              m_sampleRate,
-                                              m_resolution,
-                                              Event().withValue(0.f));
+    QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions options,
+                       sv_frame_t startFrame,
+                       sv_frame_t duration) const override {
+        return m_events.toStringExportRows(options,
+                                           startFrame,
+                                           duration,
+                                           m_sampleRate,
+                                           m_resolution,
+                                           Event().withValue(0.f));
     }
   
 protected:
--- a/data/model/TextModel.h	Fri Apr 03 12:12:02 2020 +0100
+++ b/data/model/TextModel.h	Mon Apr 06 13:55:44 2020 +0100
@@ -278,26 +278,23 @@
         m_events.toXml(out, indent, QString("dimensions=\"2\""), options);
     }
 
-    QString getDelimitedDataHeaderLine(QString delimiter,
-                                       DataExportOptions options) const override {
+    QVector<QString>
+    getStringExportHeaders(DataExportOptions options) const override {
         Event::ExportNameOptions nameOpts;
         nameOpts.valueAttributeName = "height";
-        return m_events.getDelimitedDataHeaderLine(delimiter,
-                                                   options,
-                                                   nameOpts);
+        return m_events.getStringExportHeaders(options, nameOpts);
     }
     
-    QString toDelimitedDataString(QString delimiter,
-                                  DataExportOptions options,
-                                  sv_frame_t startFrame,
-                                  sv_frame_t duration) const override {
-        return m_events.toDelimitedDataString(delimiter,
-                                              options,
-                                              startFrame,
-                                              duration,
-                                              m_sampleRate,
-                                              m_resolution,
-                                              Event().withValue(0.f));
+    QVector<QVector<QString>>
+    toStringExportRows(DataExportOptions options,
+                       sv_frame_t startFrame,
+                       sv_frame_t duration) const override {
+        return m_events.toStringExportRows(options,
+                                           startFrame,
+                                           duration,
+                                           m_sampleRate,
+                                           m_resolution,
+                                           Event().withValue(0.f));
     }
   
 protected: