diff --git a/.cursorrules b/.cursorrules index 50a1152..369a722 100644 --- a/.cursorrules +++ b/.cursorrules @@ -333,7 +333,7 @@ import Foundation - Auto-detect data types - Handle special characters and quotes - Powered by swift-textfile library for robust CSV/TSV parsing and generation -- Falls back to manual parsing/generation for custom separators (when separator ≠ comma or tab) +- CSV and TSV only (no custom delimiters); spec-compliant parsing/generation via swift-textfile ### Image Operations (XLKitImages Module) - Support GIF, PNG, JPEG formats (BMP, TIFF removed for compatibility) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13d4ae8..35f4fcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,12 @@ **🔧 Improvements:** - Migrated CSV/TSV dependency from [swift-textfile-tools](https://github.com/orchetect/swift-textfile-tools) to [swift-textfile](https://github.com/orchetect/swift-textfile) -- Updated `XLKitFormatters/CSVUtils` to use `import TextFile` and the `TextFile` product; all CSV/TSV behavior unchanged -- Updated documentation and rules (`.cursorrules`, AGENT.MD, README, Manual, tests) to reference swift-textfile and TextFile +- Updated `XLKitFormatters/CSVUtils` to use `import TextFile` and the `TextFile` product +- CSV and TSV are treated as defined formats (e.g. RFC 4180 for CSV) with standard quote/escape rules; all parsing and generation go through swift-textfile for spec compliance +- Removed custom-delimiter boilerplate (`generateCustomDelimitedText`, `parseCustomDelimitedText`) to avoid non-standard formats and delimiter-vs-content collision pitfalls +- Removed custom delimiter support from CSV import/export. Only CSV (comma) and TSV (tab) are now supported. +- **API changes:** `exportToCSV(separator:)` → `exportToCSV()`; `importCSV(_:into:separator:hasHeader:)` → `importCSV(_:into:hasHeader:)`; `init(fromCSV:sheetName:separator:hasHeader:)` → `init(fromCSV:sheetName:hasHeader:)`; `exportSheetToCSV(_:separator:)` → `exportSheetToCSV(_:)`. Same for `CSVUtils` static methods (no `separator` parameter). +- Updated documentation and rules (`.cursorrules`, AGENT.MD, README, Manual) to reference swift-textfile and TextFile --- diff --git a/Documentation/Manual.md b/Documentation/Manual.md index fb2c2d1..f02f151 100644 --- a/Documentation/Manual.md +++ b/Documentation/Manual.md @@ -469,7 +469,7 @@ let range2 = CellRange(excelRange: "A1:B5") - Sheet: `setCell`, `setRow`, `setColumn`, `setRange`, `mergeCells`, `embedImageAutoSized`, `setColumnWidth` - Convenience Methods: Type-specific setters like `setCell(string:format:)`, `setRange(number:format:)` - Images: GIF, PNG, JPEG with perfect aspect ratio preservation -- CSV/TSV: `Workbook(fromCSV:)`, `exportToCSV()`, `importCSVIntoSheet` +- CSV/TSV: `Workbook(fromCSV:)`, `Workbook(fromTSV:)`, `exportToCSV()`, `exportToTSV()`, `importCSV(_:into:hasHeader:)`, `importTSV(_:into:hasHeader:)` - Fluent API: Most setters return `Self` for chaining - Bulk Data: `setRow`, `setColumn` for easy import - Utility Properties: `allCells`, `allFormattedCells`, `isEmpty`, `cellCount`, `imageCount` @@ -663,7 +663,7 @@ do { ## CSV/TSV Import & Export -XLKit provides simple static methods for importing and exporting CSV/TSV data. CSV/TSV parsing and generation is powered by the [swift-textfile](https://github.com/orchetect/swift-textfile) library, which provides robust handling of quoted fields, escaped quotes, and edge cases. For custom separators (other than comma or tab), XLKit falls back to manual parsing/generation. +XLKit provides simple static methods for importing and exporting CSV/TSV data. Only CSV (comma) and TSV (tab) are supported; both are defined formats with standard quote and escape rules (e.g. RFC 4180 for CSV). Parsing and generation are powered by the [swift-textfile](https://github.com/orchetect/swift-textfile) library for spec-compliant handling of quoted fields and escaped quotes. ```swift // Create a workbook from CSV @@ -678,8 +678,8 @@ let sheet = workbook.getSheets().first! // Export a sheet to CSV let csv = sheet.exportToCSV() -// Import CSV into an existing sheet -sheet.importCSV(csvData, hasHeader: true) +// Import CSV into an existing sheet (Workbook method) +workbook.importCSV(csvData, into: sheet, hasHeader: true) // Create a workbook from TSV let tsvData = """ @@ -693,8 +693,8 @@ let tsvSheet = tsvWorkbook.getSheets().first! // Export a sheet to TSV let tsv = tsvSheet.exportToTSV() -// Import TSV into an existing sheet -tsvSheet.importTSV(tsvData, hasHeader: true) +// Import TSV into an existing sheet (Workbook method) +tsvWorkbook.importTSV(tsvData, into: tsvSheet, hasHeader: true) ``` All CSV/TSV helpers are available as instance methods on `Workbook` and `Sheet` classes for convenience, and are powered by the `XLKitFormatters` module under the hood. @@ -1411,13 +1411,13 @@ This section lists the full public API of XLKit. All types and members are avail | `getImages(withFormat format: ImageFormat) -> [ExcelImage]` | Return images filtered by format. | | `clearImages()` | Remove all workbook-level images. | | `imageCount: Int` | Number of images in the workbook. | -| `init(fromCSV csvData: String, sheetName:separator:hasHeader:)` | Convenience initializer: create a workbook from CSV. | +| `init(fromCSV csvData: String, sheetName:hasHeader:)` | Convenience initializer: create a workbook from CSV (comma-separated). | | `init(fromTSV tsvData: String, sheetName:hasHeader:)` | Convenience initializer: create a workbook from TSV. | | `save(to url: URL) throws` | Save the workbook to a file (synchronous; `@MainActor`). | | `save(to url: URL) async throws` | Save the workbook to a file (asynchronous; `@MainActor`). | -| `importCSV(_:into:separator:hasHeader:)` | Import CSV into an existing sheet. | +| `importCSV(_:into:hasHeader:)` | Import CSV into an existing sheet (comma-separated). | | `importTSV(_:into:hasHeader:)` | Import TSV into an existing sheet. | -| `exportSheetToCSV(_ sheet:separator:) -> String` | Export a sheet to CSV. | +| `exportSheetToCSV(_ sheet:) -> String` | Export a sheet to CSV (comma-separated). | | `exportSheetToTSV(_ sheet:) -> String` | Export a sheet to TSV. | ### Sheet API @@ -1489,7 +1489,7 @@ This section lists the full public API of XLKit. All types and members are avail | `embedImage(_ data:at:of:scale:maxWidth:maxHeight:) async throws -> Bool` | Embed image with scaling parameters. | | `embedImage(from url: URL, at coordinate: String, displaySize: CGSize? = nil) async throws -> Bool` | Embed image from file URL. | | `embedImage(from path: String, at coordinate: String, displaySize: CGSize? = nil) async throws -> Bool` | Embed image from file path. | -| `exportToCSV(separator: String = ",") -> String` | Export sheet to CSV. | +| `exportToCSV() -> String` | Export sheet to CSV (comma-separated). | | `exportToTSV() -> String` | Export sheet to TSV. | **Other** @@ -1635,15 +1635,15 @@ Static configuration and methods (`@MainActor`): ### CSVUtils -Static methods; typically used via `Workbook`/`Sheet` instance methods. CSV/TSV parsing and generation is powered by the [swift-textfile](https://github.com/orchetect/swift-textfile) library for robust handling of quoted fields, escaped quotes, and edge cases. +Static methods; typically used via `Workbook`/`Sheet` instance methods. Only CSV (comma) and TSV (tab) are supported. Parsing and generation are powered by the [swift-textfile](https://github.com/orchetect/swift-textfile) library for spec-compliant handling of quoted fields, escaped quotes, and edge cases (e.g. RFC 4180 for CSV). | Member | Description | |--------|-------------| -| `exportToCSV(sheet: Sheet, separator: String = ",") -> String` | Export sheet to CSV. Uses TextFile for comma-separated values; falls back to manual generation for custom separators. | -| `exportToTSV(sheet: Sheet) -> String` | Export sheet to TSV. Uses TextFile for tab-separated values. | -| `importFromCSV(sheet: Sheet, csvData: String, separator: String, hasHeader: Bool)` | Import CSV into sheet. Uses TextFile for comma-separated values; falls back to manual parsing for custom separators. | -| `importFromTSV(sheet: Sheet, tsvData: String, hasHeader: Bool)` | Import TSV into sheet. Uses TextFile for tab-separated values. | -| `createWorkbookFromCSV(csvData: String, sheetName: String, separator: String, hasHeader: Bool) -> Workbook` | New workbook from CSV. | +| `exportToCSV(sheet: Sheet) -> String` | Export sheet to CSV (comma-separated; spec-compliant via TextFile). | +| `exportToTSV(sheet: Sheet) -> String` | Export sheet to TSV (tab-separated; spec-compliant via TextFile). | +| `importFromCSV(sheet: Sheet, csvData: String, hasHeader: Bool)` | Import CSV into sheet (comma-separated). | +| `importFromTSV(sheet: Sheet, tsvData: String, hasHeader: Bool)` | Import TSV into sheet. | +| `createWorkbookFromCSV(csvData: String, sheetName: String, hasHeader: Bool) -> Workbook` | New workbook from CSV (comma-separated). | | `createWorkbookFromTSV(tsvData: String, sheetName: String, hasHeader: Bool) -> Workbook` | New workbook from TSV. | ### XLKitError diff --git a/SECURITY.md b/SECURITY.md index 441baad..4e49e80 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,7 @@ | Version | Supported | | ------- | ------------------ | -| 1.1.1 | :white_check_mark: | +| 1.1.2 | :white_check_mark: | ## Reporting a Vulnerability diff --git a/Sources/XLKit/Sheet+API.swift b/Sources/XLKit/Sheet+API.swift index d13c6c9..47b9908 100644 --- a/Sources/XLKit/Sheet+API.swift +++ b/Sources/XLKit/Sheet+API.swift @@ -66,9 +66,9 @@ public extension Sheet { return setColumn(column, values: values) } // MARK: - CSV/TSV Export Methods - /// Exports the sheet to CSV format - func exportToCSV(separator: String = ",") -> String { - return CSVUtils.exportToCSV(sheet: self, separator: separator) + /// Exports the sheet to CSV format (comma-separated). + func exportToCSV() -> String { + return CSVUtils.exportToCSV(sheet: self) } /// Exports the sheet to TSV format func exportToTSV() -> String { diff --git a/Sources/XLKit/Workbook+API.swift b/Sources/XLKit/Workbook+API.swift index 5306fdc..0779de5 100644 --- a/Sources/XLKit/Workbook+API.swift +++ b/Sources/XLKit/Workbook+API.swift @@ -10,11 +10,11 @@ import XLKitXLSX import XLKitFormatters public extension Workbook { - /// Creates a workbook from CSV data - convenience init(fromCSV csvData: String, sheetName: String = "Sheet1", separator: String = ",", hasHeader: Bool = false) { + /// Creates a workbook from CSV data (comma-separated). + convenience init(fromCSV csvData: String, sheetName: String = "Sheet1", hasHeader: Bool = false) { self.init() let sheet = addSheet(name: sheetName) - importCSV(csvData, into: sheet, separator: separator, hasHeader: hasHeader) + importCSV(csvData, into: sheet, hasHeader: hasHeader) } /// Creates a workbook from TSV data convenience init(fromTSV tsvData: String, sheetName: String = "Sheet1", hasHeader: Bool = false) { @@ -34,17 +34,17 @@ public extension Workbook { try XLSXEngine.generateXLSX(workbook: self, to: url) } // MARK: - CSV/TSV Operations - /// Imports CSV data into a sheet - func importCSV(_ csvData: String, into sheet: Sheet, separator: String = ",", hasHeader: Bool = false) { - CSVUtils.importFromCSV(sheet: sheet, csvData: csvData, separator: separator, hasHeader: hasHeader) + /// Imports CSV data into a sheet (comma-separated). + func importCSV(_ csvData: String, into sheet: Sheet, hasHeader: Bool = false) { + CSVUtils.importFromCSV(sheet: sheet, csvData: csvData, hasHeader: hasHeader) } /// Imports TSV data into a sheet func importTSV(_ tsvData: String, into sheet: Sheet, hasHeader: Bool = false) { CSVUtils.importFromTSV(sheet: sheet, tsvData: tsvData, hasHeader: hasHeader) } - /// Exports a sheet to CSV format - func exportSheetToCSV(_ sheet: Sheet, separator: String = ",") -> String { - return CSVUtils.exportToCSV(sheet: sheet, separator: separator) + /// Exports a sheet to CSV format (comma-separated). + func exportSheetToCSV(_ sheet: Sheet) -> String { + return CSVUtils.exportToCSV(sheet: sheet) } /// Exports a sheet to TSV format func exportSheetToTSV(_ sheet: Sheet) -> String { diff --git a/Sources/XLKitFormatters/CSVUtils.swift b/Sources/XLKitFormatters/CSVUtils.swift index 8cb1897..258a938 100644 --- a/Sources/XLKitFormatters/CSVUtils.swift +++ b/Sources/XLKitFormatters/CSVUtils.swift @@ -8,53 +8,51 @@ import Foundation @preconcurrency import XLKitCore import TextFile -/// CSV/TSV import/export utilities for XLKit -/// Uses swift-textfile library for robust CSV/TSV parsing and generation +/// CSV/TSV import/export utilities for XLKit. +/// +/// Only CSV (comma) and TSV (tab) are supported. These are defined formats with +/// standard quote and escape rules (e.g. RFC 4180 for CSV). Custom delimiters +/// are not supported to avoid non-standard formats and delimiter-vs-content +/// collision pitfalls. +/// +/// Uses the swift-textfile library for spec-compliant CSV/TSV parsing and generation. public struct CSVUtils { - - /// Exports a sheet to CSV format - public static func exportToCSV(sheet: Sheet, separator: String = ",") -> String { + + /// Exports a sheet to CSV format (comma-separated, RFC 4180-style). + public static func exportToCSV(sheet: Sheet) -> String { // Find max row/column from used cells let usedCells = sheet.getUsedCells() var maxRow = 0 var maxColumn = 0 - + for coordinate in usedCells { guard let cellCoord = CellCoordinate(excelAddress: coordinate) else { continue } maxRow = max(maxRow, cellCoord.row) maxColumn = max(maxColumn, cellCoord.column) } - + // Build StringTable from sheet data var stringTable: StringTable = [] - + for row in 1...maxRow { var rowData: [String] = [] - + for column in 1...maxColumn { let coord = CellCoordinate(row: row, column: column) let cellAddress = coord.excelAddress - + if let cellValue = sheet.getCell(cellAddress) { rowData.append(cellValue.stringValue) } else { rowData.append("") } } - + stringTable.append(rowData) } - - // Use TextFile for CSV generation - if separator == "," { - let csv = CSV(table: stringTable) - return csv.rawText - } else { - // For custom separators, we need to use a delimited format - // Since TextFile only supports CSV (comma) and TSV (tab), - // we'll fall back to manual generation for custom separators - return generateCustomDelimitedText(table: stringTable, separator: separator) - } + + let csv = CSV(table: stringTable) + return csv.rawText } /// Exports a sheet to TSV format @@ -95,18 +93,11 @@ public struct CSVUtils { return tsv.rawText } - /// Imports CSV data into a sheet - public static func importFromCSV(sheet: Sheet, csvData: String, separator: String = ",", hasHeader: Bool = false) { - // Use TextFile for CSV parsing - let stringTable: StringTable - if separator == "," { - let csv = CSV(rawText: csvData) - stringTable = csv.table - } else { - // For custom separators, parse manually - stringTable = parseCustomDelimitedText(text: csvData, separator: separator) - } - + /// Imports CSV data into a sheet (comma-separated, RFC 4180-style). + public static func importFromCSV(sheet: Sheet, csvData: String, hasHeader: Bool = false) { + let csv = CSV(rawText: csvData) + let stringTable = csv.table + // Determine which rows to import let dataRows: [[String]] if hasHeader && !stringTable.isEmpty { @@ -161,11 +152,11 @@ public struct CSVUtils { } } - /// Creates a workbook from CSV data - public static func createWorkbookFromCSV(csvData: String, sheetName: String = "Sheet1", separator: String = ",", hasHeader: Bool = false) -> Workbook { + /// Creates a workbook from CSV data (comma-separated). + public static func createWorkbookFromCSV(csvData: String, sheetName: String = "Sheet1", hasHeader: Bool = false) -> Workbook { let workbook = Workbook() let sheet = workbook.addSheet(name: sheetName) - importFromCSV(sheet: sheet, csvData: csvData, separator: separator, hasHeader: hasHeader) + importFromCSV(sheet: sheet, csvData: csvData, hasHeader: hasHeader) return workbook } @@ -213,76 +204,6 @@ public struct CSVUtils { return .string(trimmed) } - /// Generates custom delimited text for separators other than comma or tab - /// Falls back to manual generation when TextFile doesn't support the separator - private static func generateCustomDelimitedText(table: StringTable, separator: String) -> String { - return table.map { row in - row.map { textString in - var outString = textString - - // Escape double-quotes - outString = outString.replacingOccurrences(of: "\"", with: "\"\"") - - // Wrap string in double-quotes if it contains separator, quotes, or newlines - if outString.contains(separator) || - outString.contains("\"") || - outString.contains("\n") || - outString.contains("\r") { - outString = "\"\(outString)\"" - } - - return outString - } - .joined(separator: separator) - } - .joined(separator: "\n") - } - - /// Parses custom delimited text for separators other than comma or tab - /// Falls back to manual parsing when TextFile doesn't support the separator - private static func parseCustomDelimitedText(text: String, separator: String) -> StringTable { - let lines = text.components(separatedBy: .newlines) - .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - - var result: StringTable = [] - - for line in lines { - var fields: [String] = [] - var currentField = "" - var inQuotes = false - var i = 0 - - while i < line.count { - let char = line[line.index(line.startIndex, offsetBy: i)] - - if char == "\"" { - if inQuotes { - // Check for escaped quote - if i + 1 < line.count && line[line.index(line.startIndex, offsetBy: i + 1)] == "\"" { - currentField += "\"" - i += 1 // Skip the next quote - } else { - inQuotes = false - } - } else { - inQuotes = true - } - } else if String(char) == separator && !inQuotes { - fields.append(currentField) - currentField = "" - } else { - currentField += String(char) - } - - i += 1 - } - - fields.append(currentField) - result.append(fields) - } - - return result - } } // MARK: - String Extension for Pattern Matching