Skip to content

Commit d719060

Browse files
committed
feat(inspector): choose the CSV escape character (doubled quote or backslash)
1 parent a48d85a commit d719060

9 files changed

Lines changed: 98 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1616
- In the CSV editor, split a column into several by a delimiter or regular expression, and merge a column with the one next to it using a separator. Both are single undo steps. (#1469)
1717
- In the CSV editor, switch the first row between header and data with `Cmd+Shift+H` when auto-detection guesses wrong. Files without a header are no longer saved with a generated header row. (#1469)
1818
- In the CSV editor, set the delimiter, quote character, encoding, and line ending by hand from Edit > Set CSV Properties…, then Reload to re-read the file with those settings. (#1469)
19+
- In the CSV editor, choose the escape character (a doubled quote or a backslash) in Set CSV Properties…, so files that escape quotes with a backslash read and save correctly. (#1469)
1920
- Add llama.cpp and MLX as local AI providers. Each preset points at the server's default local endpoint and needs no API key, alongside the existing Ollama and custom OpenAI-compatible options. (#1777)
2021

2122
### Fixed

Plugins/CSVInspectorPlugin/CSVWriter.swift

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,8 @@ struct CSVWriter {
7272
func encodeRow(_ cells: [String]) -> String {
7373
let delimiterScalar = UnicodeScalar(dialect.delimiter)
7474
let quoteScalar = UnicodeScalar(dialect.quoteChar)
75+
let escapeScalar = UnicodeScalar(dialect.escapeChar)
7576
let delimiter = String(delimiterScalar)
76-
let quote = String(quoteScalar)
77-
let doubledQuote = quote + quote
7877

7978
var line = ""
8079
for (index, field) in cells.enumerated() {
@@ -85,8 +84,7 @@ struct CSVWriter {
8584
field,
8685
delimiterScalar: delimiterScalar,
8786
quoteScalar: quoteScalar,
88-
quote: quote,
89-
doubledQuote: doubledQuote
87+
escapeScalar: escapeScalar
9088
)
9189
}
9290
return line
@@ -108,13 +106,19 @@ struct CSVWriter {
108106
_ field: String,
109107
delimiterScalar: UnicodeScalar,
110108
quoteScalar: UnicodeScalar,
111-
quote: String,
112-
doubledQuote: String
109+
escapeScalar: UnicodeScalar
113110
) -> String {
114111
let needsQuoting = field.unicodeScalars.contains { scalar in
115112
scalar == delimiterScalar || scalar == quoteScalar || scalar == "\n" || scalar == "\r"
116113
}
117114
guard needsQuoting else { return field }
118-
return quote + field.replacingOccurrences(of: quote, with: doubledQuote) + quote
115+
let quote = String(quoteScalar)
116+
if escapeScalar == quoteScalar {
117+
return quote + field.replacingOccurrences(of: quote, with: quote + quote) + quote
118+
}
119+
let escape = String(escapeScalar)
120+
var body = field.replacingOccurrences(of: escape, with: escape + escape)
121+
body = body.replacingOccurrences(of: quote, with: escape + quote)
122+
return quote + body + quote
119123
}
120124
}

Plugins/TableProPluginKit/CSVDialect.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public struct CSVDialect: Equatable, Sendable {
1717

1818
public var delimiter: UInt8
1919
public var quoteChar: UInt8
20+
public var escapeChar: UInt8 = 0x22
2021
public var encoding: String.Encoding
2122
public var lineEnding: LineEnding
2223
public var hasBom: Bool

Plugins/TableProPluginKit/CSVStreamingParser.swift

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public struct CSVStreamingParser: Sendable {
1010
public func indexRows(_ bytes: UnsafeBufferPointer<UInt8>) -> [Range<Int>] {
1111
var ranges: [Range<Int>] = []
1212
let quote = dialect.quoteChar
13+
let escape = dialect.escapeChar
1314
let delimiter = dialect.delimiter
1415
let count = bytes.count
1516
var i = bomSkip(in: bytes)
@@ -20,8 +21,12 @@ public struct CSVStreamingParser: Sendable {
2021
while i < count {
2122
let byte = bytes[i]
2223
if insideQuotes {
24+
if escape != quote, byte == escape, i + 1 < count {
25+
i += 2
26+
continue
27+
}
2328
if byte == quote {
24-
if i + 1 < count, bytes[i + 1] == quote {
29+
if escape == quote, i + 1 < count, bytes[i + 1] == quote {
2530
i += 2
2631
continue
2732
}
@@ -69,6 +74,7 @@ public struct CSVStreamingParser: Sendable {
6974
var fields: [String] = []
7075
var field: [UInt8] = []
7176
let quote = dialect.quoteChar
77+
let escape = dialect.escapeChar
7278
let delimiter = dialect.delimiter
7379
var insideQuotes = false
7480
var i = range.lowerBound
@@ -77,8 +83,13 @@ public struct CSVStreamingParser: Sendable {
7783
while i < end {
7884
let byte = bytes[i]
7985
if insideQuotes {
86+
if escape != quote, byte == escape, i + 1 < end {
87+
field.append(bytes[i + 1])
88+
i += 2
89+
continue
90+
}
8091
if byte == quote {
81-
if i + 1 < end, bytes[i + 1] == quote {
92+
if escape == quote, i + 1 < end, bytes[i + 1] == quote {
8293
field.append(quote)
8394
i += 2
8495
continue
@@ -115,6 +126,7 @@ public struct CSVStreamingParser: Sendable {
115126
public func field(_ bytes: UnsafeBufferPointer<UInt8>, range: Range<Int>, column: Int) -> String {
116127
guard column >= 0 else { return "" }
117128
let quote = dialect.quoteChar
129+
let escape = dialect.escapeChar
118130
let delimiter = dialect.delimiter
119131
var insideQuotes = false
120132
var i = range.lowerBound
@@ -126,8 +138,13 @@ public struct CSVStreamingParser: Sendable {
126138
while i < end {
127139
let byte = bytes[i]
128140
if insideQuotes {
141+
if escape != quote, byte == escape, i + 1 < end {
142+
if currentColumn == column { field.append(bytes[i + 1]) }
143+
i += 2
144+
continue
145+
}
129146
if byte == quote {
130-
if i + 1 < end, bytes[i + 1] == quote {
147+
if escape == quote, i + 1 < end, bytes[i + 1] == quote {
131148
if currentColumn == column { field.append(quote) }
132149
i += 2
133150
continue

TablePro/Views/Inspector/CSVPropertiesSheet.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ struct CSVPropertiesSheet: View {
1313

1414
@State private var delimiterIndex: Int
1515
@State private var quoteIndex: Int
16+
@State private var escapeIndex: Int
1617
@State private var encodingIndex: Int
1718
@State private var lineEndingIndex: Int
1819

@@ -26,6 +27,7 @@ struct CSVPropertiesSheet: View {
2627
self.onCancel = onCancel
2728
_delimiterIndex = State(initialValue: CSVPropertyOptions.delimiterIndex(for: dialect.delimiter))
2829
_quoteIndex = State(initialValue: CSVPropertyOptions.quoteIndex(for: dialect.quoteChar))
30+
_escapeIndex = State(initialValue: CSVPropertyOptions.escapeIndex(for: dialect.escapeChar))
2931
_encodingIndex = State(initialValue: CSVPropertyOptions.encodingIndex(for: dialect.encoding))
3032
_lineEndingIndex = State(initialValue: CSVPropertyOptions.lineEndingIndex(for: dialect.lineEnding))
3133
}
@@ -48,6 +50,11 @@ struct CSVPropertiesSheet: View {
4850
Text(CSVPropertyOptions.quotes[index].label).tag(index)
4951
}
5052
}
53+
Picker("Escape character", selection: $escapeIndex) {
54+
ForEach(CSVPropertyOptions.escapes.indices, id: \.self) { index in
55+
Text(CSVPropertyOptions.escapes[index].label).tag(index)
56+
}
57+
}
5158
Picker("Encoding", selection: $encodingIndex) {
5259
ForEach(CSVPropertyOptions.encodings.indices, id: \.self) { index in
5360
Text(CSVPropertyOptions.encodings[index].label).tag(index)
@@ -77,6 +84,7 @@ struct CSVPropertiesSheet: View {
7784
base: baseDialect,
7885
delimiterIndex: delimiterIndex,
7986
quoteIndex: quoteIndex,
87+
escapeIndex: escapeIndex,
8088
encodingIndex: encodingIndex,
8189
lineEndingIndex: lineEndingIndex
8290
)

TablePro/Views/Inspector/CSVPropertyOptions.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ enum CSVPropertyOptions {
2121
(String(localized: "Single Quote '"), 0x27),
2222
]
2323

24+
static let escapes: [(label: String, byte: UInt8)] = [
25+
(String(localized: "Doubled Quote"), 0x22),
26+
(String(localized: "Backslash \\"), 0x5C),
27+
]
28+
2429
static let encodings: [(label: String, encoding: String.Encoding)] = [
2530
("UTF-8", .utf8),
2631
("UTF-16 LE", .utf16LittleEndian),
@@ -43,6 +48,10 @@ enum CSVPropertyOptions {
4348
quotes.firstIndex { $0.byte == byte } ?? 0
4449
}
4550

51+
static func escapeIndex(for byte: UInt8) -> Int {
52+
escapes.firstIndex { $0.byte == byte } ?? 0
53+
}
54+
4655
static func encodingIndex(for encoding: String.Encoding) -> Int {
4756
encodings.firstIndex { $0.encoding == encoding } ?? 0
4857
}
@@ -55,15 +64,18 @@ enum CSVPropertyOptions {
5564
base: CSVDialect,
5665
delimiterIndex: Int,
5766
quoteIndex: Int,
67+
escapeIndex: Int,
5868
encodingIndex: Int,
5969
lineEndingIndex: Int
6070
) -> CSVDialect {
61-
CSVDialect(
71+
var dialect = CSVDialect(
6272
delimiter: delimiters.indices.contains(delimiterIndex) ? delimiters[delimiterIndex].byte : base.delimiter,
6373
quoteChar: quotes.indices.contains(quoteIndex) ? quotes[quoteIndex].byte : base.quoteChar,
6474
encoding: encodings.indices.contains(encodingIndex) ? encodings[encodingIndex].encoding : base.encoding,
6575
lineEnding: lineEndings.indices.contains(lineEndingIndex) ? lineEndings[lineEndingIndex].value : base.lineEnding,
6676
hasBom: base.hasBom
6777
)
78+
dialect.escapeChar = escapes.indices.contains(escapeIndex) ? escapes[escapeIndex].byte : base.escapeChar
79+
return dialect
6880
}
6981
}

TableProTests/Plugins/CSVInspectorTests.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ struct CSVDialectDetectionTests {
7777
let dialect = CSVDialect.detect(from: data)
7878
#expect(dialect.encoding == .windowsCP1252)
7979
}
80+
81+
@Test("Default escape character is the quote character")
82+
func defaultEscapeChar() {
83+
#expect(CSVDialect.csv.escapeChar == 0x22)
84+
let detected = CSVDialect.detect(from: "a,b\n1,2\n".data(using: .utf8)!)
85+
#expect(detected.escapeChar == 0x22)
86+
}
8087
}
8188

8289
@Suite("CSVStreamingParser")
@@ -133,6 +140,25 @@ struct CSVStreamingParserTests {
133140
#expect(fields == ["a", #"say "hi""#, "c"])
134141
}
135142

143+
@Test("Backslash escape decodes an escaped quote to a single quote")
144+
func backslashEscapesQuote() {
145+
var dialect = CSVDialect.csv
146+
dialect.escapeChar = 0x5C
147+
let (data, ranges, parser) = parse(#""a\"b",c"# + "\n", dialect: dialect)
148+
#expect(ranges.count == 1)
149+
let fields = row(data, parser, ranges[0])
150+
#expect(fields == [#"a"b"#, "c"])
151+
}
152+
153+
@Test("Backslash escape decodes a doubled backslash to a single backslash")
154+
func backslashEscapesBackslash() {
155+
var dialect = CSVDialect.csv
156+
dialect.escapeChar = 0x5C
157+
let (data, ranges, parser) = parse(#""a\\b""# + "\n", dialect: dialect)
158+
let fields = row(data, parser, ranges[0])
159+
#expect(fields == [#"a\b"#])
160+
}
161+
136162
@Test("Empty fields preserved")
137163
func emptyFields() {
138164
let (data, ranges, parser) = parse(",,,\n")
@@ -461,6 +487,20 @@ struct CSVWriterRoundTripTests {
461487
#expect(written.prefix(3) == Data([0xEF, 0xBB, 0xBF]))
462488
}
463489

490+
@Test("Writer doubles an embedded quote by default")
491+
func writerDefaultDoublesQuote() {
492+
let writer = CSVWriter(dialect: .csv)
493+
#expect(writer.encodeRow([#"a"b"#, "c"]) == #""a""b",c"#)
494+
}
495+
496+
@Test("Writer escapes an embedded quote with the escape character")
497+
func writerBackslashEscapesQuote() {
498+
var dialect = CSVDialect.csv
499+
dialect.escapeChar = 0x5C
500+
let writer = CSVWriter(dialect: dialect)
501+
#expect(writer.encodeRow([#"a"b"#, "c"]) == #""a\"b",c"#)
502+
}
503+
464504
@Test("A headerless file is written without a synthetic header row")
465505
func roundTripHeaderless() throws {
466506
let source = "1,2\n3,4\n"

TableProTests/Views/CSVPropertyOptionsTests.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,20 @@ struct CSVPropertyOptionsTests {
2222
#expect(CSVPropertyOptions.delimiterIndex(for: 0x5E) == 0)
2323
}
2424

25-
@Test("Building a dialect from indices sets the four properties and keeps the BOM")
25+
@Test("Building a dialect from indices sets every property and keeps the BOM")
2626
func dialectRoundTrip() {
2727
let base = CSVDialect(delimiter: 0x2C, hasBom: true)
2828
let dialect = CSVPropertyOptions.dialect(
2929
base: base,
3030
delimiterIndex: CSVPropertyOptions.delimiterIndex(for: 0x09),
3131
quoteIndex: CSVPropertyOptions.quoteIndex(for: 0x27),
32+
escapeIndex: CSVPropertyOptions.escapeIndex(for: 0x5C),
3233
encodingIndex: CSVPropertyOptions.encodingIndex(for: .utf16LittleEndian),
3334
lineEndingIndex: CSVPropertyOptions.lineEndingIndex(for: .cr)
3435
)
3536
#expect(dialect.delimiter == 0x09)
3637
#expect(dialect.quoteChar == 0x27)
38+
#expect(dialect.escapeChar == 0x5C)
3739
#expect(dialect.encoding == .utf16LittleEndian)
3840
#expect(dialect.lineEnding == .cr)
3941
#expect(dialect.hasBom)

docs/features/csv-inspector.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ When a file opens, TablePro detects:
2626
- **Line ending**: CRLF, LF, or CR, whichever appears first in the first 64 KB.
2727
- **Header row**: row one is treated as headers when at least half of its cells are non-empty and not numbers. Otherwise TablePro generates `Column 1`, `Column 2`, ... and treats every row as data.
2828

29-
When a guess is wrong, choose **Edit > Set CSV Properties…** to pick the delimiter, quote character, encoding, and line ending by hand, then **Reload** to re-read the file with those settings. Reload discards unsaved edits and asks first if you have any.
29+
When a guess is wrong, choose **Edit > Set CSV Properties…** to pick the delimiter, quote character, escape character, encoding, and line ending by hand, then **Reload** to re-read the file with those settings. The escape character is either a doubled quote (the RFC 4180 default) or a backslash. Reload discards unsaved edits and asks first if you have any.
3030

3131
## Pagination
3232

0 commit comments

Comments
 (0)