From 4231bda3446356eddf5e044acf35f6ce0061be60 Mon Sep 17 00:00:00 2001 From: Sarath Francis Date: Wed, 10 Jun 2026 03:56:33 -0400 Subject: [PATCH] fix(csv-stringify): quote fields containing a carriage return A field holding a lone carriage return was written unquoted because the quoting check only looked for the configured `record_delimiter` (`\n` by default). The parser, however, treats both `\r` and `\n` as record delimiters, so the value was split apart and the document no longer round-tripped: parse(stringify([['a\rb']])) // => [['a'], ['b']] instead of [['a\rb']] When the record delimiter is newline-based, quote any field that contains `\r` or `\n`. A custom, non-newline delimiter still leaves line breaks as data, so its behaviour is unchanged. --- packages/csv-stringify/lib/api/index.js | 12 ++++++++++++ .../csv-stringify/test/option.escape_formulas.ts | 3 ++- packages/csv-stringify/test/option.quote.ts | 10 ++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/csv-stringify/lib/api/index.js b/packages/csv-stringify/lib/api/index.js index 0f214501..0f2a089b 100644 --- a/packages/csv-stringify/lib/api/index.js +++ b/packages/csv-stringify/lib/api/index.js @@ -195,6 +195,17 @@ const stringifier = function (options, state, info) { const containsQuote = quote !== "" && value.indexOf(quote) >= 0; const containsEscape = value.indexOf(escape) >= 0 && escape !== quote; const containsRecordDelimiter = value.indexOf(record_delimiter) >= 0; + // With a newline-based record delimiter, a field containing either + // line-break character must be quoted: the parser treats both `\r` + // and `\n` as record delimiters, so an unquoted value (e.g. a lone + // `\r` when the delimiter is `\n`) would be split apart on the next + // parse. A custom, non-newline delimiter leaves line breaks as data. + const recordDelimiterIsNewline = + record_delimiter.indexOf("\n") >= 0 || + record_delimiter.indexOf("\r") >= 0; + const containsLineBreak = + recordDelimiterIsNewline && + (value.indexOf("\r") >= 0 || value.indexOf("\n") >= 0); const quotedString = quoted_string && typeof field === "string"; let quotedMatch = quoted_match && @@ -232,6 +243,7 @@ const stringifier = function (options, state, info) { containsQuote === true || containsdelimiter || containsRecordDelimiter || + containsLineBreak || quoted || quotedString || quotedMatch; diff --git a/packages/csv-stringify/test/option.escape_formulas.ts b/packages/csv-stringify/test/option.escape_formulas.ts index 9963c653..c371c36b 100644 --- a/packages/csv-stringify/test/option.escape_formulas.ts +++ b/packages/csv-stringify/test/option.escape_formulas.ts @@ -39,7 +39,8 @@ describe("Option `escape_formulas`", function () { "'-c,3", "'@d,4", "'\te,5", - "'\rf,6", + // The carriage return forces quoting so the field round-trips. + "\"'\rf\",6", "g,7", "'\uFF1Dh,8", "'\uFF0Bi,9", diff --git a/packages/csv-stringify/test/option.quote.ts b/packages/csv-stringify/test/option.quote.ts index 851b3150..7ca2a919 100644 --- a/packages/csv-stringify/test/option.quote.ts +++ b/packages/csv-stringify/test/option.quote.ts @@ -184,6 +184,16 @@ describe("Option `quote`", function () { ); }); + it("quotes a field containing a carriage return", function (next) { + // A lone "\r" is treated as a record delimiter by the parser, so it must + // be quoted to survive a round trip (it previously leaked through unquoted). + stringify([["a\rb"]], { eof: false }, (err, data) => { + if (err) return next(err); + data.should.eql('"a\rb"'); + next(); + }); + }); + it("field where quote string is empty", function (next) { stringify( [