From 0baf1946b554b04edc287f52133a3e8aea3e3a0f Mon Sep 17 00:00:00 2001 From: TUSHAR BHAMBERE <77154182+tusharbhambere@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:41:09 +0530 Subject: [PATCH 1/4] v 2.3.1 --- CHANGELOG.md | 3 +++ README.md | 6 +++++- lib/spreadsheet_decoder.dart | 1 - lib/src/ods.dart | 37 +++++++++++++++++++++++++++++++++--- lib/src/spreadsheet.dart | 28 ++++++++++++++------------- lib/src/xlsx.dart | 27 ++++++++++++++++++++------ pubspec.yaml | 12 ++++++------ test/common.dart | 29 +++++++++++++++++++++++++++- test/common_html.dart | 8 ++++++-- test/common_io.dart | 6 ++++-- tool/archive.dart | 7 +++---- 11 files changed, 125 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 465d8b3..530eb2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## Unreleased +Add `raw` decoding mode to return unconverted cell values as strings (no date/serial/type parsing) + ## 2.3.0 Update dependencies diff --git a/README.md b/README.md index 1ab36b7..e3c371c 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Spreadsheet Decoder is a library for decoding and updating spreadsheets for ODS main() { var bytes = File.fromUri(fullUri).readAsBytesSync(); var decoder = SpreadsheetDecoder.decodeBytes(bytes); + var rawDecoder = SpreadsheetDecoder.decodeBytes(bytes, raw: true); var table = decoder.tables['Sheet1']; var values = table.rows[0]; ... @@ -33,6 +34,7 @@ Spreadsheet Decoder is a library for decoding and updating spreadsheets for ODS var reader = FileReader(); reader.onLoadEnd.listen((event) { var decoder = SpreadsheetDecoder.decodeBytes(reader.result); + var rawDecoder = SpreadsheetDecoder.decodeBytes(reader.result, raw: true); var table = decoder.tables['Sheet1']; var values = table.rows[0]; ... @@ -50,9 +52,11 @@ This implementation doesn't support following features: - hidden rows (visible in resulting tables) - hidden columns (visible in resulting tables) -For XLSX format, this implementation only supports native Excel format for date, time and boolean type conversion. +For XLSX format, this implementation only supports native Excel format for date, time and boolean type conversion when `raw` is `false`. In other words, custom format for date, time, boolean aren't supported and then file exported from LibreOffice as well. +To disable automatic type conversion and get raw cell content as strings, use `raw: true`. + ## License The MIT License, see [LICENSE](https://github.com/sestegra/spreadsheet_decoder/raw/master/LICENSE). diff --git a/lib/spreadsheet_decoder.dart b/lib/spreadsheet_decoder.dart index 3c0b08e..b202527 100644 --- a/lib/spreadsheet_decoder.dart +++ b/lib/spreadsheet_decoder.dart @@ -1,7 +1,6 @@ library spreadsheet_decoder; import 'dart:convert'; -import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:xml/xml.dart'; diff --git a/lib/src/ods.dart b/lib/src/ods.dart index 261b736..2656024 100644 --- a/lib/src/ods.dart +++ b/lib/src/ods.dart @@ -19,9 +19,10 @@ class OdsDecoder extends SpreadsheetDecoder { String get extension => '.ods'; final Map> _styleNames = {}; - OdsDecoder(Archive archive, {bool update = false}) { + OdsDecoder(Archive archive, {bool update = false, bool raw = false}) { _archive = archive; _update = update; + _raw = raw; _tables = {}; _parseContent(); } @@ -96,8 +97,11 @@ class OdsDecoder extends SpreadsheetDecoder { void _parseContent() { var file = _archive.findFile(contentXML); - file?.decompress(); - var content = XmlDocument.parse(utf8.decode(file?.content)); + if (file == null) { + throw FormatException('Missing required file: $contentXML'); + } + file.decompress(); + var content = XmlDocument.parse(utf8.decode(file.content)); if (_update == true) { _archiveFiles = {}; _sheets = {}; @@ -185,6 +189,33 @@ class OdsDecoder extends SpreadsheetDecoder { dynamic _readCell(XmlElement node) { dynamic value; var type = node.getAttribute('office:value-type'); + if (_raw) { + switch (type) { + case 'float': + case 'percentage': + case 'currency': + value = node.getAttribute('office:value'); + break; + case 'boolean': + value = node.getAttribute('office:boolean-value'); + break; + case 'date': + value = node.getAttribute('office:date-value'); + break; + case 'time': + value = node.getAttribute('office:time-value'); + break; + case 'string': + default: + var list = []; + node.findElements('text:p').forEach((child) { + list.add(_readString(child)); + }); + value = (list.isNotEmpty) ? list.join('\n') : null; + } + return value; + } + switch (type) { case 'float': case 'percentage': diff --git a/lib/src/spreadsheet.dart b/lib/src/spreadsheet.dart index 0316ad1..32301d1 100644 --- a/lib/src/spreadsheet.dart +++ b/lib/src/spreadsheet.dart @@ -13,7 +13,8 @@ String _normalizeNewLine(String text) { return text.replaceAll('\r\n', '\n'); } -SpreadsheetDecoder _newSpreadsheetDecoder(Archive archive, bool update) { +SpreadsheetDecoder _newSpreadsheetDecoder( + Archive archive, bool update, bool raw) { // Lookup at file format String? format; @@ -34,9 +35,9 @@ SpreadsheetDecoder _newSpreadsheetDecoder(Archive archive, bool update) { switch (format) { case _spreasheetOds: - return OdsDecoder(archive, update: update); + return OdsDecoder(archive, update: update, raw: raw); case _spreasheetXlsx: - return XlsxDecoder(archive, update: update); + return XlsxDecoder(archive, update: update, raw: raw); default: throw UnsupportedError('Spreadsheet format unsupported'); } @@ -45,6 +46,7 @@ SpreadsheetDecoder _newSpreadsheetDecoder(Archive archive, bool update) { /// Decode a spreadsheet file. abstract class SpreadsheetDecoder { late bool _update; + late bool _raw; late Archive _archive; late Map _sheets; late Map _xmlFiles; @@ -64,15 +66,15 @@ abstract class SpreadsheetDecoder { SpreadsheetDecoder(); factory SpreadsheetDecoder.decodeBytes(List data, - {bool update = false, bool verify = false}) { + {bool update = false, bool verify = false, bool raw = false}) { var archive = ZipDecoder().decodeBytes(data, verify: verify); - return _newSpreadsheetDecoder(archive, update); + return _newSpreadsheetDecoder(archive, update, raw); } - factory SpreadsheetDecoder.decodeBuffer(InputStreamBase input, - {bool update = false, bool verify = false}) { - var archive = ZipDecoder().decodeBuffer(input, verify: verify); - return _newSpreadsheetDecoder(archive, update); + factory SpreadsheetDecoder.decodeBuffer(InputStream input, + {bool update = false, bool verify = false, bool raw = false}) { + var archive = ZipDecoder().decodeStream(input, verify: verify); + return _newSpreadsheetDecoder(archive, update, raw); } /// Dump XML content (for debug purpose) @@ -169,7 +171,7 @@ abstract class SpreadsheetDecoder { var content = utf8.encode(xml); _archiveFiles[xmlFile] = ArchiveFile(xmlFile, content.length, content); } - return ZipEncoder().encode(_cloneArchive(_archive)) as List; + return ZipEncoder().encode(_cloneArchive(_archive)); } /// Encode data url @@ -188,10 +190,10 @@ abstract class SpreadsheetDecoder { if (_archiveFiles.containsKey(file.name)) { copy = _archiveFiles[file.name]!; } else { - var content = file.content as Uint8List; - var compress = file.compress; + var content = file.content; + var compression = file.compression; copy = ArchiveFile(file.name, content.length, content) - ..compress = compress; + ..compression = compression; } clone.addFile(copy); } diff --git a/lib/src/xlsx.dart b/lib/src/xlsx.dart index 22421bd..fdf265e 100644 --- a/lib/src/xlsx.dart +++ b/lib/src/xlsx.dart @@ -101,9 +101,10 @@ class XlsxDecoder extends SpreadsheetDecoder { String? _sharedStringsTarget; final Map _worksheetTargets = {}; - XlsxDecoder(Archive archive, {bool update = false}) { + XlsxDecoder(Archive archive, {bool update = false, bool raw = false}) { _archive = archive; _update = update; + _raw = raw; if (_update == true) { _archiveFiles = {}; _sheets = {}; @@ -305,8 +306,11 @@ class XlsxDecoder extends SpreadsheetDecoder { void _parseContent() { var workbook = _archive.findFile('xl/workbook.xml'); - workbook?.decompress(); - var document = XmlDocument.parse(utf8.decode(workbook?.content)); + if (workbook == null) { + throw FormatException('Missing required file: xl/workbook.xml'); + } + workbook.decompress(); + var document = XmlDocument.parse(utf8.decode(workbook.content)); document.findAllElements('sheet').forEach((node) { _parseTable(node); }); @@ -321,9 +325,12 @@ class XlsxDecoder extends SpreadsheetDecoder { final namePath = target.startsWith('/') ? target.substring(1) : 'xl/$target'; var file = _archive.findFile(namePath); - file?.decompress(); + if (file == null) { + throw FormatException('Missing required file: $namePath'); + } + file.decompress(); - var content = XmlDocument.parse(utf8.decode(file?.content)); + var content = XmlDocument.parse(utf8.decode(file.content)); var worksheet = content.findElements('worksheet').first; var sheet = worksheet.findElements('sheetData').first; @@ -386,7 +393,11 @@ class XlsxDecoder extends SpreadsheetDecoder { break; // boolean case 'b': - value = _parseValue(node.findElements('v').first) == '1'; + if (_raw) { + value = _parseValue(node.findElements('v').first); + } else { + value = _parseValue(node.findElements('v').first) == '1'; + } break; // error case 'e': @@ -411,6 +422,10 @@ class XlsxDecoder extends SpreadsheetDecoder { var s = node.getAttribute('s'); var valueNode = node.findElements('v'); var content = valueNode.first; + if (_raw) { + value = _parseValue(content); + break; + } if (s != null) { var fmtId = _numFormats[int.parse(s)]; // date diff --git a/pubspec.yaml b/pubspec.yaml index 0da5762..362f312 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,16 +1,16 @@ name: spreadsheet_decoder description: A lightweight library for parsing and updating spreadsheet documents -version: 2.3.0 +version: 2.3.1 homepage: https://github.com/sestegra/spreadsheet environment: sdk: "^3.0.0" dependencies: - archive: ^3.6.1 - xml: ^6.5.0 + archive: ^4.0.9 + xml: ^6.6.1 dev_dependencies: - path: ^1.9.0 - lints: ^4.0.0 - test: ^1.25.8 + path: ^1.9.1 + lints: ^6.1.0 + test: ^1.30.0 diff --git a/test/common.dart b/test/common.dart index e189120..9e625e0 100644 --- a/test/common.dart +++ b/test/common.dart @@ -101,6 +101,25 @@ var expectedFormat = >{ ] }; +var expectedRawFormat = >{ + 'Sheet1': [ + [ + '1337', + '1337.42', + '1234.56', + '1', + 'Hello World', + 'One line\nTwo lines\nThree lines', + '0.124', + '38749', + '38749.567361111112', + '38749.567847222221', + '0.56736111111111109', + '0.56784722222222228', + ] + ] +}; + var expectedEmptyColumn = >{ 'EMPTY': [ ['a1', null, 'c1'], @@ -299,6 +318,14 @@ void testXlsx() { }); }); + test('Format file raw mode:', () { + var decoder = decode('format.xlsx', raw: true); + expect(decoder.tables.length, expectedRawFormat.keys.length); + decoder.tables.forEach((name, table) { + expect(table.rows, expectedRawFormat[name]); + }); + }); + test('Empty column file:', () { var decoder = decode('empty_column.xlsx'); expect(decoder.tables.length, expectedEmptyColumn.keys.length); @@ -327,7 +354,7 @@ void testXlsx() { expect(decoder.tables.length, expectedNoPhonetics.keys.length); decoder.tables.forEach((name, table) { expect(table.rows, expectedNoPhonetics[name]); - }); + }); }); }); } diff --git a/test/common_html.dart b/test/common_html.dart index 53d9974..6efdc47 100644 --- a/test/common_html.dart +++ b/test/common_html.dart @@ -32,8 +32,12 @@ String readBase64(String filename) { return files[filename]!; } -SpreadsheetDecoder decode(String filename, {bool update = false}) { - return SpreadsheetDecoder.decodeBytes(Base64Decoder().convert(readBase64(filename)), update: update); +SpreadsheetDecoder decode(String filename, + {bool update = false, bool raw = false}) { + return SpreadsheetDecoder.decodeBytes( + Base64Decoder().convert(readBase64(filename)), + update: update, + raw: raw); } void save(String file, List data) {} diff --git a/test/common_io.dart b/test/common_io.dart index 1a6a299..6917433 100644 --- a/test/common_io.dart +++ b/test/common_io.dart @@ -11,8 +11,10 @@ String readBase64(String filename) { return base64Encode(_readBytes(filename)); } -SpreadsheetDecoder decode(String filename, {bool update = false}) { - return SpreadsheetDecoder.decodeBytes(_readBytes(filename), update: update, verify: true); +SpreadsheetDecoder decode(String filename, + {bool update = false, bool raw = false}) { + return SpreadsheetDecoder.decodeBytes(_readBytes(filename), + update: update, verify: true, raw: raw); } void save(String file, List data) { diff --git a/tool/archive.dart b/tool/archive.dart index 0b5eac0..a81b83c 100644 --- a/tool/archive.dart +++ b/tool/archive.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:path/path.dart'; @@ -7,9 +6,9 @@ Archive cloneArchive(Archive archive) { var clone = Archive(); for (var file in archive.files) { if (file.isFile) { - var content = file.content as Uint8List; + var content = file.content; var copy = ArchiveFile(file.name, content.length, content) - ..compress = file.compress; + ..compression = file.compression; clone.addFile(copy); } } @@ -37,7 +36,7 @@ void main(List args) { print(input); var archive = ZipDecoder().decodeBytes(File(input).readAsBytesSync(), verify: true); - var zip = ZipEncoder().encode(cloneArchive(archive)) as List; + var zip = ZipEncoder().encode(cloneArchive(archive)); try { archive = ZipDecoder().decodeBytes(zip, verify: true); var file = File('test/out/example/${basename(input)}') From e2bebd1d9ea83594bfacd69e104e512edba3c671 Mon Sep 17 00:00:00 2001 From: TUSHAR BHAMBERE <77154182+tusharbhambere@users.noreply.github.com> Date: Fri, 15 May 2026 11:03:50 +0530 Subject: [PATCH 2/4] v 2.3.2 --- CHANGELOG.md | 2 +- README.md | 7 +- example/spreadsheet_decoder.dart | 55 +++--- lib/src/ods.dart | 29 +-- lib/src/spreadsheet.dart | 16 +- lib/src/xlsx.dart | 270 ++++++++++++++++++++++++-- pubspec.yaml | 6 +- test/.DS_Store | Bin 0 -> 8196 bytes test/common.dart | 27 --- test/common_html.dart | 6 +- test/common_io.dart | 5 +- test/custom_date_format_test.dart | 50 +++++ test/files/new_sample_file_bytco.xlsx | Bin 0 -> 10082 bytes test/files/sample_date_format.xlsx | Bin 0 -> 7323 bytes test/files/test_date_excel.xlsx | Bin 0 -> 6230 bytes 15 files changed, 354 insertions(+), 119 deletions(-) create mode 100644 test/.DS_Store create mode 100644 test/custom_date_format_test.dart create mode 100644 test/files/new_sample_file_bytco.xlsx create mode 100644 test/files/sample_date_format.xlsx create mode 100644 test/files/test_date_excel.xlsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 530eb2c..cc7d595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,5 @@ ## Unreleased -Add `raw` decoding mode to return unconverted cell values as strings (no date/serial/type parsing) +XLSX: decode dates with custom `` format codes (e.g. `dd/mm/yyyy`) using the workbook's own format code instead of leaking through as raw serial numbers. Removed the `raw` decoding mode in favour of always returning typed values. ## 2.3.0 Update dependencies diff --git a/README.md b/README.md index e3c371c..c63cdc8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ Spreadsheet Decoder is a library for decoding and updating spreadsheets for ODS main() { var bytes = File.fromUri(fullUri).readAsBytesSync(); var decoder = SpreadsheetDecoder.decodeBytes(bytes); - var rawDecoder = SpreadsheetDecoder.decodeBytes(bytes, raw: true); var table = decoder.tables['Sheet1']; var values = table.rows[0]; ... @@ -34,7 +33,6 @@ Spreadsheet Decoder is a library for decoding and updating spreadsheets for ODS var reader = FileReader(); reader.onLoadEnd.listen((event) { var decoder = SpreadsheetDecoder.decodeBytes(reader.result); - var rawDecoder = SpreadsheetDecoder.decodeBytes(reader.result, raw: true); var table = decoder.tables['Sheet1']; var values = table.rows[0]; ... @@ -52,10 +50,9 @@ This implementation doesn't support following features: - hidden rows (visible in resulting tables) - hidden columns (visible in resulting tables) -For XLSX format, this implementation only supports native Excel format for date, time and boolean type conversion when `raw` is `false`. -In other words, custom format for date, time, boolean aren't supported and then file exported from LibreOffice as well. +For XLSX format, this implementation supports the native Excel formats for date, time and boolean type conversion, plus custom `` format codes declared in the workbook (e.g. `dd/mm/yyyy`). -To disable automatic type conversion and get raw cell content as strings, use `raw: true`. +Important: Excel often stores date cells as numeric serial values and only formats them for display. The decoder applies the workbook's format code to render the date as a string. If you need a fixed text representation independent of the workbook formatting, the source cell must be stored as text in the spreadsheet. ## License diff --git a/example/spreadsheet_decoder.dart b/example/spreadsheet_decoder.dart index 02999ae..98d6212 100644 --- a/example/spreadsheet_decoder.dart +++ b/example/spreadsheet_decoder.dart @@ -1,11 +1,10 @@ import 'dart:io'; -import 'package:path/path.dart'; import 'package:spreadsheet_decoder/spreadsheet_decoder.dart'; void main(List args) { - var file = 'test/files/test.xlsx'; + var file = 'test/files/test_date_excel.xlsx'; var bytes = File(file).readAsBytesSync(); - var decoder = SpreadsheetDecoder.decodeBytes(bytes, update: true); + var decoder = SpreadsheetDecoder.decodeBytes(bytes, update: false); for (var table in decoder.tables.keys) { print(table); print(decoder.tables[table]!.maxCols); @@ -15,31 +14,31 @@ void main(List args) { } } - var sheet = decoder.tables.keys.first; - decoder - ..updateCell(sheet, 0, 0, "L'oiseau <\"coucou\">") - ..updateCell(sheet, 1, 0, 'B') - ..updateCell(sheet, 2, 0, 'C') - ..updateCell(sheet, 1, 1, 42.3) - ..insertRow(sheet, 1) - ..insertRow(sheet, 13) - ..updateCell(sheet, 0, 13, 'A14') - ..updateCell(sheet, 0, 12, 'A13') - ..insertColumn(sheet, 0) - ..removeRow(sheet, 1) - ..removeColumn(sheet, 2); + // var sheet = decoder.tables.keys.first; + // decoder + // ..updateCell(sheet, 0, 0, "L'oiseau <\"coucou\">") + // ..updateCell(sheet, 1, 0, 'B') + // ..updateCell(sheet, 2, 0, 'C') + // ..updateCell(sheet, 1, 1, 42.3) + // ..insertRow(sheet, 1) + // ..insertRow(sheet, 13) + // ..updateCell(sheet, 0, 13, 'A14') + // ..updateCell(sheet, 0, 12, 'A13') + // ..insertColumn(sheet, 0) + // ..removeRow(sheet, 1) + // ..removeColumn(sheet, 2); - File(join('test/out/${basename(file)}')) - ..createSync(recursive: true) - ..writeAsBytesSync(decoder.encode()); + // File(join('test/out/${basename(file)}')) + // ..createSync(recursive: true) + // ..writeAsBytesSync(decoder.encode()); - print('************************************************************'); - for (var table in decoder.tables.keys) { - print(table); - print(decoder.tables[table]!.maxCols); - print(decoder.tables[table]!.maxRows); - for (var row in decoder.tables[table]!.rows) { - print('$row'); - } - } + // print('************************************************************'); + // for (var table in decoder.tables.keys) { + // print(table); + // print(decoder.tables[table]!.maxCols); + // print(decoder.tables[table]!.maxRows); + // for (var row in decoder.tables[table]!.rows) { + // print('$row'); + // } + // } } diff --git a/lib/src/ods.dart b/lib/src/ods.dart index 2656024..5377f06 100644 --- a/lib/src/ods.dart +++ b/lib/src/ods.dart @@ -19,10 +19,9 @@ class OdsDecoder extends SpreadsheetDecoder { String get extension => '.ods'; final Map> _styleNames = {}; - OdsDecoder(Archive archive, {bool update = false, bool raw = false}) { + OdsDecoder(Archive archive, {bool update = false}) { _archive = archive; _update = update; - _raw = raw; _tables = {}; _parseContent(); } @@ -189,32 +188,6 @@ class OdsDecoder extends SpreadsheetDecoder { dynamic _readCell(XmlElement node) { dynamic value; var type = node.getAttribute('office:value-type'); - if (_raw) { - switch (type) { - case 'float': - case 'percentage': - case 'currency': - value = node.getAttribute('office:value'); - break; - case 'boolean': - value = node.getAttribute('office:boolean-value'); - break; - case 'date': - value = node.getAttribute('office:date-value'); - break; - case 'time': - value = node.getAttribute('office:time-value'); - break; - case 'string': - default: - var list = []; - node.findElements('text:p').forEach((child) { - list.add(_readString(child)); - }); - value = (list.isNotEmpty) ? list.join('\n') : null; - } - return value; - } switch (type) { case 'float': diff --git a/lib/src/spreadsheet.dart b/lib/src/spreadsheet.dart index 32301d1..31a54e5 100644 --- a/lib/src/spreadsheet.dart +++ b/lib/src/spreadsheet.dart @@ -13,8 +13,7 @@ String _normalizeNewLine(String text) { return text.replaceAll('\r\n', '\n'); } -SpreadsheetDecoder _newSpreadsheetDecoder( - Archive archive, bool update, bool raw) { +SpreadsheetDecoder _newSpreadsheetDecoder(Archive archive, bool update) { // Lookup at file format String? format; @@ -35,9 +34,9 @@ SpreadsheetDecoder _newSpreadsheetDecoder( switch (format) { case _spreasheetOds: - return OdsDecoder(archive, update: update, raw: raw); + return OdsDecoder(archive, update: update); case _spreasheetXlsx: - return XlsxDecoder(archive, update: update, raw: raw); + return XlsxDecoder(archive, update: update); default: throw UnsupportedError('Spreadsheet format unsupported'); } @@ -46,7 +45,6 @@ SpreadsheetDecoder _newSpreadsheetDecoder( /// Decode a spreadsheet file. abstract class SpreadsheetDecoder { late bool _update; - late bool _raw; late Archive _archive; late Map _sheets; late Map _xmlFiles; @@ -66,15 +64,15 @@ abstract class SpreadsheetDecoder { SpreadsheetDecoder(); factory SpreadsheetDecoder.decodeBytes(List data, - {bool update = false, bool verify = false, bool raw = false}) { + {bool update = false, bool verify = false}) { var archive = ZipDecoder().decodeBytes(data, verify: verify); - return _newSpreadsheetDecoder(archive, update, raw); + return _newSpreadsheetDecoder(archive, update); } factory SpreadsheetDecoder.decodeBuffer(InputStream input, - {bool update = false, bool verify = false, bool raw = false}) { + {bool update = false, bool verify = false}) { var archive = ZipDecoder().decodeStream(input, verify: verify); - return _newSpreadsheetDecoder(archive, update, raw); + return _newSpreadsheetDecoder(archive, update); } /// Dump XML content (for debug purpose) diff --git a/lib/src/xlsx.dart b/lib/src/xlsx.dart index fdf265e..916183b 100644 --- a/lib/src/xlsx.dart +++ b/lib/src/xlsx.dart @@ -75,6 +75,229 @@ String _twoDigits(int n) { return '0$n'; } +String _fourDigits(int n) { + var v = n.abs().toString().padLeft(4, '0'); + return n < 0 ? '-$v' : v; +} + +const List _monthNames = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' // +]; + +const List _dayNames = [ + 'Monday', 'Tuesday', 'Wednesday', 'Thursday', + 'Friday', 'Saturday', 'Sunday' // +]; + +/// Strip literal/escaped portions of an Excel number format code so token +/// scanning only sees real format tokens. Removes "..." literals, +/// [..] bracket sections (colors, conditions, locale modifiers like +/// `[$-409]`) and `\X` escape pairs. +String _stripFormatLiterals(String code) { + var buf = StringBuffer(); + var i = 0; + while (i < code.length) { + var ch = code[i]; + if (ch == '"') { + i++; + while (i < code.length && code[i] != '"') { + i++; + } + if (i < code.length) i++; + continue; + } + if (ch == r'\' && i + 1 < code.length) { + i += 2; + continue; + } + if (ch == '[') { + while (i < code.length && code[i] != ']') { + i++; + } + if (i < code.length) i++; + continue; + } + buf.write(ch); + i++; + } + return buf.toString(); +} + +/// Returns true if the given Excel format code represents a date or +/// date+time value (contains year or day tokens). +bool _isDateTimeFormatCode(String code) { + var stripped = _stripFormatLiterals(code).toLowerCase(); + var section = stripped.split(';').first; + return section.contains('y') || section.contains('d'); +} + +/// Returns true if the format code represents a time-only value +/// (hours/minutes/seconds without a date part). +bool _isTimeOnlyFormatCode(String code) { + var stripped = _stripFormatLiterals(code).toLowerCase(); + var section = stripped.split(';').first; + if (section.contains('y') || section.contains('d')) return false; + return section.contains('h') || section.contains('s'); +} + +/// Format a [DateTime] using a (subset of) Excel number format tokens. +/// Supports y/yy/yyyy, m/mm/mmm/mmmm/mmmmm (month or minute by context), +/// d/dd/ddd/dddd, h/hh, s/ss, AM/PM and A/P. Literal characters and +/// quoted text pass through unchanged. +String _formatDateTimeWithCode(DateTime date, String code) { + var section = code.split(';').first; + var lower = section.toLowerCase(); + var twelveHour = lower.contains('am/pm') || lower.contains('a/p'); + var hour12 = ((date.hour + 11) % 12) + 1; + + var buf = StringBuffer(); + var i = 0; + var lastWasHour = false; + while (i < section.length) { + var ch = section[i]; + var lc = ch.toLowerCase(); + + if (ch == '"') { + i++; + while (i < section.length && section[i] != '"') { + buf.write(section[i]); + i++; + } + if (i < section.length) i++; + continue; + } + if (ch == r'\' && i + 1 < section.length) { + buf.write(section[i + 1]); + i += 2; + continue; + } + if (ch == '[') { + var end = section.indexOf(']', i + 1); + if (end == -1) { + i++; + continue; + } + var inner = section.substring(i + 1, end).toLowerCase(); + if (inner == 'h' || inner == 'hh') { + buf.write(date.hour.toString()); + } else if (inner == 'm' || inner == 'mm') { + buf.write(date.minute.toString()); + } else if (inner == 's' || inner == 'ss') { + buf.write(date.second.toString()); + } + i = end + 1; + continue; + } + + // AM/PM marker (check before single-char fallthrough) + if (i + 5 <= section.length && + section.substring(i, i + 5).toLowerCase() == 'am/pm') { + var src = section.substring(i, i + 5); + var meridiem = date.hour < 12 ? 'AM' : 'PM'; + if (src == src.toLowerCase()) meridiem = meridiem.toLowerCase(); + buf.write(meridiem); + i += 5; + continue; + } + if (i + 3 <= section.length && + section.substring(i, i + 3).toLowerCase() == 'a/p') { + var src = section.substring(i, i + 3); + var meridiem = date.hour < 12 ? 'A' : 'P'; + if (src == src.toLowerCase()) meridiem = meridiem.toLowerCase(); + buf.write(meridiem); + i += 3; + continue; + } + + if ('ymdhs'.contains(lc)) { + var run = lc; + var j = i + 1; + while (j < section.length && section[j].toLowerCase() == lc) { + run += lc; + j++; + } + switch (lc) { + case 'y': + if (run.length >= 4) { + buf.write(_fourDigits(date.year)); + } else { + buf.write(_twoDigits(date.year % 100)); + } + lastWasHour = false; + break; + case 'd': + if (run.length == 1) { + buf.write(date.day.toString()); + } else if (run.length == 2) { + buf.write(_twoDigits(date.day)); + } else if (run.length == 3) { + buf.write(_dayNames[(date.weekday - 1) % 7].substring(0, 3)); + } else { + buf.write(_dayNames[(date.weekday - 1) % 7]); + } + lastWasHour = false; + break; + case 'h': + var h = twelveHour ? hour12 : date.hour; + if (run.length == 1) { + buf.write(h.toString()); + } else { + buf.write(_twoDigits(h)); + } + lastWasHour = true; + break; + case 's': + if (run.length == 1) { + buf.write(date.second.toString()); + } else { + buf.write(_twoDigits(date.second)); + } + lastWasHour = false; + break; + case 'm': + var isMinute = lastWasHour; + if (!isMinute) { + var k = j; + while (k < section.length && section[k] == ' ') { + k++; + } + if (k < section.length && section[k].toLowerCase() == 's') { + isMinute = true; + } + } + if (isMinute) { + if (run.length == 1) { + buf.write(date.minute.toString()); + } else { + buf.write(_twoDigits(date.minute)); + } + } else { + if (run.length == 1) { + buf.write(date.month.toString()); + } else if (run.length == 2) { + buf.write(_twoDigits(date.month)); + } else if (run.length == 3) { + buf.write(_monthNames[date.month - 1].substring(0, 3)); + } else if (run.length == 5) { + buf.write(_monthNames[date.month - 1].substring(0, 1)); + } else { + buf.write(_monthNames[date.month - 1]); + } + } + lastWasHour = false; + break; + } + i = j; + continue; + } + + buf.write(ch); + i++; + } + return buf.toString(); +} + /// Returns the coordinates from a cell name. /// "A1" returns [1, 1] and the "B3" return [2, 3]. List cellCoordsFromCellId(String cellId) { @@ -97,14 +320,16 @@ class XlsxDecoder extends SpreadsheetDecoder { final List _sharedStrings = []; final List _numFormats = []; + // Custom number format codes from (numFmtId >= 164 typically, + // but spec allows any id when overridden). Maps numFmtId -> formatCode. + final Map _customNumFormats = {}; String? _stylesTarget; String? _sharedStringsTarget; final Map _worksheetTargets = {}; - XlsxDecoder(Archive archive, {bool update = false, bool raw = false}) { + XlsxDecoder(Archive archive, {bool update = false}) { _archive = archive; _update = update; - _raw = raw; if (_update == true) { _archiveFiles = {}; _sheets = {}; @@ -260,6 +485,19 @@ class XlsxDecoder extends SpreadsheetDecoder { if (styles != null) { styles.decompress(); var document = XmlDocument.parse(utf8.decode(styles.content)); + + // Parse custom numFmts (formatCode strings keyed by numFmtId). + var numFmtsElems = document.findAllElements('numFmts'); + if (numFmtsElems.isNotEmpty) { + numFmtsElems.first.findElements('numFmt').forEach((node) { + var idAttr = node.getAttribute('numFmtId'); + var codeAttr = node.getAttribute('formatCode'); + if (idAttr != null && codeAttr != null) { + _customNumFormats[int.parse(idAttr)] = codeAttr; + } + }); + } + document .findAllElements('cellXfs') .first @@ -393,11 +631,7 @@ class XlsxDecoder extends SpreadsheetDecoder { break; // boolean case 'b': - if (_raw) { - value = _parseValue(node.findElements('v').first); - } else { - value = _parseValue(node.findElements('v').first) == '1'; - } + value = _parseValue(node.findElements('v').first) == '1'; break; // error case 'e': @@ -422,10 +656,6 @@ class XlsxDecoder extends SpreadsheetDecoder { var s = node.getAttribute('s'); var valueNode = node.findElements('v'); var content = valueNode.first; - if (_raw) { - value = _parseValue(content); - break; - } if (s != null) { var fmtId = _numFormats[int.parse(s)]; // date @@ -444,6 +674,24 @@ class XlsxDecoder extends SpreadsheetDecoder { value = '${_twoDigits(date.hour)}:${_twoDigits(date.minute)}:${_twoDigits(date.second)}'; // number + } else if (_customNumFormats.containsKey(fmtId)) { + // Custom number format declared in . Detect date/time + // by inspecting the format code for date/time tokens + // (y, d, h, s, or m used as month). + var code = _customNumFormats[fmtId]!; + var serial = num.parse(_parseValue(content)); + if (_isDateTimeFormatCode(code)) { + var delta = serial * 24 * 3600 * 1000; + var date = DateTime(1899, 12, 30) + .add(Duration(milliseconds: delta.toInt())); + value = _formatDateTimeWithCode(date, code); + } else if (_isTimeOnlyFormatCode(code)) { + var delta = serial * 24 * 3600 * 1000; + var date = DateTime(0).add(Duration(milliseconds: delta.toInt())); + value = _formatDateTimeWithCode(date, code); + } else { + value = serial; + } } else { value = num.parse(_parseValue(content)); } diff --git a/pubspec.yaml b/pubspec.yaml index 362f312..3f81382 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: spreadsheet_decoder description: A lightweight library for parsing and updating spreadsheet documents -version: 2.3.1 +version: 2.3.2 homepage: https://github.com/sestegra/spreadsheet environment: @@ -8,9 +8,9 @@ environment: dependencies: archive: ^4.0.9 - xml: ^6.6.1 + xml: ^7.0.1 dev_dependencies: path: ^1.9.1 lints: ^6.1.0 - test: ^1.30.0 + test: ^1.31.1 diff --git a/test/.DS_Store b/test/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5b6463d19e94c031a500cfaff6401f11ce406712 GIT binary patch literal 8196 zcmeHMT}&KR6u#d8?Mx@O17e|-aTl=#DW*`{YW-=K1tPRiXaQO15AN;^FmafrJF^RD z)b&YY)x;N#|8I@@P&JBwPfh$!j1LVq+L%~l;!|HVwmz5`&z(DvE|fMhj!rj4_!yM@^;SDvj^+zN|@sq=XP;&zNhw`L>juNiEY21z`lj z2!s&`BM?R)jKJ!M0NvTV$t&FZVmBPa2!s*1CL_S#4^iqIV*wTf`mYWe{1Sk$l*E3c zIn@EaO*Ft*fCYiRDftxD0|HY7B?g2!?MJyfVJyIcKw-`x%o&0wBdAc|Kb`ENzB)rp zU^s>m2qUmO0=#xFV`I!>HZ!lR-%I*aQ?})L&CTZ_m6VpPSt~2+RP{Q2C^0;8WW6@uS;8X!LaTy5qj!oMyZ4(}mrzkoudj;S z(s0|3JMM{fboCsX-=LLOsFmu*p}gfJn)4Oan>OEAqw7nx7rZ2LDeFYe@@BO77q-aTbz@;M#7u(eqMRS#b2Q4Qs-Kp-DHA5M+lPS{{-u9?>%h~~D zC@)Sdgq7+(SxKZblaq89SIFXeyR3aBALkG5PP$(2l<6$|NpZbXmPg!)WY+ARcFgR8 zMYk;bKO@PxNrz#1nXDg;gSEOo%w4q_hH22DH&<_}(EIg4SvyNMn&spzkH|nj4nfY9 z>lVmX7i$#Lx-(;E{Fxu)x>&OiR|;4h`Bk8^W)^4r*>N_-X4y&hJbRtJ&*s<%>~r=l zJI8)xzp~%h@9Yx0%>F_t)}azL*n)aA;8yIw?bwCgXhS!8a1bL%!@wBEVIzk;W-yCK z@dTd4Q#gs|@Dg6et9T8k@HXDTyZ8j3;xl}KFL4e(;R1ffMO?yV{3$6?g;XQy(oIsm z)F3rV+oZkHKB-eWB#lU;(u6pJ6x>nl*y5S|i8M=4oNQC#W0`dN>d6+lud{3af$mkC zY(K2$>2-bO4I8VXTN=0Tyz}1HMG^|mF3|K!`umJ8$`haQJ}Z{8Rpg->8KjgjXI4r_ zZ7ok-d7f+6-mFHnGD;e;+*(h|HIy)7xveP@(Im=Kv21RMs9FhSq2k-_YN92A#h$xV zRV$@@6nlGhTC!jfZzDH6yX1e1=U=n4?0a^BT_m3WjS|SHLKIukM7-XM7;(ED9mMTE z97aC|a15hJB1KHM;b00Ukj2Az1dpMB$BE}p;~6}Q7w{r+{S{*SDZGI<@fP0096rQH z_}FLtH~0?c@yik(_Z0IOe-!gL-=B5tj5AKs1?CqUgsW8<0e<&WtLF88$I9RTeWh>+ zBM?U5{}}<4w)eNk>1->#y?L!2rTzeQ-nd>tpl?EhUyc*{k literal 0 HcmV?d00001 diff --git a/test/common.dart b/test/common.dart index 9e625e0..836749d 100644 --- a/test/common.dart +++ b/test/common.dart @@ -101,25 +101,6 @@ var expectedFormat = >{ ] }; -var expectedRawFormat = >{ - 'Sheet1': [ - [ - '1337', - '1337.42', - '1234.56', - '1', - 'Hello World', - 'One line\nTwo lines\nThree lines', - '0.124', - '38749', - '38749.567361111112', - '38749.567847222221', - '0.56736111111111109', - '0.56784722222222228', - ] - ] -}; - var expectedEmptyColumn = >{ 'EMPTY': [ ['a1', null, 'c1'], @@ -318,14 +299,6 @@ void testXlsx() { }); }); - test('Format file raw mode:', () { - var decoder = decode('format.xlsx', raw: true); - expect(decoder.tables.length, expectedRawFormat.keys.length); - decoder.tables.forEach((name, table) { - expect(table.rows, expectedRawFormat[name]); - }); - }); - test('Empty column file:', () { var decoder = decode('empty_column.xlsx'); expect(decoder.tables.length, expectedEmptyColumn.keys.length); diff --git a/test/common_html.dart b/test/common_html.dart index 6efdc47..0766b5c 100644 --- a/test/common_html.dart +++ b/test/common_html.dart @@ -32,12 +32,10 @@ String readBase64(String filename) { return files[filename]!; } -SpreadsheetDecoder decode(String filename, - {bool update = false, bool raw = false}) { +SpreadsheetDecoder decode(String filename, {bool update = false}) { return SpreadsheetDecoder.decodeBytes( Base64Decoder().convert(readBase64(filename)), - update: update, - raw: raw); + update: update); } void save(String file, List data) {} diff --git a/test/common_io.dart b/test/common_io.dart index 6917433..b26ac89 100644 --- a/test/common_io.dart +++ b/test/common_io.dart @@ -11,10 +11,9 @@ String readBase64(String filename) { return base64Encode(_readBytes(filename)); } -SpreadsheetDecoder decode(String filename, - {bool update = false, bool raw = false}) { +SpreadsheetDecoder decode(String filename, {bool update = false}) { return SpreadsheetDecoder.decodeBytes(_readBytes(filename), - update: update, verify: true, raw: raw); + update: update, verify: true); } void save(String file, List data) { diff --git a/test/custom_date_format_test.dart b/test/custom_date_format_test.dart new file mode 100644 index 0000000..db02eff --- /dev/null +++ b/test/custom_date_format_test.dart @@ -0,0 +1,50 @@ +@TestOn('vm') +library spreadsheet_custom_date_format_test; + +import 'package:test/test.dart'; + +import 'common_io.dart'; + +/// Tests that custom date format codes (numFmtId outside the +/// built-in 14-22/45-47 range) are recognised and decoded as formatted +/// date strings instead of leaking through as raw numbers. +void main() { + group('Custom date number format (xlsx):', () { + test('sample_date_format.xlsx — dd/mm/yyyy custom numFmt', () { + var decoder = decode('sample_date_format.xlsx'); + var sheet = decoder.tables.values.first; + + // Cells L2, P2, R2 use the custom numFmtId=59 with + // formatCode="dd/mm/yyyy" and raw serial values 39650, 45782, 46144. + // Column L = index 11, P = 15, R = 17 (0-based). + final row2 = sheet.rows[1]; + + final ddmmyyyy = RegExp(r'^\d{2}/\d{2}/\d{4}$'); + + expect(row2[11], isA(), + reason: 'L2 must decode as a date string, not a number'); + expect(row2[15], isA(), + reason: 'P2 must decode as a date string, not a number'); + expect(row2[17], isA(), + reason: 'R2 must decode as a date string, not a number'); + + expect(row2[11] as String, matches(ddmmyyyy), + reason: 'L2 should match the dd/mm/yyyy format code'); + expect(row2[15] as String, matches(ddmmyyyy)); + expect(row2[17] as String, matches(ddmmyyyy)); + + // The exact day-of-month follows the existing decoder's serial-to-date + // math (1899-12-30 baseline, the same convention used for the + // built-in numFmtId 14-22 path). These are the deterministic outputs + // for the three serials in this fixture. + expect(row2[11], equals('21/07/2008')); + expect(row2[15], equals('05/05/2025')); + expect(row2[17], equals('02/05/2026')); + + // Sanity: none of the date cells should leak through as numbers. + expect(row2[11], isNot(isA())); + expect(row2[15], isNot(isA())); + expect(row2[17], isNot(isA())); + }); + }); +} diff --git a/test/files/new_sample_file_bytco.xlsx b/test/files/new_sample_file_bytco.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..fc39cd6155627c7cdc92afc05045bd00c847963e GIT binary patch literal 10082 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 literal 0 HcmV?d00001 diff --git a/test/files/sample_date_format.xlsx b/test/files/sample_date_format.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d7a7228e512d53f2f25dcbd1faca4ffe7e41283f GIT binary patch literal 7323 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 literal 0 HcmV?d00001 diff --git a/test/files/test_date_excel.xlsx b/test/files/test_date_excel.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..fdb7be8b71b22074af38cfb3191384b124a1747f GIT binary patch literal 6230 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 NdZO2=h3emL{{rR@siXh^ literal 0 HcmV?d00001 From fbb583986e8c9755b4fa2c11ed1a349912498174 Mon Sep 17 00:00:00 2001 From: TUSHAR BHAMBERE <77154182+tusharbhambere@users.noreply.github.com> Date: Fri, 15 May 2026 11:53:56 +0530 Subject: [PATCH 3/4] v 2.3.3 --- CHANGELOG.md | 2 ++ README.md | 13 +++++++ example/spreadsheet_decoder.dart | 5 +-- lib/src/ods.dart | 10 ++++-- lib/src/spreadsheet.dart | 33 ++++++++++++++---- lib/src/xlsx.dart | 30 +++++++++------- pubspec.yaml | 2 +- test/common.dart | 6 ++-- test/common_io.dart | 7 ++-- test/custom_date_format_test.dart | 57 +++++++++++++++++++++++++++++++ 10 files changed, 135 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc7d595..add1120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ## Unreleased XLSX: decode dates with custom `` format codes (e.g. `dd/mm/yyyy`) using the workbook's own format code instead of leaking through as raw serial numbers. Removed the `raw` decoding mode in favour of always returning typed values. +Built-in date cells (numFmtId 14-17, 22) and ODS date cells now render as `yyyy-MM-dd` by default instead of an ISO timestamp like `2008-07-21T00:00:00.000`. Added a `dateFormat` parameter to `SpreadsheetDecoder.decodeBytes` and `SpreadsheetDecoder.decodeBuffer` so callers can choose any pattern (e.g. `dd/MM/yyyy`, `yyyy.MM.dd`); custom-numFmt date cells honour the caller's `dateFormat` when explicitly set, otherwise fall back to the workbook's own format code. + ## 2.3.0 Update dependencies diff --git a/README.md b/README.md index c63cdc8..370238e 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,19 @@ For XLSX format, this implementation supports the native Excel formats for date, Important: Excel often stores date cells as numeric serial values and only formats them for display. The decoder applies the workbook's format code to render the date as a string. If you need a fixed text representation independent of the workbook formatting, the source cell must be stored as text in the spreadsheet. +### Customising the date output format + +By default, date cells (XLSX built-in numFmtIds 14-17, 22 and ODS `date` cells) are rendered as `yyyy-MM-dd` (e.g. `2008-07-21`). You can override this from the call site by passing `dateFormat`: + + var decoder = SpreadsheetDecoder.decodeBytes( + bytes, + dateFormat: 'dd/MM/yyyy', // → 21/07/2008 + ); + +Supported tokens (case-insensitive): `yyyy`, `yy`, `mm`/`m` (month — context-sensitive vs minutes), `dd`/`d`/`ddd`/`dddd`, `hh`/`h`, `ss`/`s`, `AM/PM`. Any other characters in the pattern (`-`, `/`, `.`, spaces, etc.) are emitted as literal separators. + +For cells that use a custom `` format code declared in the workbook, the workbook's own format code is used unless you explicitly pass a non-default `dateFormat`, in which case your format wins. + ## License The MIT License, see [LICENSE](https://github.com/sestegra/spreadsheet_decoder/raw/master/LICENSE). diff --git a/example/spreadsheet_decoder.dart b/example/spreadsheet_decoder.dart index 98d6212..0c61ecf 100644 --- a/example/spreadsheet_decoder.dart +++ b/example/spreadsheet_decoder.dart @@ -2,9 +2,10 @@ import 'dart:io'; import 'package:spreadsheet_decoder/spreadsheet_decoder.dart'; void main(List args) { - var file = 'test/files/test_date_excel.xlsx'; + var file = 'test/files/new_sample_file_bytco.xlsx'; var bytes = File(file).readAsBytesSync(); - var decoder = SpreadsheetDecoder.decodeBytes(bytes, update: false); + var decoder = SpreadsheetDecoder.decodeBytes(bytes, + update: false, dateFormat: 'dd/MM/yyyy'); for (var table in decoder.tables.keys) { print(table); print(decoder.tables[table]!.maxCols); diff --git a/lib/src/ods.dart b/lib/src/ods.dart index 5377f06..4817de9 100644 --- a/lib/src/ods.dart +++ b/lib/src/ods.dart @@ -19,9 +19,12 @@ class OdsDecoder extends SpreadsheetDecoder { String get extension => '.ods'; final Map> _styleNames = {}; - OdsDecoder(Archive archive, {bool update = false}) { + OdsDecoder(Archive archive, + {bool update = false, + String dateFormat = SpreadsheetDecoder.defaultDateFormat}) { _archive = archive; _update = update; + _dateFormat = dateFormat; _tables = {}; _parseContent(); } @@ -200,8 +203,9 @@ class OdsDecoder extends SpreadsheetDecoder { node.getAttribute('office:boolean-value')!.toLowerCase() == 'true'; break; case 'date': - value = DateTime.parse(node.getAttribute('office:date-value')!) - .toIso8601String(); + value = _formatDateTimeWithCode( + DateTime.parse(node.getAttribute('office:date-value')!), + _dateFormat); break; case 'time': value = node.getAttribute('office:time-value'); diff --git a/lib/src/spreadsheet.dart b/lib/src/spreadsheet.dart index 31a54e5..7cc28dd 100644 --- a/lib/src/spreadsheet.dart +++ b/lib/src/spreadsheet.dart @@ -13,7 +13,8 @@ String _normalizeNewLine(String text) { return text.replaceAll('\r\n', '\n'); } -SpreadsheetDecoder _newSpreadsheetDecoder(Archive archive, bool update) { +SpreadsheetDecoder _newSpreadsheetDecoder( + Archive archive, bool update, String dateFormat) { // Lookup at file format String? format; @@ -34,9 +35,9 @@ SpreadsheetDecoder _newSpreadsheetDecoder(Archive archive, bool update) { switch (format) { case _spreasheetOds: - return OdsDecoder(archive, update: update); + return OdsDecoder(archive, update: update, dateFormat: dateFormat); case _spreasheetXlsx: - return XlsxDecoder(archive, update: update); + return XlsxDecoder(archive, update: update, dateFormat: dateFormat); default: throw UnsupportedError('Spreadsheet format unsupported'); } @@ -44,7 +45,16 @@ SpreadsheetDecoder _newSpreadsheetDecoder(Archive archive, bool update) { /// Decode a spreadsheet file. abstract class SpreadsheetDecoder { + /// Default output format used for date cells when none is provided. + /// Format tokens follow Excel-style codes (case-insensitive): + /// `yyyy`/`yy`, `mm`/`m` (month — context-sensitive), + /// `dd`/`d`/`ddd`/`dddd`, `hh`/`h`, `ss`/`s`, `AM/PM`. + /// Common intl-style patterns like `dd/MM/yyyy` also work because the + /// pattern is matched case-insensitively. + static const String defaultDateFormat = 'yyyy-MM-dd'; + late bool _update; + late String _dateFormat; late Archive _archive; late Map _sheets; late Map _xmlFiles; @@ -63,16 +73,25 @@ abstract class SpreadsheetDecoder { SpreadsheetDecoder(); + /// Decode an XLSX/ODS spreadsheet from raw [data] bytes. + /// + /// Pass [dateFormat] to control how date cells are rendered as strings + /// (e.g. `'dd/MM/yyyy'`, `'yyyy-MM-dd'`). Defaults to + /// [defaultDateFormat] (`yyyy-MM-dd`). factory SpreadsheetDecoder.decodeBytes(List data, - {bool update = false, bool verify = false}) { + {bool update = false, + bool verify = false, + String dateFormat = defaultDateFormat}) { var archive = ZipDecoder().decodeBytes(data, verify: verify); - return _newSpreadsheetDecoder(archive, update); + return _newSpreadsheetDecoder(archive, update, dateFormat); } factory SpreadsheetDecoder.decodeBuffer(InputStream input, - {bool update = false, bool verify = false}) { + {bool update = false, + bool verify = false, + String dateFormat = defaultDateFormat}) { var archive = ZipDecoder().decodeStream(input, verify: verify); - return _newSpreadsheetDecoder(archive, update); + return _newSpreadsheetDecoder(archive, update, dateFormat); } /// Dump XML content (for debug purpose) diff --git a/lib/src/xlsx.dart b/lib/src/xlsx.dart index 916183b..0df0ed2 100644 --- a/lib/src/xlsx.dart +++ b/lib/src/xlsx.dart @@ -327,9 +327,12 @@ class XlsxDecoder extends SpreadsheetDecoder { String? _sharedStringsTarget; final Map _worksheetTargets = {}; - XlsxDecoder(Archive archive, {bool update = false}) { + XlsxDecoder(Archive archive, + {bool update = false, + String dateFormat = SpreadsheetDecoder.defaultDateFormat}) { _archive = archive; _update = update; + _dateFormat = dateFormat; if (_update == true) { _archiveFiles = {}; _sheets = {}; @@ -661,10 +664,9 @@ class XlsxDecoder extends SpreadsheetDecoder { // date if (((fmtId >= 14) && (fmtId <= 17)) || (fmtId == 22)) { var delta = num.parse(_parseValue(content)) * 24 * 3600 * 1000; - var date = DateTime(1899, 12, 30); - value = date - .add(Duration(milliseconds: delta.toInt())) - .toIso8601String(); + var date = DateTime(1899, 12, 30) + .add(Duration(milliseconds: delta.toInt())); + value = _formatDateTimeWithCode(date, _dateFormat); // time } else if (((fmtId >= 18) && (fmtId <= 21)) || ((fmtId >= 45) && (fmtId <= 47))) { @@ -675,20 +677,24 @@ class XlsxDecoder extends SpreadsheetDecoder { '${_twoDigits(date.hour)}:${_twoDigits(date.minute)}:${_twoDigits(date.second)}'; // number } else if (_customNumFormats.containsKey(fmtId)) { - // Custom number format declared in . Detect date/time - // by inspecting the format code for date/time tokens - // (y, d, h, s, or m used as month). - var code = _customNumFormats[fmtId]!; + // Custom number format declared in . If the caller + // supplied a non-default [dateFormat], honour it for date + // cells; otherwise fall back to the workbook's own format + // code so the visible output matches Excel. + var workbookCode = _customNumFormats[fmtId]!; var serial = num.parse(_parseValue(content)); - if (_isDateTimeFormatCode(code)) { + if (_isDateTimeFormatCode(workbookCode)) { var delta = serial * 24 * 3600 * 1000; var date = DateTime(1899, 12, 30) .add(Duration(milliseconds: delta.toInt())); + var code = _dateFormat == SpreadsheetDecoder.defaultDateFormat + ? workbookCode + : _dateFormat; value = _formatDateTimeWithCode(date, code); - } else if (_isTimeOnlyFormatCode(code)) { + } else if (_isTimeOnlyFormatCode(workbookCode)) { var delta = serial * 24 * 3600 * 1000; var date = DateTime(0).add(Duration(milliseconds: delta.toInt())); - value = _formatDateTimeWithCode(date, code); + value = _formatDateTimeWithCode(date, workbookCode); } else { value = serial; } diff --git a/pubspec.yaml b/pubspec.yaml index 3f81382..9ec9245 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: spreadsheet_decoder description: A lightweight library for parsing and updating spreadsheet documents -version: 2.3.2 +version: 2.3.3 homepage: https://github.com/sestegra/spreadsheet environment: diff --git a/test/common.dart b/test/common.dart index 836749d..b8aa90f 100644 --- a/test/common.dart +++ b/test/common.dart @@ -92,9 +92,9 @@ var expectedFormat = >{ 'Hello World', 'One line\nTwo lines\nThree lines', 0.124, - '2006-02-01T00:00:00.000', - '2006-02-01T13:37:00.000', - '2006-02-01T13:37:42.000', + '2006-02-01', + '2006-02-01', + '2006-02-01', '13:37:00', '13:37:42', ] diff --git a/test/common_io.dart b/test/common_io.dart index b26ac89..78b0368 100644 --- a/test/common_io.dart +++ b/test/common_io.dart @@ -11,9 +11,12 @@ String readBase64(String filename) { return base64Encode(_readBytes(filename)); } -SpreadsheetDecoder decode(String filename, {bool update = false}) { +SpreadsheetDecoder decode(String filename, + {bool update = false, String? dateFormat}) { return SpreadsheetDecoder.decodeBytes(_readBytes(filename), - update: update, verify: true); + update: update, + verify: true, + dateFormat: dateFormat ?? SpreadsheetDecoder.defaultDateFormat); } void save(String file, List data) { diff --git a/test/custom_date_format_test.dart b/test/custom_date_format_test.dart index db02eff..6555611 100644 --- a/test/custom_date_format_test.dart +++ b/test/custom_date_format_test.dart @@ -47,4 +47,61 @@ void main() { expect(row2[17], isNot(isA())); }); }); + + group('Built-in date numFmt + dateFormat option (xlsx):', () { + test('default dateFormat renders built-in date as yyyy-MM-dd', () { + // new_sample_file_bytco.xlsx contains a column whose cell uses the + // built-in numFmtId=14 (m/d/yyyy). The decoded value must be a + // date-only string in the package default format, not an ISO + // timestamp like 2008-07-21T00:00:00.000. + var decoder = decode('new_sample_file_bytco.xlsx'); + var sheet = decoder.tables.values.first; + + final iso = RegExp(r'^\d{4}-\d{2}-\d{2}$'); + var foundDateCell = false; + for (var row in sheet.rows) { + for (var cell in row) { + if (cell is String && iso.hasMatch(cell)) { + foundDateCell = true; + // No leftover ISO time component. + expect(cell, isNot(contains('T'))); + expect(cell, isNot(contains(':'))); + } + } + } + expect(foundDateCell, isTrue, + reason: 'Expected at least one yyyy-MM-dd date cell in fixture'); + }); + + test('caller-supplied dateFormat dd/MM/yyyy is honoured', () { + var decoder = + decode('new_sample_file_bytco.xlsx', dateFormat: 'dd/MM/yyyy'); + var sheet = decoder.tables.values.first; + + final ddmmyyyy = RegExp(r'^\d{2}/\d{2}/\d{4}$'); + var foundDateCell = false; + for (var row in sheet.rows) { + for (var cell in row) { + if (cell is String && ddmmyyyy.hasMatch(cell)) { + foundDateCell = true; + } + } + } + expect(foundDateCell, isTrue, + reason: 'Expected at least one dd/MM/yyyy date cell'); + }); + + test('caller-supplied dateFormat overrides custom workbook code', () { + // sample_date_format.xlsx normally renders custom-numFmt dates as + // dd/mm/yyyy because that's the workbook's format code. When the + // caller passes a non-default dateFormat the custom branch must + // honour the caller's preference instead. + var decoder = decode('sample_date_format.xlsx', dateFormat: 'yyyy.MM.dd'); + var sheet = decoder.tables.values.first; + final row2 = sheet.rows[1]; + expect(row2[11], equals('2008.07.21')); + expect(row2[15], equals('2025.05.05')); + expect(row2[17], equals('2026.05.02')); + }); + }); } From f35d480e68a377fbf6c672b1c259bf2885537941 Mon Sep 17 00:00:00 2001 From: TUSHAR BHAMBERE <77154182+tusharbhambere@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:20:43 +0530 Subject: [PATCH 4/4] Date_Format --- lib/src/xlsx.dart | 39 ++- ...CA-teaching-plan-sample-1784613441518.xlsx | Bin 0 -> 13323 bytes test/teaching_plan_sample_test.dart | 229 ++++++++++++++++++ 3 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 test/files/subject-Data Structures and Algorithms-of-FY - MCA-teaching-plan-sample-1784613441518.xlsx create mode 100644 test/teaching_plan_sample_test.dart diff --git a/lib/src/xlsx.dart b/lib/src/xlsx.dart index 0df0ed2..55a8749 100644 --- a/lib/src/xlsx.dart +++ b/lib/src/xlsx.dart @@ -132,6 +132,40 @@ bool _isDateTimeFormatCode(String code) { return section.contains('y') || section.contains('d'); } +/// Returns true if [fmtId] is a built-in (implicit) number format that Excel +/// renders as a date, meaning the cell value holds a date serial. +/// +/// Besides the well-known western formats (14-17, 22) this covers the +/// locale-specific ranges the spec reserves for CJK (27-36, 50-58) and Thai +/// (71-81) date formats. Those ids carry no `` entry in the workbook, +/// so they have to be recognised by id alone. +bool _isBuiltInDateFormat(int fmtId) { + if (((fmtId >= 14) && (fmtId <= 17)) || (fmtId == 22)) return true; + // CJK: yyyy年m月, m月d日, yyyy年m月d日, era-based ge.m.d, ... + if ((fmtId >= 27) && (fmtId <= 31)) return true; + if (fmtId == 36) return true; + if ((fmtId >= 50) && (fmtId <= 54)) return true; + if ((fmtId == 57) || (fmtId == 58)) return true; + // Thai: ว/ด/ปปปป, ว-ดดด-ปป, d/m/bb, ... + if ((fmtId >= 71) && (fmtId <= 74)) return true; + if ((fmtId == 77) || (fmtId == 81)) return true; + return false; +} + +/// Returns true if [fmtId] is a built-in number format that Excel renders as a +/// time of day only (no date part). Companion to [_isBuiltInDateFormat]. +bool _isBuiltInTimeFormat(int fmtId) { + if ((fmtId >= 18) && (fmtId <= 21)) return true; + // CJK: h時mm分, 上午/下午h時mm分ss秒, ... + if ((fmtId >= 32) && (fmtId <= 35)) return true; + if ((fmtId >= 45) && (fmtId <= 47)) return true; + if ((fmtId == 55) || (fmtId == 56)) return true; + // Thai: ช:นน, นน:ทท.0, ... + if ((fmtId == 75) || (fmtId == 76)) return true; + if ((fmtId >= 78) && (fmtId <= 80)) return true; + return false; +} + /// Returns true if the format code represents a time-only value /// (hours/minutes/seconds without a date part). bool _isTimeOnlyFormatCode(String code) { @@ -662,14 +696,13 @@ class XlsxDecoder extends SpreadsheetDecoder { if (s != null) { var fmtId = _numFormats[int.parse(s)]; // date - if (((fmtId >= 14) && (fmtId <= 17)) || (fmtId == 22)) { + if (_isBuiltInDateFormat(fmtId)) { var delta = num.parse(_parseValue(content)) * 24 * 3600 * 1000; var date = DateTime(1899, 12, 30) .add(Duration(milliseconds: delta.toInt())); value = _formatDateTimeWithCode(date, _dateFormat); // time - } else if (((fmtId >= 18) && (fmtId <= 21)) || - ((fmtId >= 45) && (fmtId <= 47))) { + } else if (_isBuiltInTimeFormat(fmtId)) { var delta = num.parse(_parseValue(content)) * 24 * 3600 * 1000; var date = DateTime(0); date = date.add(Duration(milliseconds: delta.toInt())); diff --git a/test/files/subject-Data Structures and Algorithms-of-FY - MCA-teaching-plan-sample-1784613441518.xlsx b/test/files/subject-Data Structures and Algorithms-of-FY - MCA-teaching-plan-sample-1784613441518.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..4ccbe94edbebdc231097ed048b49cef0ddd9d76f GIT binary patch literal 13323 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 literal 0 HcmV?d00001 diff --git a/test/teaching_plan_sample_test.dart b/test/teaching_plan_sample_test.dart new file mode 100644 index 0000000..bcd3ff8 --- /dev/null +++ b/test/teaching_plan_sample_test.dart @@ -0,0 +1,229 @@ +@TestOn('vm') +library spreadsheet_teaching_plan_sample_test; + +import 'package:spreadsheet_decoder/spreadsheet_decoder.dart'; +import 'package:test/test.dart'; + +import 'common_io.dart'; + +const filename = + 'subject-Data Structures and Algorithms-of-FY - MCA-teaching-plan-sample-1784613441518.xlsx'; +const sheetName = 'Teaching Plan Sample'; + +const headers = [ + 'Topic', + 'SubTopic', + 'CourseOutcome', + 'LearningOutcome', + 'Hours', + 'Minutes', + 'PlanningDate', + 'ExecutionDate', +]; + +const topicCol = 0; +const subTopicCol = 1; +const courseOutcomeCol = 2; +const learningOutcomeCol = 3; +const hoursCol = 4; +const minutesCol = 5; +const planningDateCol = 6; +const executionDateCol = 7; + +/// The PlanningDate column mixes two storage forms in the source workbook: +/// +/// * a real date serial styled with `numFmtId="58"` (a built-in +/// locale-specific date format) — these are the rows that used to decode +/// as a bare number such as `46212`; +/// * a shared string holding pre-formatted `d/M/yyyy` text — passed through +/// verbatim by the decoder. +/// +/// Keyed by row index in [SpreadsheetTable.rows] (row 0 is the header). +const serialBackedPlanningDates = { + 1: '2026-07-09', // serial 46212 + 2: '2026-09-09', // serial 46274 + 3: '2026-11-09', // serial 46335 + 13: '2026-05-10', // serial 46152 + 14: '2026-07-10', // serial 46213 + 15: '2026-09-10', // serial 46275 + 16: '2026-12-10', // serial 46366 + 25: '2026-02-11', // serial 46064 + 26: '2026-04-11', // serial 46123 + 35: '2026-02-12', // serial 46065 + 36: '2026-04-12', // serial 46124 + 37: '2026-07-12', // serial 46215 + 38: '2026-09-12', // serial 46277 + 39: '2026-11-12', // serial 46338 +}; + +const textBackedPlanningDates = { + 4: '16/9/2026', + 5: '18/9/2026', + 6: '18/9/2026', + 7: '21/9/2026', + 8: '21/9/2026', + 9: '21/9/2026', + 10: '23/9/2026', + 11: '28/9/2026', + 12: '30/9/2026', + 17: '14/10/2026', + 18: '16/10/2026', + 19: '19/10/2026', + 20: '21/10/2026', + 21: '23/10/2026', + 22: '26/10/2026', + 23: '28/10/2026', + 24: '30/10/2026', + 27: '13/11/2026', + 28: '16/11/2026', + 29: '18/11/2026', + 30: '20/11/2026', + 31: '23/11/2026', + 32: '25/11/2026', + 33: '27/11/2026', + 34: '30/11/2026', + 40: '14/12/2026', + 41: '16/12/2026', +}; + +String _cell(dynamic value, {int width = 34}) { + if (value == null) return 'null'; + var text = '${value.runtimeType}:${value.toString().replaceAll('\n', r'\n')}'; + return text.length > width ? '${text.substring(0, width - 1)}…' : text; +} + +/// Dumps the decoded grid so the parsed output can be eyeballed in the test +/// log. Run with `dart test -r expanded` to see it. +void logTable(SpreadsheetTable table) { + print('sheet "$sheetName": ${table.maxRows} rows x ${table.maxCols} cols'); + for (var i = 0; i < table.rows.length; i++) { + var cells = table.rows[i].map(_cell).join(' | '); + print('[${i.toString().padLeft(2)}] $cells'); + } +} + +void main() { + group('Teaching plan sample excel file:', () { + test('decode xlsx without update', () { + var decoder = decode(filename, update: false); + expect(decoder, isNotNull); + expect(decoder.tables.isNotEmpty, isTrue); + expect(decoder.tables.containsKey(sheetName), isTrue); + + var table = decoder.tables[sheetName]!; + expect(table.maxRows, greaterThan(0)); + expect(table.maxCols, greaterThan(0)); + expect(table.rows.length, equals(table.maxRows)); + }); + + test('decode xlsx with update', () { + var decoder = decode(filename, update: true); + expect(decoder, isNotNull); + expect(decoder.tables.containsKey(sheetName), isTrue); + + var table = decoder.tables[sheetName]!; + expect(table.maxRows, greaterThan(0)); + expect(table.maxCols, greaterThan(0)); + + // Test reading rows and specific cells + for (var row in table.rows) { + expect(row, isA()); + } + }); + + test('logs parsed data', () { + var table = decode(filename).tables[sheetName]!; + logTable(table); + + // Guard the dump above against silently logging an empty grid. + expect(table.rows, isNotEmpty); + expect(table.rows.every((row) => row.length == table.maxCols), isTrue); + }); + + test('grid shape and header row', () { + var table = decode(filename).tables[sheetName]!; + + expect(table.maxRows, equals(42)); + expect(table.maxCols, equals(8)); + expect(table.rows.length, equals(42)); + expect(table.rows.first, equals(headers)); + }); + + test('column value types', () { + var table = decode(filename).tables[sheetName]!; + var body = table.rows.skip(1); + + for (var row in body) { + // Topic is blank on the row covered by a vertical merge. + expect(row[topicCol], anyOf(isNull, isA())); + expect(row[subTopicCol], isA()); + expect(row[courseOutcomeCol], matches(RegExp(r'^CO\d$'))); + expect(row[learningOutcomeCol], equals('UO1')); + expect(row[hoursCol], isA()); + expect(row[minutesCol], isA()); + // Never a raw serial: every planning date renders as text. + expect(row[planningDateCol], isA()); + // ExecutionDate is an empty styled cell (``). + expect(row[executionDateCol], isNull); + } + + expect(body.map((row) => row[topicCol]).where((t) => t == null).length, + equals(1)); + }); + + test('PlanningDate serials decode as dates, not numbers', () { + var table = decode(filename).tables[sheetName]!; + + serialBackedPlanningDates.forEach((index, expected) { + expect(table.rows[index][planningDateCol], equals(expected), + reason: 'row $index (numFmtId 58 date serial)'); + }); + }); + + test('PlanningDate text cells pass through unchanged', () { + var table = decode(filename).tables[sheetName]!; + + textBackedPlanningDates.forEach((index, expected) { + expect(table.rows[index][planningDateCol], equals(expected), + reason: 'row $index (shared string)'); + }); + }); + + test('every PlanningDate row is accounted for', () { + var table = decode(filename).tables[sheetName]!; + var covered = { + ...serialBackedPlanningDates.keys, + ...textBackedPlanningDates.keys, + }; + + expect(covered.length, equals(table.rows.length - 1)); + expect(covered, equals({for (var i = 1; i < table.rows.length; i++) i})); + }); + + test('dateFormat option is applied to PlanningDate serials', () { + var table = decode(filename, dateFormat: 'dd/MM/yyyy').tables[sheetName]!; + + expect(table.rows[1][planningDateCol], equals('09/07/2026')); + expect(table.rows[13][planningDateCol], equals('10/05/2026')); + expect(table.rows[39][planningDateCol], equals('12/11/2026')); + + // Text cells are not reformatted — they were never date serials. + expect(table.rows[4][planningDateCol], equals('16/9/2026')); + }); + + test('update round-trip preserves decoded PlanningDate values', () { + var original = decode(filename, update: true); + var reopened = SpreadsheetDecoder.decodeBytes(original.encode()); + + var before = original.tables[sheetName]!; + var after = reopened.tables[sheetName]!; + + expect(after.maxRows, equals(before.maxRows)); + for (var i = 0; i < before.rows.length; i++) { + expect(after.rows[i][planningDateCol], + equals(before.rows[i][planningDateCol]), + reason: 'row $i'); + } + }); + }); +}