diff --git a/crates/xlsx/src/csv.rs b/crates/xlsx/src/csv.rs
--- a/crates/xlsx/src/csv.rs
+++ b/crates/xlsx/src/csv.rs
@@ -225,8 +225,18 @@ pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record:
             }
             field_started_quoted = false;
         } else if c == '\r' || c == '\n' {
-            if c == '\r' && chars.peek() == Some(&'\n') {
-                chars.next();
+            // CR LF ends a line, and so do several CRs before an LF (`\r\r\n`, as some bank
+            // exports write: converted twice); CRs alone stay a line end each. The run of CRs is
+            // read once, whatever its length: the CRs after the first, with no LF after them,
+            // are that many empty lines.
+            let mut empty_lines = 0usize;
+            if c == '\r' {
+                while chars.next_if_eq(&'\r').is_some() {
+                    empty_lines += 1;
+                }
+                if chars.next_if_eq(&'\n').is_some() {
+                    empty_lines = 0;
+                }
             }
             let f = std::mem::take(&mut field);
             if record.len() < MAX_COLS as usize {
@@ -236,6 +246,11 @@ pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record:
             if !on_record(std::mem::take(&mut record)) {
                 return;
             }
+            for _ in 0..empty_lines {
+                if !on_record(vec![String::new()]) {
+                    return;
+                }
+            }
         } else {
             field.push(c);
         }
diff --git a/crates/xlsx/src/tests/csv_tests.rs b/crates/xlsx/src/tests/csv_tests.rs
--- a/crates/xlsx/src/tests/csv_tests.rs
+++ b/crates/xlsx/src/tests/csv_tests.rs
@@ -183,6 +183,24 @@ fn delimiter_sniffing() {
     assert!(read_csv(b"a", &CsvOptions { delimiter: b'"', ..Default::default() }).is_err());
 }
 
+/// `\r\r\n` line ends (some bank exports, converted twice) are one line end each: no empty row
+/// after every record. CRs alone stay a line end each.
+#[test]
+fn cr_cr_lf_ends_one_line() {
+    let wb = read_csv(b"a;b\r\r\n1;2\r\r\n3;4\r\r\n", &CsvOptions { delimiter: 0, ..Default::default() }).unwrap();
+    assert_eq!((v(&wb, 1, 1), v(&wb, 2, 0)), (Value::Number(2.0), Value::Number(3.0)));
+    assert_eq!(wb.sheet(0).and_then(|s| s.used_range()).map(|r| r.end.row), Some(2));
+    let mac = read_csv(b"a\r\rb", &CsvOptions::default()).unwrap();
+    assert_eq!((v(&mac, 0, 0), v(&mac, 2, 0)), (Value::text("a"), Value::text("b")));
+    // A long run of CRs is read once, not again for each CR (that took about 2·10¹⁰ steps
+    // for these 200,000, minutes; now it is one pass).
+    let mut long = b"a".to_vec();
+    long.extend(std::iter::repeat_n(b'\r', 200_000));
+    long.push(b'b');
+    let wb = read_csv(&long, &CsvOptions::default()).unwrap();
+    assert_eq!(v(&wb, 200_000, 0), Value::text("b"));
+}
+
 #[test]
 fn writing() {
     let mut wb = Workbook::new();
