Repository: jtablesaw/tablesaw
Pull request #901 (2021-04-23 15:03:43)

Allow reading of malformed CSV
Thanks for contributing.

- [x] Tick to sign-off your agreement to the [Developer Certificate of Origin (DCO) 1.1](https://developercertificate.org)

## Description

add ignoreInvalidRows option in `CsvReadOptions` and `ReadOptions`  to skip invalid csv rows

## Testing

add 2 unit tests


ChangzhenZhang: @benmccann, I find that I didn't create branch for this issue. What should I do now?
benmccann: > I find that I didn't create branch for this issue. What should I do now?

That's okay. You can push additional changes to your `master` and they will show up here. Then when this PR is done you can simply delete and recreate your fork as an easy way to reset your repo and next time you can create a branch

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5230f16d..4ea06392 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,10 +14,8 @@ jobs:
       fail-fast: false
       matrix:
         # Test the Java LTS versions and latest version available
-        java-version: [1.8, 11, 15]
-        # Tests seem to be failing on Windows
-        # os: [windows-latest, macOS-latest, ubuntu-latest]
-        os: [macOS-latest, ubuntu-latest]
+        java-version: [1.8, 11, 16]
+        os: [macOS-latest, ubuntu-latest, windows-latest]
     steps:
       - name: Checkout
         uses: actions/checkout@v2
diff --git a/README.md b/README.md
index 4903c84c..487b3770 100644
--- a/README.md
+++ b/README.md
@@ -59,11 +59,15 @@ You may also add supporting projects:
 
 * Start here:  https://jtablesaw.github.io/tablesaw/gettingstarted
 * Then see our documentation page: https://jtablesaw.github.io/tablesaw/ and the [Tablesaw User Guide](https://jtablesaw.github.io/tablesaw/userguide/toc).
-
-And *always* feel free to ask questions or make suggestions here on the [issues tab](https://github.com/jtablesaw/tablesaw/issues).
+* Ask questions, make suggestions, or tell us how you're using Tablesaw in the new GitHub [discussions forum](https://github.com/jtablesaw/tablesaw/discussions). 
+* Feature requests and bug reports can be made on the [issues tab](https://github.com/jtablesaw/tablesaw/issues).
 
 ### Integrations
 
 * We recommend trying Tablesaw inside [Jupyter notebooks](http://arogozhnikov.github.io/2016/09/10/jupyter-features.html), which lets you experiment with Tablesaw in a more interactive manner. Get started by [installing BeakerX](http://beakerx.com/documentation) and trying [the sample Tablesaw notebook](https://github.com/twosigma/beakerx/blob/master/doc/groovy/Tablesaw.ipynb)
+
+* Eclipse uses may find [etablesaw](https://github.com/hallvard/etablesaw) useful. It provides Eclipse integration aimed at turning Eclipse into a data workbench.   
+
 * You may utilize Tablesaw with many machine learning libraries. To see an example of using Tablesaw with [Smile](https://haifengl.github.io) check out [the sample Tablesaw Jupyter notebook](https://github.com/twosigma/beakerx/blob/master/doc/groovy/Tablesaw.ipynb)
+
 * You may use [quandl4j-tablesaw](http://quandl4j.org) if you'd like to load financial and economic data from [Quandl](https://www.quandl.com) into Tablesaw. This is demonstrated in [the sample Tablesaw notebook](https://github.com/twosigma/beakerx/blob/master/doc/groovy/Tablesaw.ipynb) as well
diff --git a/core/src/main/java/tech/tablesaw/api/DateColumn.java b/core/src/main/java/tech/tablesaw/api/DateColumn.java
index 11f25d40..abaf74df 100644
--- a/core/src/main/java/tech/tablesaw/api/DateColumn.java
+++ b/core/src/main/java/tech/tablesaw/api/DateColumn.java
@@ -34,6 +34,7 @@ import java.util.Set;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
 import java.util.stream.Stream;
+import javax.annotation.Nonnull;
 import tech.tablesaw.columns.AbstractColumn;
 import tech.tablesaw.columns.AbstractColumnParser;
 import tech.tablesaw.columns.Column;
@@ -135,17 +136,32 @@ public class DateColumn extends AbstractColumn<DateColumn, LocalDate>
     return value == null ? setMissing(index) : set(index, PackedLocalDate.pack(value));
   }
 
+  /**
+   * Creates and sets a printFormatter based-on the given DateTimeFormatter. Missing values will be
+   * printed as the given missingValueString. Non missing values will be handled by the
+   * dateTimeFormatter
+   */
   public void setPrintFormatter(DateTimeFormatter dateTimeFormatter, String missingValueString) {
     Preconditions.checkNotNull(dateTimeFormatter);
     Preconditions.checkNotNull(missingValueString);
     this.printFormatter = new DateColumnFormatter(dateTimeFormatter, missingValueString);
   }
 
+  /**
+   * Creates and sets a printFormatter based-on the given DateTimeFormatter. Missing values will be
+   * printed as empty strings. Non missing values will be handled by the dateTimeFormatter
+   */
   public void setPrintFormatter(DateTimeFormatter dateTimeFormatter) {
     Preconditions.checkNotNull(dateTimeFormatter);
     this.printFormatter = new DateColumnFormatter(dateTimeFormatter);
   }
 
+  /** Sets the print formatter to the argument */
+  public void setPrintFormatter(@Nonnull DateColumnFormatter dateColumnFormatter) {
+    Preconditions.checkNotNull(dateColumnFormatter);
+    this.printFormatter = dateColumnFormatter;
+  }
+
   @Override
   public String getString(int row) {
     return printFormatter.format(getPackedDate(row));
diff --git a/core/src/main/java/tech/tablesaw/api/StringColumn.java b/core/src/main/java/tech/tablesaw/api/StringColumn.java
index eb72d1ee..10e23f74 100644
--- a/core/src/main/java/tech/tablesaw/api/StringColumn.java
+++ b/core/src/main/java/tech/tablesaw/api/StringColumn.java
@@ -234,11 +234,11 @@ public class StringColumn extends AbstractStringColumn<StringColumn> {
         if (i + n >= size()) {
           break;
         }
-        copy.appendCell(get(i));
+        copy.append(get(i));
       }
     } else {
       for (int i = -n; i < size(); i++) {
-        copy.appendCell(get(i));
+        copy.append(get(i));
       }
       for (int m = 0; m > n; m--) {
         copy.appendMissing();
diff --git a/core/src/main/java/tech/tablesaw/api/TextColumn.java b/core/src/main/java/tech/tablesaw/api/TextColumn.java
index 8c9778e8..61ebb62e 100644
--- a/core/src/main/java/tech/tablesaw/api/TextColumn.java
+++ b/core/src/main/java/tech/tablesaw/api/TextColumn.java
@@ -207,20 +207,20 @@ public class TextColumn extends AbstractStringColumn<TextColumn> {
 
     if (n >= 0) {
       for (int m = 0; m < n; m++) {
-        copy.appendCell(TextColumnType.missingValueIndicator());
+        copy.appendMissing();
       }
       for (int i = 0; i < size(); i++) {
         if (i + n >= size()) {
           break;
         }
-        copy.appendCell(get(i));
+        copy.append(get(i));
       }
     } else {
       for (int i = -n; i < size(); i++) {
-        copy.appendCell(get(i));
+        copy.append(get(i));
       }
       for (int m = 0; m > n; m--) {
-        copy.appendCell(TextColumnType.missingValueIndicator());
+        copy.appendMissing();
       }
     }
 
@@ -286,13 +286,14 @@ public class TextColumn extends AbstractStringColumn<TextColumn> {
 
   @Override
   public TextColumn appendCell(String object) {
-    values.add(TextColumnType.DEFAULT_PARSER.parse(object));
+    append(TextColumnType.DEFAULT_PARSER.parse(object));
     return this;
   }
 
   @Override
   public TextColumn appendCell(String object, AbstractColumnParser<?> parser) {
-    return appendObj(parser.parse(object));
+    append(String.valueOf(parser.parse(object)));
+    return this;
   }
 
   @Override
@@ -385,7 +386,7 @@ public class TextColumn extends AbstractStringColumn<TextColumn> {
 
   /** Added for naming consistency with all other columns */
   public TextColumn append(String value) {
-    appendCell(value);
+    values.add(value);
     return this;
   }
 
diff --git a/core/src/main/java/tech/tablesaw/columns/AbstractColumnParser.java b/core/src/main/java/tech/tablesaw/columns/AbstractColumnParser.java
index 07e5e152..d281461e 100644
--- a/core/src/main/java/tech/tablesaw/columns/AbstractColumnParser.java
+++ b/core/src/main/java/tech/tablesaw/columns/AbstractColumnParser.java
@@ -83,4 +83,8 @@ public abstract class AbstractColumnParser<T> {
     }
     return new String(chars, 0, pos);
   }
+
+  public void setMissingValueStrings(List<String> missingValueStrings) {
+    this.missingValueStrings = missingValueStrings;
+  }
 }
diff --git a/core/src/main/java/tech/tablesaw/columns/ColumnFormatter.java b/core/src/main/java/tech/tablesaw/columns/ColumnFormatter.java
new file mode 100644
index 00000000..6712f80d
--- /dev/null
+++ b/core/src/main/java/tech/tablesaw/columns/ColumnFormatter.java
@@ -0,0 +1,22 @@
+package tech.tablesaw.columns;
+
+/**
+ * Abstract class for Column Formatters Every column type has a specialized print formatter that is
+ * used for formatting output for both table printing via, for example, table.printAll(). It is also
+ * used for writing text files using table.write().csv()
+ */
+public abstract class ColumnFormatter {
+
+  // The string to use for missing values
+  private final String missingString;
+
+  /** Constructs a new Formatter with the given missing value string. */
+  protected ColumnFormatter(String missingString) {
+    this.missingString = missingString;
+  }
+
+  /** Returns the string to be used in place of any missing values in the column */
+  public String getMissingString() {
+    return missingString;
+  }
+}
diff --git a/core/src/main/java/tech/tablesaw/columns/TemporalColumnFormatter.java b/core/src/main/java/tech/tablesaw/columns/TemporalColumnFormatter.java
new file mode 100644
index 00000000..220a62ea
--- /dev/null
+++ b/core/src/main/java/tech/tablesaw/columns/TemporalColumnFormatter.java
@@ -0,0 +1,36 @@
+package tech.tablesaw.columns;
+
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Abstract class for Column Formatters for temporal columns Date, DateTime, Time, and Instant Every
+ * column type has a specialized print formatter that is used for formatting output for both table
+ * printing via, for example, table.printAll(). It is also used for writing text files using
+ * table.write().csv()
+ */
+public abstract class TemporalColumnFormatter extends ColumnFormatter {
+
+  private final DateTimeFormatter format;
+
+  /** Constructs a new Formatter with the given formatter and an empty missing value string. */
+  protected TemporalColumnFormatter(DateTimeFormatter format) {
+    super("");
+    this.format = format;
+  }
+
+  /** Constructs a new Formatter with the given formatter and missing value string. */
+  protected TemporalColumnFormatter(DateTimeFormatter format, String missingValueString) {
+    super(missingValueString);
+    this.format = format;
+  }
+
+  /** Constructs a new default Formatter. This produces unformatted output. */
+  protected TemporalColumnFormatter() {
+    super("");
+    this.format = null;
+  }
+
+  public DateTimeFormatter getFormat() {
+    return format;
+  }
+}
diff --git a/core/src/main/java/tech/tablesaw/columns/booleans/BooleanFormatter.java b/core/src/main/java/tech/tablesaw/columns/booleans/BooleanFormatter.java
index 1da18b34..a965f557 100644
--- a/core/src/main/java/tech/tablesaw/columns/booleans/BooleanFormatter.java
+++ b/core/src/main/java/tech/tablesaw/columns/booleans/BooleanFormatter.java
@@ -1,30 +1,30 @@
 package tech.tablesaw.columns.booleans;
 
-public class BooleanFormatter {
+import tech.tablesaw.columns.ColumnFormatter;
+
+public class BooleanFormatter extends ColumnFormatter {
   private String trueString = "true";
   private String falseString = "false";
 
-  private String missingString = "";
-
   public BooleanFormatter(String trueString, String falseString, String missingString) {
+    super(missingString);
     this.trueString = trueString;
     this.falseString = falseString;
-    this.missingString = missingString;
   }
 
   public BooleanFormatter(String trueString, String falseString) {
+    super("");
     this.trueString = trueString;
     this.falseString = falseString;
-    this.missingString = "";
   }
 
   public BooleanFormatter(String missingString) {
-    this.missingString = missingString;
+    super(missingString);
   }
 
   public String format(Boolean value) {
     if (value == null) {
-      return missingString;
+      return getMissingString();
     }
     if (value) {
       return trueString;
@@ -34,7 +34,7 @@ public class BooleanFormatter {
 
   public String format(byte value) {
     if (value == BooleanColumnType.MISSING_VALUE) {
-      return missingString;
+      return getMissingString();
     }
     if (value == (byte) 1) {
       return trueString;
@@ -52,7 +52,7 @@ public class BooleanFormatter {
         + falseString
         + '\''
         + ", missingString='"
-        + missingString
+        + getMissingString()
         + '\''
         + '}';
   }
diff --git a/core/src/main/java/tech/tablesaw/columns/dates/DateColumnFormatter.java b/core/src/main/java/tech/tablesaw/columns/dates/DateColumnFormatter.java
index 7b93efb4..5b755885 100644
--- a/core/src/main/java/tech/tablesaw/columns/dates/DateColumnFormatter.java
+++ b/core/src/main/java/tech/tablesaw/columns/dates/DateColumnFormatter.java
@@ -6,47 +6,44 @@ import static tech.tablesaw.columns.dates.PackedLocalDate.toDateString;
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import javax.annotation.concurrent.Immutable;
+import tech.tablesaw.columns.TemporalColumnFormatter;
 
 @Immutable
-public class DateColumnFormatter {
+public class DateColumnFormatter extends TemporalColumnFormatter {
 
-  private final DateTimeFormatter format;
-  private String missingString = "";
-
-  public DateColumnFormatter() {
-    this.format = null;
+  public DateColumnFormatter(DateTimeFormatter format) {
+    super(format);
   }
 
-  public DateColumnFormatter(DateTimeFormatter format) {
-    this.format = format;
+  public DateColumnFormatter() {
+    super();
   }
 
-  public DateColumnFormatter(DateTimeFormatter format, String missingString) {
-    this.format = format;
-    this.missingString = missingString;
+  public DateColumnFormatter(DateTimeFormatter format, String missingValueString) {
+    super(format, missingValueString);
   }
 
   public String format(int value) {
     if (value == DateColumnType.missingValueIndicator()) {
-      return missingString;
+      return getMissingString();
     }
-    if (format == null) {
+    if (getFormat() == null) {
       return toDateString(value);
     }
     LocalDate date = asLocalDate(value);
     if (date == null) {
       return "";
     }
-    return format.format(date);
+    return getFormat().format(date);
   }
 
   @Override
   public String toString() {
     return "DateColumnFormatter{"
         + "format="
-        + format
+        + getFormat()
         + ", missingString='"
-        + missingString
+        + getMissingString()
         + '\''
         + '}';
   }
diff --git a/core/src/main/java/tech/tablesaw/columns/instant/InstantColumnFormatter.java b/core/src/main/java/tech/tablesaw/columns/instant/InstantColumnFormatter.java
index 2bf7f2e6..e76f582d 100644
--- a/core/src/main/java/tech/tablesaw/columns/instant/InstantColumnFormatter.java
+++ b/core/src/main/java/tech/tablesaw/columns/instant/InstantColumnFormatter.java
@@ -1,73 +1,71 @@
 package tech.tablesaw.columns.instant;
 
+import java.time.Instant;
 import java.time.ZoneId;
 import java.time.ZoneOffset;
 import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
 import javax.annotation.concurrent.Immutable;
-import tech.tablesaw.columns.times.TimeColumnType;
+import tech.tablesaw.columns.TemporalColumnFormatter;
 
 @Immutable
-public class InstantColumnFormatter {
+public class InstantColumnFormatter extends TemporalColumnFormatter {
 
-  private final DateTimeFormatter format;
   private final ZoneId zoneId;
-  private String missingValueString = "";
 
   public InstantColumnFormatter() {
-    this.format = null;
+    super(null);
     this.zoneId = ZoneOffset.UTC;
   }
 
   public InstantColumnFormatter(ZoneId zoneId) {
-    this.format = null;
+    super(null);
     this.zoneId = zoneId;
   }
 
   public InstantColumnFormatter(DateTimeFormatter format) {
-    this.format = format;
+    super(format);
     this.zoneId = ZoneOffset.UTC;
   }
 
   public InstantColumnFormatter(DateTimeFormatter format, ZoneId zoneId) {
-    this.format = format;
+    super(format);
     this.zoneId = zoneId;
   }
 
   public InstantColumnFormatter(DateTimeFormatter format, String missingValueString) {
-    this.format = format;
-    this.missingValueString = missingValueString;
+    super(format, missingValueString);
     this.zoneId = ZoneOffset.UTC;
   }
 
   public InstantColumnFormatter(
       DateTimeFormatter format, ZoneId zoneId, String missingValueString) {
-    this.format = format;
-    this.missingValueString = missingValueString;
+    super(format, missingValueString);
     this.zoneId = zoneId;
   }
 
   public String format(long value) {
-    if (value == TimeColumnType.missingValueIndicator()) {
-      return missingValueString;
+    if (value == InstantColumnType.missingValueIndicator()) {
+      return getMissingString();
     }
-    if (format == null) {
+    if (getFormat() == null) {
       return PackedInstant.toString(value);
     }
-    ZonedDateTime time = PackedInstant.asInstant(value).atZone(zoneId);
-    if (time == null) {
+    Instant instant = PackedInstant.asInstant(value);
+    if (instant == null) {
       return "";
     }
-    return format.format(time);
+    ZonedDateTime time = instant.atZone(zoneId);
+    return getFormat().format(time);
   }
 
   @Override
   public String toString() {
     return "InstantColumnFormatter{"
         + "format="
-        + format
+        + getFormat()
         + ", missingValueString='"
-        + missingValueString
+        + getMissingString()
         + '\''
         + '}';
   }
diff --git a/core/src/main/java/tech/tablesaw/columns/numbers/NumberColumnFormatter.java b/core/src/main/java/tech/tablesaw/columns/numbers/NumberColumnFormatter.java
index f0e5a3a9..45877691 100644
--- a/core/src/main/java/tech/tablesaw/columns/numbers/NumberColumnFormatter.java
+++ b/core/src/main/java/tech/tablesaw/columns/numbers/NumberColumnFormatter.java
@@ -4,11 +4,11 @@ import java.text.DecimalFormat;
 import java.text.DecimalFormatSymbols;
 import java.text.NumberFormat;
 import java.util.Locale;
+import tech.tablesaw.columns.ColumnFormatter;
 
-public class NumberColumnFormatter {
+public class NumberColumnFormatter extends ColumnFormatter {
 
   private final NumberFormat format;
-  private String missingString = "";
 
   public static NumberColumnFormatter percent(int fractionalDigits) {
     NumberFormat format = NumberFormat.getPercentInstance();
@@ -63,26 +63,28 @@ public class NumberColumnFormatter {
   }
 
   public NumberColumnFormatter() {
+    super("");
     this.format = null;
   }
 
   public NumberColumnFormatter(NumberFormat format) {
+    super("");
     this.format = format;
   }
 
   public NumberColumnFormatter(NumberFormat format, String missingString) {
+    super(missingString);
     this.format = format;
-    this.missingString = missingString;
   }
 
   public NumberColumnFormatter(String missingString) {
+    super(missingString);
     this.format = null;
-    this.missingString = missingString;
   }
 
   public String format(long value) {
     if (isMissingValue(value)) {
-      return missingString;
+      return getMissingString();
     }
     if (format == null) {
       return String.valueOf(value);
@@ -92,7 +94,7 @@ public class NumberColumnFormatter {
 
   public String format(double value) {
     if (isMissingValue(value)) {
-      return missingString;
+      return getMissingString();
     }
     if (format == null) {
       return String.valueOf(value);
@@ -106,7 +108,7 @@ public class NumberColumnFormatter {
         + "format="
         + format
         + ", missingString='"
-        + missingString
+        + getMissingString()
         + '\''
         + '}';
   }
diff --git a/core/src/main/java/tech/tablesaw/columns/strings/StringColumnFormatter.java b/core/src/main/java/tech/tablesaw/columns/strings/StringColumnFormatter.java
index f801966d..b73fa4fe 100644
--- a/core/src/main/java/tech/tablesaw/columns/strings/StringColumnFormatter.java
+++ b/core/src/main/java/tech/tablesaw/columns/strings/StringColumnFormatter.java
@@ -1,29 +1,31 @@
 package tech.tablesaw.columns.strings;
 
 import java.util.function.Function;
+import tech.tablesaw.columns.ColumnFormatter;
 
-public class StringColumnFormatter {
+public class StringColumnFormatter extends ColumnFormatter {
 
   private final Function<String, String> formatter;
-  private String missingString = "";
 
   public StringColumnFormatter() {
+    super("");
     this.formatter = null;
   }
 
   public StringColumnFormatter(Function<String, String> formatFunction) {
+    super("");
     this.formatter = formatFunction;
   }
 
   public StringColumnFormatter(Function<String, String> formatFunction, String missingString) {
+    super(missingString);
     this.formatter = formatFunction;
-    this.missingString = missingString;
   }
 
   public String format(String value) {
 
     if (StringColumnType.missingValueIndicator().equals(value)) {
-      return missingString;
+      return getMissingString();
     }
     if (formatter == null) {
       return value;
@@ -37,7 +39,7 @@ public class StringColumnFormatter {
         + "format="
         + formatter
         + ", missingString='"
-        + missingString
+        + getMissingString()
         + '\''
         + '}';
   }
diff --git a/core/src/main/java/tech/tablesaw/columns/times/TimeColumnFormatter.java b/core/src/main/java/tech/tablesaw/columns/times/TimeColumnFormatter.java
index e1f8b6d6..5cabce9d 100644
--- a/core/src/main/java/tech/tablesaw/columns/times/TimeColumnFormatter.java
+++ b/core/src/main/java/tech/tablesaw/columns/times/TimeColumnFormatter.java
@@ -6,29 +6,27 @@ import static tech.tablesaw.columns.times.PackedLocalTime.toShortTimeString;
 import java.time.LocalTime;
 import java.time.format.DateTimeFormatter;
 import javax.annotation.concurrent.Immutable;
+import tech.tablesaw.columns.TemporalColumnFormatter;
 
 @Immutable
-public class TimeColumnFormatter {
+public class TimeColumnFormatter extends TemporalColumnFormatter {
 
-  private final DateTimeFormatter format;
-  private String missingString = "";
-
-  public TimeColumnFormatter() {
-    this.format = null;
+  public TimeColumnFormatter(DateTimeFormatter format) {
+    super(format);
   }
 
-  public TimeColumnFormatter(DateTimeFormatter format) {
-    this.format = format;
+  public TimeColumnFormatter() {
+    super();
   }
 
-  public TimeColumnFormatter(DateTimeFormatter format, String missingString) {
-    this.format = format;
-    this.missingString = missingString;
+  public TimeColumnFormatter(DateTimeFormatter format, String missingValueString) {
+    super(format, missingValueString);
   }
 
   public String format(int value) {
+    DateTimeFormatter format = getFormat();
     if (value == TimeColumnType.missingValueIndicator()) {
-      return missingString;
+      return getMissingString();
     }
     if (format == null) {
       return toShortTimeString(value);
@@ -44,9 +42,9 @@ public class TimeColumnFormatter {
   public String toString() {
     return "TimeColumnFormatter{"
         + "format="
-        + format
+        + getFormat()
         + ", missingString='"
-        + missingString
+        + getMissingString()
         + '\''
         + '}';
   }
diff --git a/core/src/main/java/tech/tablesaw/io/FileReader.java b/core/src/main/java/tech/tablesaw/io/FileReader.java
index d84ee956..e78abc70 100644
--- a/core/src/main/java/tech/tablesaw/io/FileReader.java
+++ b/core/src/main/java/tech/tablesaw/io/FileReader.java
@@ -181,6 +181,11 @@ public abstract class FileReader {
         (nextLine = reader.parseNext()) != null;
         rowNumber++) {
       // validation
+      if (options.skipRowsWithInvalidColumnCount()
+          && options.header()
+          && nextLine.length != types.length) {
+        continue;
+      }
       if (nextLine.length < types.length) {
         if (nextLine.length == 1 && Strings.isNullOrEmpty(nextLine[0])) {
           logger.error("Warning: Invalid file. Row " + rowNumber + " is empty. Continuing.");
diff --git a/core/src/main/java/tech/tablesaw/io/ReadOptions.java b/core/src/main/java/tech/tablesaw/io/ReadOptions.java
index 761d310c..ad9acaae 100644
--- a/core/src/main/java/tech/tablesaw/io/ReadOptions.java
+++ b/core/src/main/java/tech/tablesaw/io/ReadOptions.java
@@ -42,6 +42,7 @@ import tech.tablesaw.api.ColumnType;
 public class ReadOptions {
 
   public static final boolean DEFAULT_IGNORE_ZERO_DECIMAL = true;
+  public static final boolean DEFAULT_SKIP_ROWS_WITH_INVALID_COLUMN_COUNT = false;
 
   private static final List<ColumnType> DEFAULT_TYPES =
       Lists.newArrayList(
@@ -80,6 +81,7 @@ public class ReadOptions {
   protected final int maxCharsPerColumn;
   protected final boolean ignoreZeroDecimal;
   protected final boolean allowDuplicateColumnNames;
+  protected final boolean skipRowsWithInvalidColumnCount;
 
   protected final DateTimeFormatter dateFormatter;
   protected final DateTimeFormatter dateTimeFormatter;
@@ -100,6 +102,7 @@ public class ReadOptions {
     header = builder.header;
     maxCharsPerColumn = builder.maxCharsPerColumn;
     ignoreZeroDecimal = builder.ignoreZeroDecimal;
+    skipRowsWithInvalidColumnCount = builder.skipRowsWithInvalidColumnCount;
 
     dateFormatter = builder.dateFormatter;
     timeFormatter = builder.timeFormatter;
@@ -154,6 +157,10 @@ public class ReadOptions {
     return ignoreZeroDecimal;
   }
 
+  public boolean skipRowsWithInvalidColumnCount() {
+    return skipRowsWithInvalidColumnCount;
+  }
+
   public DateTimeFormatter dateTimeFormatter() {
     if (dateTimeFormatter != null) {
       return dateTimeFormatter;
@@ -203,6 +210,7 @@ public class ReadOptions {
     protected boolean header = true;
     protected int maxCharsPerColumn = 4096;
     protected boolean ignoreZeroDecimal = DEFAULT_IGNORE_ZERO_DECIMAL;
+    protected boolean skipRowsWithInvalidColumnCount = DEFAULT_SKIP_ROWS_WITH_INVALID_COLUMN_COUNT;
     private boolean allowDuplicateColumnNames = false;
 
     protected Builder() {
@@ -302,6 +310,12 @@ public class ReadOptions {
       return this;
     }
 
+    /** Skip the rows with invalid column count in data values. Defaluts to {@code false}. */
+    public Builder skipRowsWithInvalidColumnCount(boolean skipRowsWithInvalidColumnCount) {
+      this.skipRowsWithInvalidColumnCount = skipRowsWithInvalidColumnCount;
+      return this;
+    }
+
     public Builder sample(boolean sample) {
       this.sample = sample;
       return this;
diff --git a/core/src/main/java/tech/tablesaw/io/csv/CsvReadOptions.java b/core/src/main/java/tech/tablesaw/io/csv/CsvReadOptions.java
index 0e6ce8cb..c9d3a3c0 100644
--- a/core/src/main/java/tech/tablesaw/io/csv/CsvReadOptions.java
+++ b/core/src/main/java/tech/tablesaw/io/csv/CsvReadOptions.java
@@ -364,5 +364,11 @@ public class CsvReadOptions extends ReadOptions {
       super.ignoreZeroDecimal(ignoreZeroDecimal);
       return this;
     }
+
+    @Override
+    public Builder skipRowsWithInvalidColumnCount(boolean skipRowsWithInvalidColumnCount) {
+      super.skipRowsWithInvalidColumnCount(skipRowsWithInvalidColumnCount);
+      return this;
+    }
   }
 }
diff --git a/core/src/main/java/tech/tablesaw/io/csv/CsvWriteOptions.java b/core/src/main/java/tech/tablesaw/io/csv/CsvWriteOptions.java
index 2a497c9f..ac18ecb7 100644
--- a/core/src/main/java/tech/tablesaw/io/csv/CsvWriteOptions.java
+++ b/core/src/main/java/tech/tablesaw/io/csv/CsvWriteOptions.java
@@ -16,6 +16,7 @@ public class CsvWriteOptions extends WriteOptions {
   private final boolean header;
   private final boolean ignoreLeadingWhitespaces;
   private final boolean ignoreTrailingWhitespaces;
+  private final boolean usePrintFormatter;
   private final Character separator;
   private final Character quoteChar;
   private final Character escapeChar;
@@ -38,6 +39,7 @@ public class CsvWriteOptions extends WriteOptions {
     this.dateFormatter = builder.dateFormatter;
     this.dateTimeFormatter = builder.dateTimeFormatter;
     this.columnNameMap = builder.columnNameMap;
+    this.usePrintFormatter = builder.usePrintFormatters;
   }
 
   public boolean header() {
@@ -64,6 +66,10 @@ public class CsvWriteOptions extends WriteOptions {
     return quoteAllFields;
   }
 
+  public boolean usePrintFormatters() {
+    return usePrintFormatter;
+  }
+
   public Map<String, String> columnNameMap() {
     return columnNameMap;
   }
@@ -110,6 +116,7 @@ public class CsvWriteOptions extends WriteOptions {
     private boolean ignoreLeadingWhitespaces = true;
     private boolean ignoreTrailingWhitespaces = true;
     private boolean quoteAllFields = false;
+    private boolean usePrintFormatters = false;
     private Character separator;
     private String lineEnd = System.lineSeparator();
     private Character escapeChar;
@@ -159,16 +166,37 @@ public class CsvWriteOptions extends WriteOptions {
       return this;
     }
 
+    /**
+     * Writes date column output using the given DateFormatter
+     *
+     * @deprecated
+     */
+    @Deprecated
     public CsvWriteOptions.Builder dateFormatter(DateTimeFormatter dateFormatter) {
       this.dateFormatter = dateFormatter;
       return this;
     }
 
+    /**
+     * Writes DateTime column output using the given DateFormatter
+     *
+     * @deprecated
+     */
+    @Deprecated
     public CsvWriteOptions.Builder dateTimeFormatter(DateTimeFormatter dateTimeFormatter) {
       this.dateTimeFormatter = dateTimeFormatter;
       return this;
     }
 
+    /**
+     * Sets the usePrintFormatters option @link{tech.tablesaw.columns.ColumnFormatter} When true,
+     * printFormatters will be used in writing the output text for any column that has one.
+     */
+    public CsvWriteOptions.Builder usePrintFormatters(boolean useFormatter) {
+      this.usePrintFormatters = useFormatter;
+      return this;
+    }
+
     /**
      * Causes all data exported as a CSV file to be enclosed in quotes. Note that this includes the
      * headers, and all columns regardless of type
diff --git a/core/src/main/java/tech/tablesaw/io/csv/CsvWriter.java b/core/src/main/java/tech/tablesaw/io/csv/CsvWriter.java
index 4a3052c1..c2cbfc5b 100644
--- a/core/src/main/java/tech/tablesaw/io/csv/CsvWriter.java
+++ b/core/src/main/java/tech/tablesaw/io/csv/CsvWriter.java
@@ -50,30 +50,11 @@ public final class CsvWriter implements DataWriter<CsvWriteOptions> {
       csvWriter =
           new com.univocity.parsers.csv.CsvWriter(options.destination().createWriter(), settings);
 
-      if (options.header()) {
-        String[] header = new String[table.columnCount()];
-        for (int c = 0; c < table.columnCount(); c++) {
-          String name = table.column(c).name();
-          header[c] = options.columnNameMap().getOrDefault(name, name);
-        }
-        csvWriter.writeHeaders(header);
-      }
+      writeHeader(table, options, csvWriter);
       for (int r = 0; r < table.rowCount(); r++) {
         String[] entries = new String[table.columnCount()];
         for (int c = 0; c < table.columnCount(); c++) {
-          table.get(r, c);
-          DateTimeFormatter dateFormatter = options.dateFormatter();
-          DateTimeFormatter dateTimeFormatter = options.dateTimeFormatter();
-          ColumnType columnType = table.column(c).type();
-          if (dateFormatter != null && columnType.equals(ColumnType.LOCAL_DATE)) {
-            DateColumn dc = (DateColumn) table.column(c);
-            entries[c] = options.dateFormatter().format(dc.get(r));
-          } else if (dateTimeFormatter != null && columnType.equals(ColumnType.LOCAL_DATE_TIME)) {
-            DateTimeColumn dc = (DateTimeColumn) table.column(c);
-            entries[c] = options.dateTimeFormatter().format(dc.get(r));
-          } else {
-            entries[c] = table.getUnformatted(r, c);
-          }
+          writeValues(table, options, r, entries, c);
         }
         csvWriter.writeRow(entries);
       }
@@ -85,6 +66,37 @@ public final class CsvWriter implements DataWriter<CsvWriteOptions> {
     }
   }
 
+  private void writeValues(Table table, CsvWriteOptions options, int r, String[] entries, int c) {
+    DateTimeFormatter dateFormatter = options.dateFormatter();
+    DateTimeFormatter dateTimeFormatter = options.dateTimeFormatter();
+    ColumnType columnType = table.column(c).type();
+    if (dateFormatter != null && columnType.equals(ColumnType.LOCAL_DATE)) {
+      DateColumn dc = (DateColumn) table.column(c);
+      entries[c] = options.dateFormatter().format(dc.get(r));
+    } else if (dateTimeFormatter != null && columnType.equals(ColumnType.LOCAL_DATE_TIME)) {
+      DateTimeColumn dc = (DateTimeColumn) table.column(c);
+      entries[c] = options.dateTimeFormatter().format(dc.get(r));
+    } else {
+      if (options.usePrintFormatters()) {
+        entries[c] = table.getString(r, c);
+      } else {
+        entries[c] = table.getUnformatted(r, c);
+      }
+    }
+  }
+
+  private void writeHeader(
+      Table table, CsvWriteOptions options, com.univocity.parsers.csv.CsvWriter csvWriter) {
+    if (options.header()) {
+      String[] header = new String[table.columnCount()];
+      for (int c = 0; c < table.columnCount(); c++) {
+        String name = table.column(c).name();
+        header[c] = options.columnNameMap().getOrDefault(name, name);
+      }
+      csvWriter.writeHeaders(header);
+    }
+  }
+
   protected static CsvWriterSettings createSettings(CsvWriteOptions options) {
     CsvWriterSettings settings = new CsvWriterSettings();
     // Sets the character sequence to write for the values that are null.
diff --git a/core/src/main/java/tech/tablesaw/joining/DataFrameJoiner.java b/core/src/main/java/tech/tablesaw/joining/DataFrameJoiner.java
index fd043cda..f1bb9f7b 100644
--- a/core/src/main/java/tech/tablesaw/joining/DataFrameJoiner.java
+++ b/core/src/main/java/tech/tablesaw/joining/DataFrameJoiner.java
@@ -14,22 +14,11 @@ import tech.tablesaw.columns.booleans.BooleanColumnType;
 import tech.tablesaw.columns.dates.DateColumnType;
 import tech.tablesaw.columns.datetimes.DateTimeColumnType;
 import tech.tablesaw.columns.instant.InstantColumnType;
-import tech.tablesaw.columns.numbers.DoubleColumnType;
-import tech.tablesaw.columns.numbers.FloatColumnType;
-import tech.tablesaw.columns.numbers.IntColumnType;
-import tech.tablesaw.columns.numbers.LongColumnType;
-import tech.tablesaw.columns.numbers.ShortColumnType;
+import tech.tablesaw.columns.numbers.*;
 import tech.tablesaw.columns.strings.StringColumnType;
 import tech.tablesaw.columns.strings.TextColumnType;
 import tech.tablesaw.columns.times.TimeColumnType;
-import tech.tablesaw.index.ByteIndex;
-import tech.tablesaw.index.DoubleIndex;
-import tech.tablesaw.index.FloatIndex;
-import tech.tablesaw.index.Index;
-import tech.tablesaw.index.IntIndex;
-import tech.tablesaw.index.LongIndex;
-import tech.tablesaw.index.ShortIndex;
-import tech.tablesaw.index.StringIndex;
+import tech.tablesaw.index.*;
 import tech.tablesaw.selection.Selection;
 
 public class DataFrameJoiner {
@@ -169,6 +158,7 @@ public class DataFrameJoiner {
    *     column have the same name if {@code true} the join will succeed and duplicate columns are
    *     renamed
    * @param table2JoinColumnNames The names of the columns in table2 to join on.
+   * @return the joined table
    */
   private Table joinInternal(
       Table table1,
@@ -176,7 +166,10 @@ public class DataFrameJoiner {
       JoinType joinType,
       boolean allowDuplicates,
       String... table2JoinColumnNames) {
+
     List<Integer> table2JoinColumnIndexes = getJoinIndexes(table2, table2JoinColumnNames);
+    List<Index> table1Indexes = buildIndexesForJoinColumns(joinColumnIndexes, table1);
+    List<Index> table2Indexes = buildIndexesForJoinColumns(table2JoinColumnIndexes, table2);
 
     Table result = Table.create(table1.name());
     // A set of column indexes in the result table that can be ignored. They are duplicate join
@@ -185,10 +178,7 @@ public class DataFrameJoiner {
         emptyTableFromColumns(
             result, table1, table2, joinType, allowDuplicates, table2JoinColumnIndexes);
 
-    List<Index> table1Indexes = buildIndexesForJoinColumns(joinColumnIndexes, table1);
-    List<Index> table2Indexes = buildIndexesForJoinColumns(table2JoinColumnIndexes, table2);
     validateIndexes(table1Indexes, table2Indexes);
-
     if (table1.rowCount() == 0 && (joinType == JoinType.LEFT_OUTER || joinType == JoinType.INNER)) {
       // Handle special case of empty table here so it doesn't fall through to the behavior
       // that adds rows for full outer and right outer joins
@@ -198,31 +188,59 @@ public class DataFrameJoiner {
 
     Selection table1DoneRows = Selection.with();
     Selection table2DoneRows = Selection.with();
-    for (Row row : table1) {
-      int ri = row.getRowNumber();
-      if (table1DoneRows.contains(ri)) {
-        // Already processed a selection of table1 that contained this row.
-        continue;
-      }
-
-      Selection table1Rows = createMultiColSelection(table1, ri, table1Indexes, table1.rowCount());
-      Selection table2Rows = createMultiColSelection(table1, ri, table2Indexes, table2.rowCount());
+    // use table 2 for row iteration, which can significantly increase performance
+    if (table1.rowCount() > table2.rowCount() && joinType == JoinType.INNER) {
+      for (Row row : table2) {
+        int ri = row.getRowNumber();
+        if (table2DoneRows.contains(ri)) {
+          // Already processed a selection of table1 that contained this row.
+          continue;
+        }
+        Selection table1Rows =
+            createMultiColSelection(
+                table2, ri, table1Indexes, table1.rowCount(), table2JoinColumnIndexes);
+        Selection table2Rows =
+            createMultiColSelection(
+                table2, ri, table2Indexes, table2.rowCount(), table2JoinColumnIndexes);
 
-      if ((joinType == JoinType.LEFT_OUTER || joinType == JoinType.FULL_OUTER)
-          && table2Rows.isEmpty()) {
-        withMissingLeftJoin(result, table1, table1Rows, resultIgnoreColIndexes);
-      } else {
         crossProduct(result, table1, table2, table1Rows, table2Rows, resultIgnoreColIndexes);
-      }
 
-      table1DoneRows = table1DoneRows.or(table1Rows);
-      if (joinType == JoinType.FULL_OUTER || joinType == JoinType.RIGHT_OUTER) {
-        // Update done rows in table2 for full Outer.
         table2DoneRows = table2DoneRows.or(table2Rows);
-      } else if (table1DoneRows.size() == table1.rowCount()) {
-        // Processed all the rows in table1 exit early.
-        result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
-        return result;
+        if (table2DoneRows.size() == table2.rowCount()) {
+          // Processed all the rows in table1 exit early.
+          result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+          return result;
+        }
+      }
+    } else {
+      for (Row row : table1) {
+        int ri = row.getRowNumber();
+        if (table1DoneRows.contains(ri)) {
+          // Already processed a selection of table1 that contained this row.
+          continue;
+        }
+        Selection table1Rows =
+            createMultiColSelection(
+                table1, ri, table1Indexes, table1.rowCount(), joinColumnIndexes);
+        Selection table2Rows =
+            createMultiColSelection(
+                table1, ri, table2Indexes, table2.rowCount(), joinColumnIndexes);
+        if ((joinType == JoinType.LEFT_OUTER || joinType == JoinType.FULL_OUTER)
+            && table2Rows.isEmpty()) {
+          withMissingLeftJoin(result, table1, table1Rows, resultIgnoreColIndexes);
+        } else {
+          crossProduct(result, table1, table2, table1Rows, table2Rows, resultIgnoreColIndexes);
+        }
+
+        table1DoneRows = table1DoneRows.or(table1Rows);
+        if (joinType == JoinType.FULL_OUTER || joinType == JoinType.RIGHT_OUTER) {
+          // Update done rows in table2 for full Outer.
+          table2DoneRows = table2DoneRows.or(table2Rows);
+        } else if (table1DoneRows.size() == table1.rowCount()) {
+          // Processed all the rows in table1 exit early.
+          result.removeColumns(Ints.toArray(resultIgnoreColIndexes));
+          return result;
+        }
       }
     }
 
@@ -355,14 +373,26 @@ public class DataFrameJoiner {
               + valueColumn.type());
     }
   }
-
-  /** Create a big multicolumn selection for all join columns in the given table. */
+  /**
+   * Create a big multicolumn selection for all join columns in the given table. Joins two tables.
+   *
+   * @param table the table that used to generate Selection.
+   * @param ri row number of row in table.
+   * @param indexes a reverse index for every join column in the table.
+   * @param selectionSize max size in table .
+   * @param joinColumnIndexes the column index of join key in tables
+   * @return selection created
+   */
   private Selection createMultiColSelection(
-      Table table1, int ri, List<Index> indexes, int selectionSize) {
+      Table table,
+      int ri,
+      List<Index> indexes,
+      int selectionSize,
+      List<Integer> joinColumnIndexes) {
     Selection multiColSelection = Selection.withRange(0, selectionSize);
     int i = 0;
     for (Integer joinColumnIndex : joinColumnIndexes) {
-      Column<?> col = table1.column(joinColumnIndex);
+      Column<?> col = table.column(joinColumnIndex);
       Selection oneColSelection = selectionForColumn(col, ri, indexes.get(i));
       // and the selections.
       multiColSelection = multiColSelection.and(oneColSelection);
@@ -442,7 +472,9 @@ public class DataFrameJoiner {
   public Table leftOuter(boolean allowDuplicateColumnNames, Table... tables) {
     Table joined = table;
     for (Table table2 : tables) {
-      joined = leftOuter(table2, allowDuplicateColumnNames, joinColumnNames);
+      joined =
+          joinInternal(
+              joined, table2, JoinType.LEFT_OUTER, allowDuplicateColumnNames, joinColumnNames);
     }
     return joined;
   }
@@ -511,7 +543,11 @@ public class DataFrameJoiner {
   public Table rightOuter(boolean allowDuplicateColumnNames, Table... tables) {
     Table joined = table;
     for (Table table2 : tables) {
-      joined = rightOuter(table2, allowDuplicateColumnNames, joinColumnNames);
+      joined =
+          joinInternal(
+              joined, table2, JoinType.RIGHT_OUTER, allowDuplicateColumnNames, joinColumnNames);
+      joinColumnIndexes.clear();
+      joinColumnIndexes.addAll(getJoinIndexes(joined, joinColumnNames));
     }
     return joined;
   }
diff --git a/pom.xml b/pom.xml
index aa80f382..506d541d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -217,7 +217,7 @@
             <plugin>
                 <groupId>org.jacoco</groupId>
                 <artifactId>jacoco-maven-plugin</artifactId>
-                <version>0.8.3</version>
+                <version>0.8.6</version>
                 <executions>
                     <execution>
                         <id>prepare-agent</id>
@@ -238,12 +238,11 @@
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-surefire-plugin</artifactId>
                 <configuration>
-                    <argLine>-Duser.language=en-US -Duser.region=US</argLine>
                     <printSummary>false</printSummary>
                     <!-- Jacoco prepare-agent builds some command-line params without -->
                     <!-- which jacoco will not instrument. Hence it is important to add -->
                     <!-- those command-line params here (${argLine} holds those params) -->
-                    <argLine>${argLine} -Xms256m -Xmx2048m</argLine>
+                    <argLine>${argLine} -Xms256m -Xmx2048m -Duser.language=en-US -Duser.region=US</argLine>
                     <forkCount>1</forkCount>
                     <runOrder>random</runOrder>
                 </configuration>


Test patch:
diff --git a/core/src/test/java/tech/tablesaw/analytic/AnalyticQueryTest.java b/core/src/test/java/tech/tablesaw/analytic/AnalyticQueryTest.java
index 2353c26c..05c62907 100644
--- a/core/src/test/java/tech/tablesaw/analytic/AnalyticQueryTest.java
+++ b/core/src/test/java/tech/tablesaw/analytic/AnalyticQueryTest.java
@@ -13,6 +13,8 @@ import tech.tablesaw.api.Table;
 
 class AnalyticQueryTest {
 
+  private static final String LINE_END = System.lineSeparator();
+
   @Test
   public void testToSqlString() {
     Table table = Table.create("table1", IntColumn.create("sales"));
@@ -31,17 +33,17 @@ class AnalyticQueryTest {
 
     String expected =
         "SELECT"
-            + System.lineSeparator()
+            + LINE_END
             + "SUM(sales) OVER w1 AS sumSales"
-            + System.lineSeparator()
+            + LINE_END
             + "FROM table1"
-            + System.lineSeparator()
+            + LINE_END
             + "Window w1 AS ("
-            + System.lineSeparator()
+            + LINE_END
             + "PARTITION BY product, region"
-            + System.lineSeparator()
+            + LINE_END
             + "ORDER BY sales ASC"
-            + System.lineSeparator()
+            + LINE_END
             + "ROWS BETWEEN UNBOUNDED_PRECEDING AND UNBOUNDED_FOLLOWING);";
 
     assertEquals(expected, query.toSqlLikeString());
@@ -60,10 +62,14 @@ class AnalyticQueryTest {
             .build();
 
     String expectd =
-        "SELECT\n"
-            + "MAX(sales) OVER w1 AS salesSum\n"
-            + "FROM sales\n"
-            + "Window w1 AS (\n"
+        "SELECT"
+            + LINE_END
+            + "MAX(sales) OVER w1 AS salesSum"
+            + LINE_END
+            + "FROM sales"
+            + LINE_END
+            + "Window w1 AS ("
+            + LINE_END
             + "ROWS BETWEEN CURRENT_ROW AND 1 FOLLOWING);";
 
     assertEquals(expectd, query.toSqlLikeString());
@@ -81,10 +87,14 @@ class AnalyticQueryTest {
             .build();
 
     String expectd =
-        "SELECT\n"
-            + "RANK() OVER w1 AS myRank\n"
-            + "FROM myTable\n"
-            + "Window w1 AS (\n"
+        "SELECT"
+            + LINE_END
+            + "RANK() OVER w1 AS myRank"
+            + LINE_END
+            + "FROM myTable"
+            + LINE_END
+            + "Window w1 AS ("
+            + LINE_END
             + "ORDER BY date ASC, region ASC);";
 
     assertEquals(expectd, query.toSqlLikeString());
diff --git a/core/src/test/java/tech/tablesaw/api/StringColumnTest.java b/core/src/test/java/tech/tablesaw/api/StringColumnTest.java
index 7f1e22d5..aaacf51a 100644
--- a/core/src/test/java/tech/tablesaw/api/StringColumnTest.java
+++ b/core/src/test/java/tech/tablesaw/api/StringColumnTest.java
@@ -21,6 +21,8 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static tech.tablesaw.columns.strings.StringPredicates.isEqualToIgnoringCase;
 
+import com.google.common.collect.Lists;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
@@ -29,6 +31,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import tech.tablesaw.TestDataUtil;
 import tech.tablesaw.columns.strings.StringColumnFormatter;
+import tech.tablesaw.columns.strings.StringParser;
 import tech.tablesaw.selection.Selection;
 
 class StringColumnTest {
@@ -63,6 +66,21 @@ class StringColumnTest {
     assertArrayEquals(sc.asList().toArray(), sc.asObjectArray());
   }
 
+  @Test
+  void appendAsterisk() {
+    final StringColumn sc = StringColumn.create("sc");
+    sc.append("*");
+    assertEquals(0, sc.countMissing());
+    StringParser parser = new StringParser(ColumnType.TEXT);
+    parser.setMissingValueStrings(new ArrayList<>());
+    sc.appendCell("*", parser);
+    assertEquals(0, sc.countMissing());
+    StringParser parser2 = new StringParser(ColumnType.TEXT);
+    parser2.setMissingValueStrings(Lists.newArrayList("45"));
+    sc.appendCell("45", parser2);
+    assertEquals(1, sc.countMissing());
+  }
+
   @Test
   void testForNulls() {
     String[] array1 = {"1", "2", "3", "4", null};
diff --git a/core/src/test/java/tech/tablesaw/api/TableTest.java b/core/src/test/java/tech/tablesaw/api/TableTest.java
index c4234f07..6b1a5843 100644
--- a/core/src/test/java/tech/tablesaw/api/TableTest.java
+++ b/core/src/test/java/tech/tablesaw/api/TableTest.java
@@ -351,8 +351,10 @@ public class TableTest {
   void melt() throws Exception {
     boolean dropMissing = false;
     String df =
-        "subject, time, age, weight, height\n"
-            + "John Smith,    1,  33,     90,   1.87\n"
+        "subject, time, age, weight, height"
+            + LINE_END
+            + "John Smith,    1,  33,     90,   1.87"
+            + LINE_END
             + "Mary Smith,    1,  NA,     NA,   1.54";
     StringReader reader = new StringReader(df);
     Table t = Table.read().csv(reader);
@@ -362,14 +364,22 @@ public class TableTest {
 
     Table melted = t.melt(ids, measures, dropMissing);
     assertEquals(
-        "                                              \n"
-            + "  subject    |  time  |  variable  |  value  |\n"
-            + "----------------------------------------------\n"
-            + " John Smith  |     1  |       age  |     33  |\n"
-            + " John Smith  |     1  |    weight  |     90  |\n"
-            + " John Smith  |     1  |    height  |   1.87  |\n"
-            + " Mary Smith  |     1  |       age  |         |\n"
-            + " Mary Smith  |     1  |    weight  |         |\n"
+        "                                              "
+            + LINE_END
+            + "  subject    |  time  |  variable  |  value  |"
+            + LINE_END
+            + "----------------------------------------------"
+            + LINE_END
+            + " John Smith  |     1  |       age  |     33  |"
+            + LINE_END
+            + " John Smith  |     1  |    weight  |     90  |"
+            + LINE_END
+            + " John Smith  |     1  |    height  |   1.87  |"
+            + LINE_END
+            + " Mary Smith  |     1  |       age  |         |"
+            + LINE_END
+            + " Mary Smith  |     1  |    weight  |         |"
+            + LINE_END
             + " Mary Smith  |     1  |    height  |   1.54  |",
         melted.toString());
   }
@@ -378,8 +388,10 @@ public class TableTest {
   void meltAndDropMissing() throws Exception {
     boolean dropMissing = true;
     String df =
-        "subject, time, age, weight, height\n"
-            + "John Smith,    1,  33,     90,   1.87\n"
+        "subject, time, age, weight, height"
+            + LINE_END
+            + "John Smith,    1,  33,     90,   1.87"
+            + LINE_END
             + "Mary Smith,    1,  NA,     NA,   1.54";
     StringReader reader = new StringReader(df);
     Table t = Table.read().csv(reader);
@@ -390,12 +402,18 @@ public class TableTest {
     Table melted = t.melt(ids, measures, dropMissing);
     melted.write().csv("../data/molten_smiths_drop_missing.csv");
     assertEquals(
-        "                                              \n"
-            + "  subject    |  time  |  variable  |  value  |\n"
-            + "----------------------------------------------\n"
-            + " John Smith  |     1  |       age  |     33  |\n"
-            + " John Smith  |     1  |    weight  |     90  |\n"
-            + " John Smith  |     1  |    height  |   1.87  |\n"
+        "                                              "
+            + LINE_END
+            + "  subject    |  time  |  variable  |  value  |"
+            + LINE_END
+            + "----------------------------------------------"
+            + LINE_END
+            + " John Smith  |     1  |       age  |     33  |"
+            + LINE_END
+            + " John Smith  |     1  |    weight  |     90  |"
+            + LINE_END
+            + " John Smith  |     1  |    height  |   1.87  |"
+            + LINE_END
             + " Mary Smith  |     1  |    height  |   1.54  |",
         melted.toString());
   }
@@ -408,9 +426,12 @@ public class TableTest {
     cast.write().csv(writer);
     String writeString = writer.toString();
     assertEquals(
-        "subject,time,weight,age,height\n"
-            + "John Smith,1,90.0,33.0,1.87\n"
-            + "Mary Smith,1,,,1.54\n",
+        "subject,time,weight,age,height"
+            + LINE_END
+            + "John Smith,1,90.0,33.0,1.87"
+            + LINE_END
+            + "Mary Smith,1,,,1.54"
+            + LINE_END,
         writeString);
   }
 
@@ -422,9 +443,12 @@ public class TableTest {
     cast.write().csv(writer);
     String writeString = writer.toString();
     assertEquals(
-        "subject,time,weight,age,height\n"
-            + "John Smith,1,90.0,33.0,1.87\n"
-            + "Mary Smith,1,,,1.54\n",
+        "subject,time,weight,age,height"
+            + LINE_END
+            + "John Smith,1,90.0,33.0,1.87"
+            + LINE_END
+            + "Mary Smith,1,,,1.54"
+            + LINE_END,
         writeString);
   }
 
diff --git a/core/src/test/java/tech/tablesaw/api/TextColumnTest.java b/core/src/test/java/tech/tablesaw/api/TextColumnTest.java
index a30bb142..9476f1d7 100644
--- a/core/src/test/java/tech/tablesaw/api/TextColumnTest.java
+++ b/core/src/test/java/tech/tablesaw/api/TextColumnTest.java
@@ -21,6 +21,7 @@ import static tech.tablesaw.columns.strings.StringPredicates.isEqualToIgnoringCa
 
 import com.google.common.base.Joiner;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.function.Function;
@@ -28,6 +29,7 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import tech.tablesaw.TestDataUtil;
 import tech.tablesaw.columns.strings.StringColumnFormatter;
+import tech.tablesaw.columns.strings.StringParser;
 import tech.tablesaw.io.csv.CsvReadOptions;
 import tech.tablesaw.selection.Selection;
 
@@ -109,6 +111,22 @@ public class TextColumnTest {
     assertEquals(3, joined.columnCount());
   }
 
+  @Test
+  void appendCellAsterisk() {
+    final TextColumn sc = TextColumn.create("sc");
+    StringParser parser = new StringParser(ColumnType.TEXT);
+    parser.setMissingValueStrings(new ArrayList<>());
+    sc.appendCell("*", parser);
+    assertEquals(0, sc.countMissing());
+  }
+
+  @Test
+  void appendAsterisk() {
+    final TextColumn sc = TextColumn.create("sc");
+    sc.append("*");
+    assertEquals(0, sc.countMissing());
+  }
+
   @Test
   public void lead() {
     TextColumn c1 = column.lead(1);
diff --git a/core/src/test/java/tech/tablesaw/io/DataFrameReaderTest.java b/core/src/test/java/tech/tablesaw/io/DataFrameReaderTest.java
index 1da7e46e..374b11af 100644
--- a/core/src/test/java/tech/tablesaw/io/DataFrameReaderTest.java
+++ b/core/src/test/java/tech/tablesaw/io/DataFrameReaderTest.java
@@ -26,7 +26,8 @@ class DataFrameReaderTest {
   }
 
   private URL mockUrlHelper(String url, List<String> content) throws Exception {
-    Path path = mockFileHelper(url, content);
+    // Remove http:// part to be able to save to a local filesystem file
+    Path path = mockFileHelper(url.replace("http://", ""), content);
     return path.toUri().toURL();
   }
 
@@ -40,7 +41,7 @@ class DataFrameReaderTest {
 
   @Test
   public void csv() throws IOException {
-    Path path = mockFileHelper("/data/file.csv", ImmutableList.of("region", "canada", "us"));
+    Path path = mockFileHelper("data/file.csv", ImmutableList.of("region", "canada", "us"));
     Table expected = Table.create(StringColumn.create("region", new String[] {"canada", "us"}));
     Table actual = Table.read().csv(Files.newInputStream(path));
     assertEquals(expected.columnNames(), actual.columnNames());
diff --git a/core/src/test/java/tech/tablesaw/io/csv/CsvReaderTest.java b/core/src/test/java/tech/tablesaw/io/csv/CsvReaderTest.java
index c126144c..08662487 100644
--- a/core/src/test/java/tech/tablesaw/io/csv/CsvReaderTest.java
+++ b/core/src/test/java/tech/tablesaw/io/csv/CsvReaderTest.java
@@ -826,4 +826,29 @@ public class CsvReaderTest {
     Table out = Table.read().csv(new StringReader(string));
     assertEquals(table.get(0, 0), out.get(0, 0));
   }
+
+  @Test
+  public void testSkipRowsWithInvalidColumnCount() throws IOException {
+    Table table =
+        Table.read()
+            .csv(
+                CsvReadOptions.builder("../data/short_row.csv")
+                    .skipRowsWithInvalidColumnCount(true)
+                    .build());
+    assertEquals(2, table.rowCount());
+  }
+
+  @Test
+  public void skipRowsWithInvalidColumnCountWithoutHeader() throws IOException {
+    assertThrows(
+        AddCellToColumnException.class,
+        () -> {
+          Table.read()
+              .csv(
+                  CsvReadOptions.builder("../data/short_row.csv")
+                      .header(false)
+                      .skipRowsWithInvalidColumnCount(true)
+                      .build());
+        });
+  }
 }
diff --git a/core/src/test/java/tech/tablesaw/io/csv/CsvWriterTest.java b/core/src/test/java/tech/tablesaw/io/csv/CsvWriterTest.java
index 509a44aa..0792e02c 100644
--- a/core/src/test/java/tech/tablesaw/io/csv/CsvWriterTest.java
+++ b/core/src/test/java/tech/tablesaw/io/csv/CsvWriterTest.java
@@ -6,18 +6,23 @@ import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import java.io.IOException;
 import java.io.StringWriter;
+import java.text.DecimalFormat;
+import java.time.Instant;
+import java.time.LocalDate;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.Map;
 import org.junit.jupiter.api.Test;
-import tech.tablesaw.api.DateTimeColumn;
-import tech.tablesaw.api.StringColumn;
-import tech.tablesaw.api.Table;
+import tech.tablesaw.api.*;
+import tech.tablesaw.columns.booleans.BooleanFormatter;
+import tech.tablesaw.columns.instant.InstantColumnFormatter;
+import tech.tablesaw.columns.numbers.NumberColumnFormatter;
+import tech.tablesaw.columns.strings.StringColumnFormatter;
 
 public class CsvWriterTest {
 
   @Test
-  public void toWriterWithExtension() throws IOException {
+  void toWriterWithExtension() throws IOException {
     StringColumn colA = StringColumn.create("colA", ImmutableList.of("a", "b"));
     StringColumn colB = StringColumn.create("colB", ImmutableList.of("1", "2"));
     Table table = Table.create("testTable", colA, colB);
@@ -27,7 +32,7 @@ public class CsvWriterTest {
   }
 
   @Test
-  public void quoteAll() throws IOException {
+  void quoteAll() throws IOException {
     StringColumn colA = StringColumn.create("colA", ImmutableList.of("a", "b"));
     StringColumn colB = StringColumn.create("colB", ImmutableList.of("1", "2"));
     Table table = Table.create("testTable", colA, colB);
@@ -39,7 +44,7 @@ public class CsvWriterTest {
   }
 
   @Test
-  public void dateFormatter() throws IOException {
+  void dateFormatter() throws IOException {
     Table table = Table.read().csv("../data/bush.csv").rows(1);
     StringWriter writer = new StringWriter();
     table
@@ -54,7 +59,156 @@ public class CsvWriterTest {
   }
 
   @Test
-  public void dateTimeFormatter() throws IOException {
+  void printFormatter_double() throws IOException {
+    Table table = Table.create("", DoubleColumn.create("percents"));
+    table.doubleColumn("percents").setPrintFormatter(NumberColumnFormatter.percent(2));
+    table.doubleColumn("percents").append(0.323).append(0.1192).append(1.0);
+    StringWriter writer = new StringWriter();
+    table.write().usingOptions(CsvWriteOptions.builder(writer).usePrintFormatters(true).build());
+    assertEquals(
+        "percents\n" + "32.30%\n" + "11.92%\n" + "100.00%\n",
+        writer.toString().replaceAll("\\r\\n", "\n"));
+  }
+
+  @Test
+  void printFormatter_date() throws IOException {
+    Table table = Table.create("", DateColumn.create("dates"));
+    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-dd-MMM");
+    table.dateColumn("dates").setPrintFormatter(formatter, "WHAT?");
+    table
+        .dateColumn("dates")
+        .append(LocalDate.of(2021, 11, 3))
+        .appendObj(null)
+        .append(LocalDate.of(2021, 3, 11));
+    StringWriter writer = new StringWriter();
+    table.write().usingOptions(CsvWriteOptions.builder(writer).usePrintFormatters(true).build());
+    assertEquals(
+        "dates\n" + "2021-03-Nov\n" + "WHAT?\n" + "2021-11-Mar\n",
+        writer.toString().replaceAll("\\r\\n", "\n"));
+  }
+
+  @Test
+  void printFormatter_int() throws IOException {
+    Table table = Table.create("", IntColumn.create("ints"));
+    table.intColumn("ints").setPrintFormatter(NumberColumnFormatter.intsWithGrouping());
+    table.intColumn("ints").append(102_123).append(2).append(-1_232_132);
+    StringWriter writer = new StringWriter();
+    table.write().usingOptions(CsvWriteOptions.builder(writer).usePrintFormatters(true).build());
+    assertEquals(
+        "ints\n" + "\"102,123\"\n" + "2\n" + "\"-1,232,132\"\n",
+        writer.toString().replaceAll("\\r\\n", "\n"));
+  }
+
+  @Test
+  void printFormatter_float() throws IOException {
+    Table table = Table.create("", FloatColumn.create("floats"));
+    table.floatColumn("floats").setPrintFormatter(NumberColumnFormatter.fixedWithGrouping(2));
+    table.floatColumn("floats").append(032.3f).append(0.1192f).appendObj(null).append(1001.0f);
+    StringWriter writer = new StringWriter();
+    table.write().usingOptions(CsvWriteOptions.builder(writer).usePrintFormatters(true).build());
+    assertEquals(
+        "floats\n" + "32.30\n" + "0.12\n" + "\n" + "\"1,001.00\"\n",
+        writer.toString().replaceAll("\\r\\n", "\n"));
+  }
+
+  @Test
+  void printFormatter_datetime() throws IOException {
+    Table table = Table.create("", DateTimeColumn.create("dates"));
+    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMM d, yyyy - hh:mm");
+    table.dateTimeColumn("dates").setPrintFormatter(formatter, "WHAT?");
+    table.dateTimeColumn("dates").append(LocalDateTime.of(2011, 1, 1, 4, 30));
+    StringWriter writer = new StringWriter();
+    table.write().usingOptions(CsvWriteOptions.builder(writer).usePrintFormatters(true).build());
+    assertEquals(
+        "dates\n" + "\"Jan 1, 2011 - 04:30\"\n", writer.toString().replaceAll("\\r\\n", "\n"));
+  }
+
+  @Test
+  void printFormatter_boolean() throws IOException {
+    Table table = Table.create("", BooleanColumn.create("bools"));
+    BooleanFormatter formatter = new BooleanFormatter("Yes", "No", "IDK");
+    table.booleanColumn("bools").setPrintFormatter(formatter);
+    table.booleanColumn("bools").append(true).append(false).appendMissing();
+    StringWriter writer = new StringWriter();
+    table.write().usingOptions(CsvWriteOptions.builder(writer).usePrintFormatters(true).build());
+    assertEquals(
+        "bools\n" + "Yes\n" + "No\n" + "IDK\n", writer.toString().replaceAll("\\r\\n", "\n"));
+  }
+
+  @Test
+  void printFormatter_string() throws IOException {
+    Table table = Table.create("", StringColumn.create("strings"));
+    StringColumnFormatter formatter = new StringColumnFormatter(s -> "[" + s + "]", "N/A");
+    table.stringColumn("strings").setPrintFormatter(formatter);
+    table.stringColumn("strings").append("hey").append("you").appendMissing();
+    StringWriter writer = new StringWriter();
+    table.write().usingOptions(CsvWriteOptions.builder(writer).usePrintFormatters(true).build());
+    assertEquals(
+        "strings\n" + "[hey]\n" + "[you]\n" + "N/A\n",
+        writer.toString().replaceAll("\\r\\n", "\n"));
+  }
+
+  @Test
+  void printFormatter_text() throws IOException {
+    Table table = Table.create("", TextColumn.create("strings"));
+    StringColumnFormatter formatter = new StringColumnFormatter(s -> "[" + s + "]", "N/A");
+    table.textColumn("strings").setPrintFormatter(formatter);
+    table.textColumn("strings").append("hey").append("you").appendMissing();
+    StringWriter writer = new StringWriter();
+    table.write().usingOptions(CsvWriteOptions.builder(writer).usePrintFormatters(true).build());
+    assertEquals(
+        "strings\n" + "[hey]\n" + "[you]\n" + "N/A\n",
+        writer.toString().replaceAll("\\r\\n", "\n"));
+  }
+
+  @Test
+  void printFormatter_short() throws IOException {
+    Table table = Table.create("", ShortColumn.create("ints"));
+    table.shortColumn("ints").setPrintFormatter(NumberColumnFormatter.intsWithGrouping());
+    table.shortColumn("ints").append((short) 102).append((short) 12_132).append((short) -1_234);
+    StringWriter writer = new StringWriter();
+    table.write().usingOptions(CsvWriteOptions.builder(writer).usePrintFormatters(true).build());
+    assertEquals(
+        "ints\n" + "102\n" + "\"12,132\"\n" + "\"-1,234\"\n",
+        writer.toString().replaceAll("\\r\\n", "\n"));
+  }
+
+  @Test
+  void printFormatter_instant() throws IOException {
+    Table table = Table.create("", InstantColumn.create("dates"));
+    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMM d, yyyy - hh:mm");
+    table.instantColumn("dates").setPrintFormatter(new InstantColumnFormatter(formatter, "WHAT?"));
+    table.instantColumn("dates").append(Instant.parse("2007-12-03T10:15:30.00Z")).appendMissing();
+    StringWriter writer = new StringWriter();
+    table.write().usingOptions(CsvWriteOptions.builder(writer).usePrintFormatters(true).build());
+    assertEquals(
+        "dates\n" + "\"Dec 3, 2007 - 10:15\"\n" + "WHAT?\n",
+        writer.toString().replaceAll("\\r\\n", "\n"));
+  }
+
+  /** Test preventing scientific notation */
+  @Test
+  void printFormatter_scientific_notation() throws IOException {
+    Table table = Table.create("", DoubleColumn.create("doubles"));
+    DecimalFormat df = new DecimalFormat("0.#");
+    df.setMaximumFractionDigits(11);
+    NumberColumnFormatter formatter = new NumberColumnFormatter(df);
+    table.doubleColumn("doubles").setPrintFormatter(formatter);
+    table
+        .doubleColumn("doubles")
+        .append(32.32342489123)
+        .append(0.1192342224)
+        .appendObj(null)
+        .append(1001.0);
+    StringWriter writer = new StringWriter();
+    table.write().usingOptions(CsvWriteOptions.builder(writer).usePrintFormatters(true).build());
+    assertEquals(
+        "doubles\n" + "32.32342489123\n" + "0.1192342224\n" + "\n" + "1001\n",
+        writer.toString().replaceAll("\\r\\n", "\n"));
+  }
+
+  @Test
+  void dateTimeFormatter() throws IOException {
     Table table = Table.create("test", DateTimeColumn.create("dt"));
     table.dateTimeColumn(0).append(LocalDateTime.of(2011, 1, 1, 4, 30));
     StringWriter writer = new StringWriter();
diff --git a/core/src/test/java/tech/tablesaw/io/csv/UnicodeBomHandlingTest.java b/core/src/test/java/tech/tablesaw/io/csv/UnicodeBomHandlingTest.java
index 8db30be4..05703583 100644
--- a/core/src/test/java/tech/tablesaw/io/csv/UnicodeBomHandlingTest.java
+++ b/core/src/test/java/tech/tablesaw/io/csv/UnicodeBomHandlingTest.java
@@ -6,6 +6,7 @@ import static tech.tablesaw.io.csv.UnicodeBomHandlingTest.BOM.UTF_8;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStreamReader;
+import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import tech.tablesaw.api.Table;
 
@@ -22,6 +23,7 @@ public class UnicodeBomHandlingTest {
   }
 
   @Test
+  @Disabled
   public void javaBehaviour() throws IOException {
 
     Table t =
diff --git a/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java b/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java
index 0d7f678f..80ce265b 100644
--- a/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java
+++ b/core/src/test/java/tech/tablesaw/joining/DataFrameJoinerTest.java
@@ -1,6 +1,7 @@
 package tech.tablesaw.joining;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import com.google.common.base.Joiner;
 import java.util.Arrays;
@@ -92,6 +93,13 @@ public class DataFrameJoinerTest {
                   .join("ID,Dog Name", "1.1,Spot", "3.0,Fido", "4.0,Sasha", "5.0,King"),
               "Dogs");
 
+  private static final Table DOUBLE_INDEXED_DOGS_REVERSE =
+      Table.read()
+          .csv(
+              Joiner.on(System.lineSeparator())
+                  .join("Dog Name,ID", "Spot,1.1", "Fido,3.0", "Sasha,4.0", "King,5.0"),
+              "DogsReverse");
+
   private static final Table DOUBLE_INDEXED_CATS =
       Table.read()
           .csv(
@@ -227,6 +235,35 @@ public class DataFrameJoinerTest {
             "Student");
   }
 
+  private static Table createSTUDENTLarger() {
+    return Table.read()
+        .csv(
+            Joiner.on(System.lineSeparator())
+                .join(
+                    "ID,FirstName,LastName,City,State,Age,USID,GradYear",
+                    "1,Bob,Barney,Burke,VA,20,11122,2019",
+                    "2,Chris,Cabello,Canyonville,OR,20,22224,2019",
+                    "3,Dan,Dirble,Denver,CO,21,33335,2020",
+                    "4,Edward,Earhardt,Easterly,WA,21,44339,2021",
+                    "5,Frank,Farnsworth,Fredriksburg,VA,22,55338,2019",
+                    "6,George,Gabral,Garrisburg,MD,22,66337,2020",
+                    "7,Michael,Marbury,Milton,NH,23,77330,2020",
+                    "8,Robert,Riley,Roseburg,OR,23,88836,2020",
+                    "9,Bob,Earhardt,Milton,NH,24,93333,2019",
+                    "10,Dan,Gabral,Easterly,WA,24,13333,2020",
+                    "11,Bob,Barney,Burke,VA,20,11122,2019",
+                    "12,Chris,Cabello,Canyonville,OR,20,22224,2019",
+                    "13,Dan,Dirble,Denver,CO,21,33335,2020",
+                    "14,Edward,Earhardt,Easterly,WA,21,44339,2021",
+                    "15,Frank,Farnsworth,Fredriksburg,VA,22,55338,2019",
+                    "16,George,Gabral,Garrisburg,MD,22,66337,2020",
+                    "17,Michael,Marbury,Milton,NH,23,77330,2020",
+                    "18,Robert,Riley,Roseburg,OR,23,88836,2020",
+                    "19,Bob,Earhardt,Milton,NH,24,93333,2019",
+                    "20,Dan,Gabral,Easterly,WA,24,13333,2020"),
+            "Student");
+  }
+
   private static Table createINSTRUCTOR() {
     return Table.read()
         .csv(
@@ -246,6 +283,37 @@ public class DataFrameJoinerTest {
             "Instructor");
   }
 
+  private static Table createGOODS1() {
+    return Table.read()
+        .csv(
+            Joiner.on(System.lineSeparator())
+                .join(
+                    "ID,Name,Price",
+                    "1,Chips,10",
+                    "2,Coca-cola,20",
+                    "3,Bread,30",
+                    "4,Toothbrush,10",
+                    "5,Soap,20",
+                    "6,Towel,30"),
+            "Goods1");
+  }
+
+  private static Table createGOODS2() {
+    return Table.read()
+        .csv(
+            Joiner.on(System.lineSeparator())
+                .join("ID,Name,Price", "1,Chips,11", "2,Bread,29", "3,Soap,21", "4,Towel,33"),
+            "Goods2");
+  }
+
+  private static Table createGOODS3() {
+    return Table.read()
+        .csv(
+            Joiner.on(System.lineSeparator())
+                .join("ID,Name,Price", "1,Chips,9", "2,Coca-cola,22", "3,Bread,29"),
+            "Goods3");
+  }
+
   private static Table createDEPTHEAD() {
     return Table.read()
         .csv(
@@ -542,12 +610,42 @@ public class DataFrameJoinerTest {
     assertEquals(4, joined.column("ID").size());
   }
 
+  @Test
+  public void rightOuterJoinWithDoubles2Reverse() {
+    Table joined = DOUBLE_INDEXED_PEOPLE.joinOn("ID").rightOuter(DOUBLE_INDEXED_DOGS_REVERSE);
+    assertEquals(3, joined.columnCount());
+    assertEquals(4, joined.rowCount());
+    assertEquals(4, joined.column("ID").size());
+  }
+
   @Test
   public void rightOuterJoinWithDoubles3() {
     Table joined =
         DOUBLE_INDEXED_PEOPLE.joinOn("ID").rightOuter(DOUBLE_INDEXED_DOGS, DOUBLE_INDEXED_CATS);
-    assertEquals(3, joined.columnCount());
+    assertTrue(
+        joined.columnNames().containsAll(Arrays.asList("ID", "Name", "Dog Name", "Cat Name")));
     assertEquals(4, joined.rowCount());
+    assertEquals(4, joined.column("ID").size());
+    assertEquals(0, joined.column("ID").countMissing());
+    assertEquals(4, joined.column("Name").size());
+    assertEquals(3, joined.column("Name").countMissing());
+    assertEquals(4, joined.column("Dog Name").size());
+    assertEquals(3, joined.column("Dog Name").countMissing());
+    assertEquals(4, joined.column("Cat Name").size());
+    assertEquals(0, joined.column("Cat Name").countMissing());
+  }
+
+  @Test
+  public void rightOuterJoinWithDoubles4() {
+    Table joined = DOUBLE_INDEXED_PEOPLE.joinOn("ID").rightOuter(DOUBLE_INDEXED_DOGS);
+    assertTrue(joined.columnNames().containsAll(Arrays.asList("ID", "Name", "Dog Name")));
+    assertEquals(4, joined.rowCount());
+    assertEquals(4, joined.column("ID").size());
+    assertEquals(0, joined.column("ID").countMissing());
+    assertEquals(4, joined.column("Dog Name").size());
+    assertEquals(0, joined.column("Dog Name").countMissing());
+    assertEquals(4, joined.column("Name").size());
+    assertEquals(1, joined.column("Name").countMissing());
   }
 
   @Test
@@ -570,9 +668,16 @@ public class DataFrameJoinerTest {
   public void leftOuterJoinWithDoubles4() {
     Table joined =
         DOUBLE_INDEXED_DOGS.joinOn("ID").leftOuter(DOUBLE_INDEXED_PEOPLE, DOUBLE_INDEXED_CATS);
-    assertEquals(3, joined.columnCount());
-    assertEquals(4, joined.rowCount());
+    assertTrue(
+        joined.columnNames().containsAll(Arrays.asList("ID", "Dog Name", "Name", "Cat Name")));
     assertEquals(4, joined.column("ID").size());
+    assertEquals(4, joined.column("Dog Name").size());
+    assertEquals(0, joined.column("Dog Name").countMissing());
+    assertEquals(4, joined.column("Name").size());
+    assertEquals(1, joined.column("Name").countMissing());
+    assertEquals(4, joined.column("Cat Name").size());
+    assertEquals(3, joined.column("Cat Name").countMissing());
+    assertEquals(4, joined.rowCount());
   }
 
   @Test
@@ -714,6 +819,84 @@ public class DataFrameJoinerTest {
     assertEquals(2, joined.column("Feed").countMissing());
   }
 
+  @Test
+  public void fullOuterJoinMultiTable1() {
+    Table table1 = createGOODS1();
+    Table table2 = createGOODS2();
+    Table table3 = createGOODS3();
+    Table joined = table1.joinOn("Name").fullOuter(true, table2, table3);
+    assertEquals(7, joined.columnCount());
+    assertTrue(
+        joined
+            .columnNames()
+            .containsAll(
+                Arrays.asList("ID", "Name", "Price", "T2.ID", "T2.Price", "T3.ID", "T3.Price")));
+    assertEquals(6, joined.rowCount());
+    assertEquals(0, joined.column("ID").countMissing());
+    assertEquals(0, joined.column("Name").countMissing());
+    assertEquals(0, joined.column("Price").countMissing());
+    assertEquals(6, joined.column("T2.ID").size());
+    assertEquals(2, joined.column("T2.ID").countMissing());
+    assertEquals(6, joined.column("T2.Price").size());
+    assertEquals(2, joined.column("T2.Price").countMissing());
+    assertEquals(6, joined.column("T3.ID").size());
+    assertEquals(3, joined.column("T3.ID").countMissing());
+    assertEquals(6, joined.column("T3.Price").size());
+    assertEquals(3, joined.column("T3.Price").countMissing());
+  }
+
+  @Test
+  public void fullOuterJoinMultiTable2() {
+    Table table3 = createGOODS3();
+    Table table2 = createGOODS2();
+    Table table1 = createGOODS1();
+    Table joined = table3.joinOn("Name").fullOuter(true, table2, table1);
+    assertEquals(7, joined.columnCount());
+    assertTrue(
+        joined
+            .columnNames()
+            .containsAll(
+                Arrays.asList("ID", "Name", "Price", "T2.ID", "T2.Price", "T3.ID", "T3.Price")));
+    assertEquals(6, joined.rowCount());
+    assertEquals(3, joined.column("ID").countMissing());
+    assertEquals(0, joined.column("Name").countMissing());
+    assertEquals(3, joined.column("Price").countMissing());
+    assertEquals(6, joined.column("T2.ID").size());
+    assertEquals(2, joined.column("T2.ID").countMissing());
+    assertEquals(6, joined.column("T2.Price").size());
+    assertEquals(2, joined.column("T2.Price").countMissing());
+    assertEquals(6, joined.column("T3.ID").size());
+    assertEquals(0, joined.column("T3.ID").countMissing());
+    assertEquals(6, joined.column("T3.Price").size());
+    assertEquals(0, joined.column("T3.Price").countMissing());
+  }
+
+  @Test
+  public void fullOuterJoinMultiTable3() {
+    Table table2 = createGOODS2();
+    Table table3 = createGOODS3();
+    Table table1 = createGOODS1();
+    Table joined = table2.joinOn("Name").fullOuter(true, table3, table1);
+    assertEquals(7, joined.columnCount());
+    assertTrue(
+        joined
+            .columnNames()
+            .containsAll(
+                Arrays.asList("ID", "Name", "Price", "T2.ID", "T2.Price", "T3.ID", "T3.Price")));
+    assertEquals(6, joined.rowCount());
+    assertEquals(2, joined.column("ID").countMissing());
+    assertEquals(0, joined.column("Name").countMissing());
+    assertEquals(2, joined.column("Price").countMissing());
+    assertEquals(6, joined.column("T2.ID").size());
+    assertEquals(3, joined.column("T2.ID").countMissing());
+    assertEquals(6, joined.column("T2.Price").size());
+    assertEquals(3, joined.column("T2.Price").countMissing());
+    assertEquals(6, joined.column("T3.ID").size());
+    assertEquals(0, joined.column("T3.ID").countMissing());
+    assertEquals(6, joined.column("T3.Price").size());
+    assertEquals(0, joined.column("T3.Price").countMissing());
+  }
+
   @Test
   public void innerJoinStudentInstructorOnAge() {
     Table table1 = createSTUDENT();
@@ -722,6 +905,7 @@ public class DataFrameJoinerTest {
     assert (joined
         .columnNames()
         .containsAll(Arrays.asList("T2.ID", "T2.City", "T2.State", "T2.USID", "T2.GradYear")));
+    System.out.println(joined.printAll());
     assertEquals(16, joined.columnCount());
     assertEquals(14, joined.rowCount());
   }
@@ -791,6 +975,50 @@ public class DataFrameJoinerTest {
     assertEquals(30, joined.columnCount());
     assertEquals(14, joined.rowCount());
   }
+  // Tests for left table is larger than right table, single cols
+  @Test
+  public void innerJoinStudentInstructorClassDeptHeadOnAgeLargerLeftTable() {
+    Table table1 = createSTUDENTLarger();
+    Table table2 = createINSTRUCTOR();
+    Table table3 = createCLASS();
+    Table table4 = createDEPTHEAD();
+    Table joined = table1.joinOn("Age").inner(true, table2, table3, table4);
+    List<String> expectedCol =
+        Arrays.asList(
+            "ID",
+            "FirstName",
+            "LastName",
+            "City",
+            "State",
+            "Age",
+            "USID",
+            "GradYear",
+            "T2.ID",
+            "First",
+            "Last",
+            "Title",
+            "T2.City",
+            "T2.State",
+            "T2.USID",
+            "T2.GradYear",
+            "T3.ID",
+            "ClassType",
+            "Name",
+            "Level",
+            "Description",
+            "StartDate",
+            "EndDate",
+            "Completed",
+            "T4.ID",
+            "T4.First",
+            "T4.Last",
+            "Dept",
+            "T4.City",
+            "T4.State");
+    assert (String.join("", joined.columnNames()).equals(String.join("", expectedCol)));
+    assertEquals(30, joined.columnCount());
+    assertEquals(28, joined.rowCount());
+  }
 
   @Test
   public void innerJoinStudentInstructorDeptHeadOnStateAge() {
@@ -813,6 +1041,42 @@ public class DataFrameJoinerTest {
     assertEquals(20, joined.columnCount());
     assertEquals(1, joined.rowCount());
   }
+  // Tests for left table is larger than right table, multiple cols
+  @Test
+  public void innerJoinStudentInstructorDeptHeadOnStateAgeWithLargerLeftTable() {
+    Table table1 = createSTUDENTLarger();
+    Table table2 = createINSTRUCTOR();
+    Table table3 = createDEPTHEAD();
+    // table1 join table 2 will have 6 rows, so drop last 5 rows of table3 for test
+    table3 = table3.dropRows(5, 6, 7, 8, 9);
+    Table joined = table1.joinOn("State", "Age").inner(true, table2, table3);
+    List<String> expectedCol =
+        Arrays.asList(
+            "ID",
+            "FirstName",
+            "LastName",
+            "City",
+            "State",
+            "Age",
+            "USID",
+            "GradYear",
+            "T2.ID",
+            "First",
+            "Last",
+            "Title",
+            "T2.City",
+            "T2.USID",
+            "T2.GradYear",
+            "T3.ID",
+            "T3.First",
+            "T3.Last",
+            "Dept",
+            "T3.City");
+    // test for col order
+    assert (String.join("", joined.columnNames()).equals(String.join("", expectedCol)));
+    assertEquals(20, joined.columnCount());
+    assertEquals(2, joined.rowCount());
+  }
 
   @Test
   public void innerJoinStudentInstructorOnStateAge() {
@@ -843,6 +1107,26 @@ public class DataFrameJoinerTest {
     assertEquals(10, joined.column("Age").size());
   }
 
+  @Test
+  public void leftJoinStoreWithMultiTables() {
+    Table table1 = createGOODS1();
+    Table table2 = createGOODS2();
+    Table table3 = createGOODS3();
+    Table joined = table1.joinOn("Name").leftOuter(true, table2, table3);
+    assertEquals(7, joined.columnCount());
+    assertTrue(
+        joined
+            .columnNames()
+            .containsAll(
+                Arrays.asList("ID", "Name", "Price", "T2.ID", "T2.Price", "T3.ID", "T3.Price")));
+    assertEquals(6, joined.rowCount());
+    assertEquals(6, joined.column("Price").size());
+    assertEquals(6, joined.column("T2.ID").size());
+    assertEquals(2, joined.column("T2.ID").countMissing());
+    assertEquals(6, joined.column("T3.ID").size());
+    assertEquals(3, joined.column("T3.ID").countMissing());
+  }
+
   @Test
   public void innerJoinHouseBoatOnBedroomsOwner() {
     Table table1 = createHOUSE();
diff --git a/core/src/test/java/tech/tablesaw/table/TableSummaryTest.java b/core/src/test/java/tech/tablesaw/table/TableSummaryTest.java
index 2da8f36d..cfff6844 100644
--- a/core/src/test/java/tech/tablesaw/table/TableSummaryTest.java
+++ b/core/src/test/java/tech/tablesaw/table/TableSummaryTest.java
@@ -13,6 +13,8 @@ import tech.tablesaw.api.Table;
 
 public class TableSummaryTest {
 
+  private static final String LINE_END = System.lineSeparator();
+
   @Test
   public void emptyTable() {
     Table testTable = Table.create("Data");
@@ -29,16 +31,26 @@ public class TableSummaryTest {
             DoubleColumn.create("value2", 2.0, 2.1, 2.2));
     Table result = testTable.summary();
     assertEquals(
-        "                            Data                             \n"
-            + " Summary   |         value1         |        value2         |\n"
-            + "-------------------------------------------------------------\n"
-            + "    Count  |                     3  |                    3  |\n"
-            + "      sum  |                   3.3  |                  6.3  |\n"
-            + "     Mean  |                   1.1  |                  2.1  |\n"
-            + "      Min  |                     1  |                    2  |\n"
-            + "      Max  |                   1.2  |                  2.2  |\n"
-            + "    Range  |   0.19999999999999996  |  0.20000000000000018  |\n"
-            + " Variance  |  0.009999999999999995  |  0.01000000000000004  |\n"
+        "                            Data                             "
+            + LINE_END
+            + " Summary   |         value1         |        value2         |"
+            + LINE_END
+            + "-------------------------------------------------------------"
+            + LINE_END
+            + "    Count  |                     3  |                    3  |"
+            + LINE_END
+            + "      sum  |                   3.3  |                  6.3  |"
+            + LINE_END
+            + "     Mean  |                   1.1  |                  2.1  |"
+            + LINE_END
+            + "      Min  |                     1  |                    2  |"
+            + LINE_END
+            + "      Max  |                   1.2  |                  2.2  |"
+            + LINE_END
+            + "    Range  |   0.19999999999999996  |  0.20000000000000018  |"
+            + LINE_END
+            + " Variance  |  0.009999999999999995  |  0.01000000000000004  |"
+            + LINE_END
             + " Std. Dev  |   0.09999999999999998  |   0.1000000000000002  |",
         result.print());
   }
@@ -58,24 +70,42 @@ public class TableSummaryTest {
                 }));
     Table result = testTable.summary();
     assertEquals(
-        "                                   Data                                    \n"
-            + "  Summary   |  label   |         value1         |  truthy  |    dates     |\n"
-            + "---------------------------------------------------------------------------\n"
-            + "     Count  |       3  |                     3  |          |           3  |\n"
-            + "    Unique  |       2  |                        |          |              |\n"
-            + "       Top  |  yellow  |                        |          |              |\n"
-            + " Top Freq.  |       2  |                        |          |              |\n"
-            + "       sum  |          |                   3.3  |          |              |\n"
-            + "      Mean  |          |                   1.1  |          |              |\n"
-            + "       Min  |          |                     1  |          |              |\n"
-            + "       Max  |          |                   1.2  |          |              |\n"
-            + "     Range  |          |   0.19999999999999996  |          |              |\n"
-            + "  Variance  |          |  0.009999999999999995  |          |              |\n"
-            + "  Std. Dev  |          |   0.09999999999999998  |          |              |\n"
-            + "     false  |          |                        |       1  |              |\n"
-            + "      true  |          |                        |       2  |              |\n"
-            + "   Missing  |          |                        |          |           0  |\n"
-            + "  Earliest  |          |                        |          |  2001-01-01  |\n"
+        "                                   Data                                    "
+            + LINE_END
+            + "  Summary   |  label   |         value1         |  truthy  |    dates     |"
+            + LINE_END
+            + "---------------------------------------------------------------------------"
+            + LINE_END
+            + "     Count  |       3  |                     3  |          |           3  |"
+            + LINE_END
+            + "    Unique  |       2  |                        |          |              |"
+            + LINE_END
+            + "       Top  |  yellow  |                        |          |              |"
+            + LINE_END
+            + " Top Freq.  |       2  |                        |          |              |"
+            + LINE_END
+            + "       sum  |          |                   3.3  |          |              |"
+            + LINE_END
+            + "      Mean  |          |                   1.1  |          |              |"
+            + LINE_END
+            + "       Min  |          |                     1  |          |              |"
+            + LINE_END
+            + "       Max  |          |                   1.2  |          |              |"
+            + LINE_END
+            + "     Range  |          |   0.19999999999999996  |          |              |"
+            + LINE_END
+            + "  Variance  |          |  0.009999999999999995  |          |              |"
+            + LINE_END
+            + "  Std. Dev  |          |   0.09999999999999998  |          |              |"
+            + LINE_END
+            + "     false  |          |                        |       1  |              |"
+            + LINE_END
+            + "      true  |          |                        |       2  |              |"
+            + LINE_END
+            + "   Missing  |          |                        |          |           0  |"
+            + LINE_END
+            + "  Earliest  |          |                        |          |  2001-01-01  |"
+            + LINE_END
             + "    Latest  |          |                        |          |  2002-01-01  |",
         result.print());
   }
diff --git a/html/src/test/java/tech/tablesaw/io/html/HtmlWriterTest.java b/html/src/test/java/tech/tablesaw/io/html/HtmlWriterTest.java
index e4f584cc..ea261fed 100644
--- a/html/src/test/java/tech/tablesaw/io/html/HtmlWriterTest.java
+++ b/html/src/test/java/tech/tablesaw/io/html/HtmlWriterTest.java
@@ -29,7 +29,7 @@ import tech.tablesaw.io.html.HtmlWriteOptions.ElementCreator;
 
 public class HtmlWriterTest {
 
-  private static final String LINE_END = System.lineSeparator();
+  private static final String LINE_END = "\n";
 
   private double[] v1 = {1, 2, NaN};
   private double[] v2 = {1, 2, NaN};
diff --git a/jsplot/src/test/java/tech/tablesaw/components/LayoutTest.java b/jsplot/src/test/java/tech/tablesaw/components/LayoutTest.java
index 29a72bff..a9ca6d49 100644
--- a/jsplot/src/test/java/tech/tablesaw/components/LayoutTest.java
+++ b/jsplot/src/test/java/tech/tablesaw/components/LayoutTest.java
@@ -11,6 +11,8 @@ import tech.tablesaw.plotly.components.Margin;
 
 public class LayoutTest {
 
+  private static final String LINE_END = System.lineSeparator();
+
   // @Test
   public void asJavascript() {
 
@@ -55,58 +57,62 @@ public class LayoutTest {
     {
       Layout layout = Layout.builder().autosize(true).build();
       assertEquals(
-          "var layout = {\n"
-              + //
-              "    autosize: true,\n"
-              + //
-              "\n\n"
-              + //
-              "};\n",
+          "var layout = {"
+              + LINE_END
+              + "    autosize: true,"
+              + LINE_END
+              + LINE_END
+              + LINE_END
+              + "};"
+              + LINE_END,
           layout.asJavascript());
     }
     {
       Layout layout = Layout.builder().autosize(true).width(800).build();
       assertEquals(
-          "var layout = {\n"
-              + //
-              "    width: 800,\n"
-              + //
-              "    autosize: true,\n"
-              + //
-              "\n\n"
-              + //
-              "};\n",
+          "var layout = {"
+              + LINE_END
+              + "    width: 800,"
+              + LINE_END
+              + "    autosize: true,"
+              + LINE_END
+              + LINE_END
+              + LINE_END
+              + "};"
+              + LINE_END,
           layout.asJavascript());
     }
     {
       Layout layout = Layout.builder().autosize(true).height(600).width(800).build();
       assertEquals(
-          "var layout = {\n"
-              + //
-              "    height: 600,\n"
-              + //
-              "    width: 800,\n"
-              + //
-              "    autosize: true,\n"
-              + //
-              "\n\n"
-              + //
-              "};\n",
+          "var layout = {"
+              + LINE_END
+              + "    height: 600,"
+              + LINE_END
+              + "    width: 800,"
+              + LINE_END
+              + "    autosize: true,"
+              + LINE_END
+              + LINE_END
+              + LINE_END
+              + "};"
+              + LINE_END,
           layout.asJavascript());
     }
     {
       // see if 700x450
       Layout layout = Layout.builder().autosize(false).height(600).build();
       assertEquals(
-          "var layout = {\n"
-              + //
-              "    height: 600,\n"
-              + //
-              "    width: 700,\n"
-              + //
-              "\n\n"
-              + //
-              "};\n",
+          "var layout = {"
+              + LINE_END
+              + "    height: 600,"
+              + LINE_END
+              + "    width: 700,"
+              + LINE_END
+              + LINE_END
+              + LINE_END
+              + "};"
+              + LINE_END,
           layout.asJavascript());
     }
   }
diff --git a/jsplot/src/test/java/tech/tablesaw/plotly/components/FigureTest.java b/jsplot/src/test/java/tech/tablesaw/plotly/components/FigureTest.java
index e9ad6c48..8fced202 100644
--- a/jsplot/src/test/java/tech/tablesaw/plotly/components/FigureTest.java
+++ b/jsplot/src/test/java/tech/tablesaw/plotly/components/FigureTest.java
@@ -8,6 +8,7 @@ import tech.tablesaw.plotly.traces.Trace;
 
 class FigureTest {
 
+  private static final String LINE_END = System.lineSeparator();
   private String divName = "target";
 
   private double[] x = {1, 2, 3, 4, 5};
@@ -20,22 +21,38 @@ class FigureTest {
     Figure figure = new Figure(trace);
 
     assertEquals(
-        "    <script>\n"
-            + "        var target_target = document.getElementById('target');\n"
-            + "        \n"
-            + "var trace0 =\n"
-            + "{\n"
-            + "x: [\"1.0\",\"2.0\",\"3.0\",\"4.0\",\"5.0\"],\n"
-            + "y: [\"1.0\",\"4.0\",\"9.0\",\"16.0\",\"25.0\"],\n"
-            + "mode: 'markers',\n"
-            + "xaxis: 'x',\n"
-            + "yaxis: 'y',\n"
-            + "type: 'scatter',\n"
-            + "name: '',\n"
-            + "};\n"
-            + "\n"
-            + "        var data = [ trace0];\n"
-            + "Plotly.newPlot(target_target, data);            </script>\n",
+        "    <script>"
+            + LINE_END
+            + "        var target_target = document.getElementById('target');"
+            + LINE_END
+            + "        "
+            + LINE_END
+            + "var trace0 ="
+            + LINE_END
+            + "{"
+            + LINE_END
+            + "x: [\"1.0\",\"2.0\",\"3.0\",\"4.0\",\"5.0\"],"
+            + LINE_END
+            + "y: [\"1.0\",\"4.0\",\"9.0\",\"16.0\",\"25.0\"],"
+            + LINE_END
+            + "mode: 'markers',"
+            + LINE_END
+            + "xaxis: 'x',"
+            + LINE_END
+            + "yaxis: 'y',"
+            + LINE_END
+            + "type: 'scatter',"
+            + LINE_END
+            + "name: '',"
+            + LINE_END
+            + "};"
+            + LINE_END
+            + ""
+            + LINE_END
+            + "        var data = [ trace0];"
+            + LINE_END
+            + "Plotly.newPlot(target_target, data);            </script>"
+            + LINE_END,
         figure.asJavascript(divName));
   }
 
@@ -62,46 +79,86 @@ class FigureTest {
     Figure figure = new Figure(layout, trace);
 
     assertEquals(
-        "    <script>\n"
-            + "        var target_target = document.getElementById('target');\n"
-            + "        var layout = {\n"
-            + "    title: 'A test title',\n"
-            + "    height: 450,\n"
-            + "    width: 700,\n"
-            + "    showlegend: true,\n"
-            + "    margin: {\n"
-            + "  \"autoexpand\" : true,\n"
-            + "  \"b\" : 80,\n"
-            + "  \"l\" : 200,\n"
-            + "  \"pad\" : 0,\n"
-            + "  \"r\" : 80,\n"
-            + "  \"t\" : 200\n"
-            + "},\n"
-            + "    xaxis: {\n"
-            + "    title: 'x Axis 1',\n"
-            + "        titlefont: {\n"
-            + "  \"color\" : \"red\",\n"
-            + "  \"family\" : \"arial\",\n"
-            + "  \"size\" : 8\n"
-            + "},\n"
-            + "    },\n"
-            + "\n"
-            + "\n"
-            + "};\n"
-            + "\n"
-            + "var trace0 =\n"
-            + "{\n"
-            + "x: [\"1.0\",\"2.0\",\"3.0\",\"4.0\",\"5.0\"],\n"
-            + "y: [\"1.0\",\"4.0\",\"9.0\",\"16.0\",\"25.0\"],\n"
-            + "mode: 'markers',\n"
-            + "xaxis: 'x',\n"
-            + "yaxis: 'y',\n"
-            + "type: 'scatter',\n"
-            + "name: '',\n"
-            + "};\n"
-            + "\n"
-            + "        var data = [ trace0];\n"
-            + "Plotly.newPlot(target_target, data, layout);            </script>\n",
+        "    <script>"
+            + LINE_END
+            + "        var target_target = document.getElementById('target');"
+            + LINE_END
+            + "        var layout = {"
+            + LINE_END
+            + "    title: 'A test title',"
+            + LINE_END
+            + "    height: 450,"
+            + LINE_END
+            + "    width: 700,"
+            + LINE_END
+            + "    showlegend: true,"
+            + LINE_END
+            + "    margin: {"
+            + LINE_END
+            + "  \"autoexpand\" : true,"
+            + LINE_END
+            + "  \"b\" : 80,"
+            + LINE_END
+            + "  \"l\" : 200,"
+            + LINE_END
+            + "  \"pad\" : 0,"
+            + LINE_END
+            + "  \"r\" : 80,"
+            + LINE_END
+            + "  \"t\" : 200"
+            + LINE_END
+            + "},"
+            + LINE_END
+            + "    xaxis: {"
+            + LINE_END
+            + "    title: 'x Axis 1',"
+            + LINE_END
+            + "        titlefont: {"
+            + LINE_END
+            + "  \"color\" : \"red\","
+            + LINE_END
+            + "  \"family\" : \"arial\","
+            + LINE_END
+            + "  \"size\" : 8"
+            + LINE_END
+            + "},"
+            + LINE_END
+            + "    },"
+            + LINE_END
+            + ""
+            + LINE_END
+            + ""
+            + LINE_END
+            + "};"
+            + LINE_END
+            + ""
+            + LINE_END
+            + "var trace0 ="
+            + LINE_END
+            + "{"
+            + LINE_END
+            + "x: [\"1.0\",\"2.0\",\"3.0\",\"4.0\",\"5.0\"],"
+            + LINE_END
+            + "y: [\"1.0\",\"4.0\",\"9.0\",\"16.0\",\"25.0\"],"
+            + LINE_END
+            + "mode: 'markers',"
+            + LINE_END
+            + "xaxis: 'x',"
+            + LINE_END
+            + "yaxis: 'y',"
+            + LINE_END
+            + "type: 'scatter',"
+            + LINE_END
+            + "name: '',"
+            + LINE_END
+            + "};"
+            + LINE_END
+            + ""
+            + LINE_END
+            + "        var data = [ trace0];"
+            + LINE_END
+            + "Plotly.newPlot(target_target, data, layout);            </script>"
+            + LINE_END,
         figure.asJavascript(divName));
   }