Repository: robocorp/rpaframework

Issue:
`RPA.Excel.Files` investigate and fix `xml:space='preserve'` invalid format
Sub-library `openpyxl` adds an `xml:space='preserve'` under the created _core.xml_ during workbook saving and that invalidates the file.

### References
- [Stackoverflow](https://stackoverflow.com/questions/60798410/openpyxl-validation-error-schemas-openxmlformats-org-spreadsheetml-2006-maincol)
- Slack [thread](https://robocorp-developers.slack.com/archives/C02CZ8QSTD0/p1657013530923319)


Discussion:
Taking a look
That property is added to an XML node to preserve white space, but the underlying library is adding it to a node that Excel doesn't support that property... I was unable to reproduce with a simple excel file, so I may need to dig further.
Upstream issue on the `openpyxl` library: https://foss.heptapod.net/openpyxl/openpyxl/-/issues/1713
The document properties are not exposed from the underlying `Workbook` class so I am not sure how to proceed, @cmin764 you might need to consider if there's anything you can think of to do here as a workaround.

Resolving patch:
diff --git a/packages/main/src/RPA/Excel/Files.py b/packages/main/src/RPA/Excel/Files.py
--- a/packages/main/src/RPA/Excel/Files.py
+++ b/packages/main/src/RPA/Excel/Files.py
@@ -6,17 +6,19 @@
 from typing import List, Any, Union, Optional
 
 import openpyxl
-from openpyxl.utils import get_column_letter
-from openpyxl.utils.exceptions import InvalidFileException
-
 import xlrd
 import xlwt
-from xlutils.copy import copy as xlutils_copy
 from PIL import Image
+from openpyxl.utils import get_column_letter
+from openpyxl.utils.exceptions import InvalidFileException
+from xlutils.copy import copy as xlutils_copy
 
 from RPA.Tables import Tables, Table
 
 
+PathType = Union[str, pathlib.Path]
+
+
 def get_column_index(column: str) -> int:
     """Get column index from name, e.g. A -> 1, D -> 4, AC -> 29.
     Reverse of `get_column_letter()`
@@ -403,6 +405,7 @@ def save_workbook(
                 extension,
             )
 
+        self.workbook.validate_content()
         return self.workbook.save(path)
 
     def list_worksheets(self) -> List[str]:
@@ -1006,16 +1009,36 @@ def set_worksheet_value(
         return self.set_cell_value(row, column, value, name, fmt)
 
 
-class XlsxWorkbook:
-    """Container for manipulating moden Excel files (.xlsx)"""
+class BaseWorkbook:
 
-    def __init__(self, path=None):
+    """Common logic for both .xls and .xlsx files management."""
+
+    def __init__(self, path: Optional[PathType] = None):
         self.logger = logging.getLogger(__name__)
         self.path = path
         self._book = None
         self._extension = None
         self._active = None
 
+    @property
+    def book(self):
+        return self._book
+
+    def _validate_content(self, props_obj: Any):
+        # Strips leading/trailing whitespace in Excel properties.
+        public_props = [prop for prop in dir(props_obj) if not prop.startswith("_")]
+        for prop in public_props:
+            value = getattr(props_obj, prop)
+            if value and isinstance(value, str):
+                setattr(props_obj, prop, value.strip())
+
+
+class XlsxWorkbook(BaseWorkbook):
+    """Container for manipulating modern Excel files (.xlsx)"""
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
     @staticmethod
     def is_sheet_empty(sheet):
         # Maximum rows/columns are always 1 or more, even when the sheet doesn't
@@ -1110,6 +1133,9 @@ def close(self):
         self._extension = None
         self._active = None
 
+    def validate_content(self):
+        self._validate_content(self._book.properties)
+
     def save(self, path=None):
         path = path or self.path
         if not path:
@@ -1248,15 +1274,11 @@ def insert_image(self, row, column, image, name=None):
         sheet.add_image(img)
 
 
-class XlsWorkbook:
+class XlsWorkbook(BaseWorkbook):
     """Container for manipulating legacy Excel files (.xls)"""
 
-    def __init__(self, path=None):
-        self.logger = logging.getLogger(__name__)
-        self.path = path
-        self._book = None
-        self._extension = None
-        self._active = None
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
         self._images = []
 
     @staticmethod
@@ -1384,6 +1406,9 @@ def _book_write(self):
         finally:
             fd.close()
 
+    def validate_content(self):
+        self._validate_content(self._book)
+
     def save(self, path=None):
         path = path or self.path
         if not path:


Test patch:
diff --git a/packages/main/tests/python/test_excel.py b/packages/main/tests/python/test_excel.py
--- a/packages/main/tests/python/test_excel.py
+++ b/packages/main/tests/python/test_excel.py
@@ -7,13 +7,16 @@
 from RPA.Excel.Files import Files, XlsxWorkbook, XlsWorkbook, ensure_unique
 from RPA.Tables import Table
 
-from . import RESOURCES_DIR
+from . import RESOURCES_DIR, RESULTS_DIR
+
+
+EXCELS_DIR = RESOURCES_DIR / "excels"
 
 
 @contextlib.contextmanager
 def _library(excel_file):
     lib = Files()
-    excel_path = RESOURCES_DIR / excel_file
+    excel_path = EXCELS_DIR / excel_file
     lib.open_workbook(excel_path)
     yield lib
     lib.close_workbook()
@@ -67,7 +70,7 @@ def test_open_missing(filename):
 def test_wrong_extension_fallback_xlsx():
     # openpyxl does not support xls (actual format) but xlrd will succeed
     library = Files()
-    path = str(RESOURCES_DIR / "wrong_extension.xlsx")
+    path = str(EXCELS_DIR / "wrong_extension.xlsx")
     library.open_workbook(path)
     assert library.workbook is not None
 
@@ -75,7 +78,7 @@ def test_wrong_extension_fallback_xlsx():
 def test_wrong_extension_fallback_xls():
     # openpyxl will refuse to read wrong extension and xlrd does not support xlsx
     library = Files()
-    path = str(RESOURCES_DIR / "wrong_extension.xls")
+    path = str(EXCELS_DIR / "wrong_extension.xls")
     with pytest.raises(ValueError, match=".*wrong_extension.xls.*path.*extension.*"):
         library.open_workbook(path)
     assert library.workbook is None
@@ -426,7 +429,7 @@ def test_create_workbook_default_sheet(fmt):
 )
 def test_read_worksheet_with_formulas(excel_file, data_only):
     library = Files()
-    excel_path = RESOURCES_DIR / excel_file
+    excel_path = EXCELS_DIR / excel_file
     library.open_workbook(excel_path, data_only=data_only)
     assert library.get_worksheet_value(2, "A") == 1
     assert library.get_worksheet_value(2, "B") == 3
@@ -435,3 +438,23 @@ def test_read_worksheet_with_formulas(excel_file, data_only):
     else:
         assert library.get_worksheet_value(2, "C") == 4
     library.close_workbook()
+
+
+@pytest.mark.parametrize("name", ["spaces.xls", "spaces.xlsx"])
+def test_invalid_whitespace_fix(name):
+    library = Files()
+    if name.endswith("xlsx"):
+        get_user = lambda book: book.properties.lastModifiedBy
+        expected_user = "cmin  "
+    else:
+        get_user = lambda book: book.user_name
+        expected_user = "cmin"
+
+    library.open_workbook(EXCELS_DIR / name)
+    assert get_user(library.workbook.book) == expected_user
+
+    library.save_workbook(RESULTS_DIR / name)
+    # Leading/trailing whitespace is stripped on save, thus not creating any unwanted
+    #  `xml:space="preserve"` tag child under workbook properties. (which breaks
+    #  validation with Microsoft)
+    assert get_user(library.workbook.book) == "cmin"
diff --git a/packages/main/tests/resources/empty.xls b/packages/main/tests/resources/excels/empty.xls
similarity index 100%
rename from packages/main/tests/resources/empty.xls
rename to packages/main/tests/resources/excels/empty.xls
diff --git a/packages/main/tests/resources/empty.xlsx b/packages/main/tests/resources/excels/empty.xlsx
similarity index 100%
rename from packages/main/tests/resources/empty.xlsx
rename to packages/main/tests/resources/excels/empty.xlsx
diff --git a/packages/main/tests/resources/example.xls b/packages/main/tests/resources/excels/example.xls
similarity index 100%
rename from packages/main/tests/resources/example.xls
rename to packages/main/tests/resources/excels/example.xls
diff --git a/packages/main/tests/resources/example.xlsx b/packages/main/tests/resources/excels/example.xlsx
similarity index 100%
rename from packages/main/tests/resources/example.xlsx
rename to packages/main/tests/resources/excels/example.xlsx
diff --git a/packages/main/tests/resources/formulas.xls b/packages/main/tests/resources/excels/formulas.xls
similarity index 100%
rename from packages/main/tests/resources/formulas.xls
rename to packages/main/tests/resources/excels/formulas.xls
diff --git a/packages/main/tests/resources/formulas.xlsx b/packages/main/tests/resources/excels/formulas.xlsx
similarity index 100%
rename from packages/main/tests/resources/formulas.xlsx
rename to packages/main/tests/resources/excels/formulas.xlsx
diff --git a/packages/main/tests/resources/one-row.xls b/packages/main/tests/resources/excels/one-row.xls
similarity index 100%
rename from packages/main/tests/resources/one-row.xls
rename to packages/main/tests/resources/excels/one-row.xls
diff --git a/packages/main/tests/resources/one-row.xlsx b/packages/main/tests/resources/excels/one-row.xlsx
similarity index 100%
rename from packages/main/tests/resources/one-row.xlsx
rename to packages/main/tests/resources/excels/one-row.xlsx
diff --git a/packages/main/tests/resources/excels/spaces.xls b/packages/main/tests/resources/excels/spaces.xls
new file mode 100755
Binary files /dev/null and b/packages/main/tests/resources/excels/spaces.xls differ
diff --git a/packages/main/tests/resources/excels/spaces.xlsx b/packages/main/tests/resources/excels/spaces.xlsx
new file mode 100755
Binary files /dev/null and b/packages/main/tests/resources/excels/spaces.xlsx differ
diff --git a/packages/main/tests/resources/wrong_extension.xls b/packages/main/tests/resources/excels/wrong_extension.xls
similarity index 100%
rename from packages/main/tests/resources/wrong_extension.xls
rename to packages/main/tests/resources/excels/wrong_extension.xls
diff --git a/packages/main/tests/resources/wrong_extension.xlsx b/packages/main/tests/resources/excels/wrong_extension.xlsx
similarity index 100%
rename from packages/main/tests/resources/wrong_extension.xlsx
rename to packages/main/tests/resources/excels/wrong_extension.xlsx
diff --git a/packages/main/tests/robot/test_excel.robot b/packages/main/tests/robot/test_excel.robot
--- a/packages/main/tests/robot/test_excel.robot
+++ b/packages/main/tests/robot/test_excel.robot
@@ -7,12 +7,13 @@ Task Teardown   Close Workbook
 
 *** Variables ***
 ${RESOURCES}    ${CURDIR}${/}..${/}resources
+${EXCELS}       ${RESOURCES}${/}excels
 
 
 *** Keywords ***
 Append Content To Sheet
     [Arguments]    ${excel_file}    ${content}
-    ${src} =    Set Variable    ${RESOURCES}${/}${excel_file}
+    ${src} =    Set Variable    ${EXCELS}${/}${excel_file}
     ${dest} =    Set Variable    ${OUTPUT_DIR}${/}${excel_file}
     Copy File    ${src}    ${dest}
     Open Workbook    ${dest}
diff --git a/packages/main/tests/robot/test_tables.robot b/packages/main/tests/robot/test_tables.robot
--- a/packages/main/tests/robot/test_tables.robot
+++ b/packages/main/tests/robot/test_tables.robot
@@ -4,7 +4,7 @@ Library           RPA.FileSystem
 Library           RPA.Excel.Files
 
 *** Variables ***
-${ORDERS_FILE}    ${CURDIR}${/}..${/}resources${/}example.xlsx
+${ORDERS_FILE}    ${CURDIR}${/}..${/}resources${/}excels${/}example.xlsx
 
 *** Tasks ***
 Create table literal