markitdown-wolfxl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ """Opt-in WolfXL XLSX conversion for MarkItDown's version-1 plugin interface."""
2
+
3
+ from typing import Any, BinaryIO
4
+
5
+ from markitdown import (
6
+ PRIORITY_SPECIFIC_FILE_FORMAT,
7
+ DocumentConverter,
8
+ DocumentConverterResult,
9
+ MarkItDown,
10
+ StreamInfo,
11
+ )
12
+ from markitdown.converters import HtmlConverter
13
+
14
+ from ._reader import WolfxlReader
15
+
16
+ __version__ = "0.1.0"
17
+ __plugin_interface_version__ = 1
18
+
19
+ __all__ = [
20
+ "__version__",
21
+ "__plugin_interface_version__",
22
+ "register_converters",
23
+ "WolfxlXlsxConverter",
24
+ ]
25
+
26
+
27
+ def register_converters(markitdown: MarkItDown, **kwargs: Any) -> None:
28
+ """Register before the built-in XLSX converter, only when plugins are enabled."""
29
+ markitdown.register_converter(
30
+ WolfxlXlsxConverter(), priority=PRIORITY_SPECIFIC_FILE_FORMAT - 1
31
+ )
32
+
33
+
34
+ class WolfxlXlsxConverter(DocumentConverter):
35
+ """Convert XLSX worksheet cell tables with WolfXL and public pandas APIs."""
36
+
37
+ def __init__(self) -> None:
38
+ super().__init__()
39
+ self._html_converter = HtmlConverter()
40
+
41
+ def accepts(
42
+ self, file_stream: BinaryIO, stream_info: StreamInfo, **kwargs: Any
43
+ ) -> bool:
44
+ extension = (stream_info.extension or "").lower()
45
+ if extension:
46
+ return extension == ".xlsx"
47
+ return (stream_info.mimetype or "").lower() == (
48
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
49
+ )
50
+
51
+ def convert(
52
+ self, file_stream: BinaryIO, stream_info: StreamInfo, **kwargs: Any
53
+ ) -> DocumentConverterResult:
54
+ reader = WolfxlReader(file_stream)
55
+ try:
56
+ sheets = reader.parse()
57
+ finally:
58
+ reader.close()
59
+ fragments = []
60
+ for name, frame in sheets.items():
61
+ markdown = self._html_converter.convert_string(
62
+ frame.to_html(index=False), **kwargs
63
+ ).markdown.strip()
64
+ fragments.append(f"## {name}\n{markdown}")
65
+ return DocumentConverterResult(markdown="\n\n".join(fragments).strip())
@@ -0,0 +1,148 @@
1
+ """Opt-in XLSX loading using only public WolfXL and pandas APIs."""
2
+
3
+ from typing import Any
4
+
5
+ import pandas as pd
6
+ from wolfxl import load_workbook
7
+
8
+ _NAN = float("nan")
9
+
10
+ # pandas' documented default missing-value spellings for read_excel/read_csv.
11
+ _NA_VALUES = {
12
+ "",
13
+ "#N/A",
14
+ "#N/A N/A",
15
+ "#NA",
16
+ "-1.#IND",
17
+ "-1.#QNAN",
18
+ "-NaN",
19
+ "-nan",
20
+ "1.#IND",
21
+ "1.#QNAN",
22
+ "<NA>",
23
+ "N/A",
24
+ "NA",
25
+ "NULL",
26
+ "NaN",
27
+ "None",
28
+ "n/a",
29
+ "nan",
30
+ "null",
31
+ }
32
+
33
+
34
+ def _cell_value(cell: Any) -> Any:
35
+ value = cell.value
36
+ if value is None:
37
+ return ""
38
+ if cell.data_type == "e":
39
+ return _NAN
40
+ if cell.data_type == "n" and isinstance(value, (int, float)):
41
+ integer = int(value)
42
+ return integer if integer == value else float(value)
43
+ return value
44
+
45
+
46
+ def _frame(rows: list[list[Any]]) -> pd.DataFrame:
47
+ # Ignore trailing empty rows/cells, but retain interior blanks and sheet order.
48
+ while rows and not rows[-1]:
49
+ rows.pop()
50
+ if not rows:
51
+ return pd.DataFrame()
52
+ width = max(map(len, rows))
53
+ rows = [row + [""] * (width - len(row)) for row in rows]
54
+ names = [
55
+ value if value != "" else f"Unnamed: {i}" for i, value in enumerate(rows[0])
56
+ ]
57
+ # Match the default header=0 naming, including collisions with literal suffixes.
58
+ counts: dict[Any, int] = {}
59
+ order = [i for i, value in enumerate(rows[0]) if value != ""] + [
60
+ i for i, value in enumerate(rows[0]) if value == ""
61
+ ]
62
+ for i in order:
63
+ name = names[i]
64
+ count = counts.get(name, 0)
65
+ while count:
66
+ counts[name] = count + 1
67
+ candidate = f"{name}.{count}"
68
+ if candidate not in names:
69
+ name = candidate
70
+ count = counts.get(name, 0)
71
+ names[i] = name
72
+ counts[name] = count + 1
73
+ if len(rows) == 1:
74
+ return pd.DataFrame(columns=names)
75
+ columns = []
76
+ for i in range(width):
77
+ raw_values = [row[i] for row in rows[1:]]
78
+ values = [
79
+ _NAN if isinstance(value, str) and value in _NA_VALUES else value
80
+ for value in raw_values
81
+ ]
82
+ series = pd.Series(values, dtype=object)
83
+ if series.isna().any():
84
+ try:
85
+ nonmissing_numeric = pd.to_numeric(series.dropna())
86
+ except (ValueError, TypeError):
87
+ pass
88
+ else:
89
+ # read_excel retains object values, including NA spellings, when
90
+ # unsigned 64-bit integers and missing values cannot share a
91
+ # lossless NumPy dtype. Do not round those integers to float.
92
+ if pd.api.types.is_unsigned_integer_dtype(nonmissing_numeric.dtype):
93
+ columns.append(pd.Series(raw_values, dtype=object))
94
+ continue
95
+ try:
96
+ series = pd.to_numeric(series)
97
+ except (ValueError, TypeError):
98
+ # Object columns retain the first equal scalar, including Python's
99
+ # 1 == True / 0 == False aliases, just as read_excel does.
100
+ canonical: dict[Any, Any] = {}
101
+ for value in series:
102
+ canonical.setdefault(value, value)
103
+ series = series.map(canonical)
104
+ # Numeric inference precedes boolean inference, as in read_excel.
105
+ nonmissing = series.dropna()
106
+ boolean_values = {
107
+ True: True,
108
+ False: False,
109
+ "True": True,
110
+ "TRUE": True,
111
+ "true": True,
112
+ "False": False,
113
+ "FALSE": False,
114
+ "false": False,
115
+ }
116
+ if len(nonmissing) and all(
117
+ isinstance(value, (str, bool)) and value in boolean_values
118
+ for value in nonmissing
119
+ ):
120
+ series = series.map(boolean_values)
121
+ columns.append(series.infer_objects())
122
+ frame = pd.concat(columns, axis=1)
123
+ frame.columns = names
124
+ return frame
125
+
126
+
127
+ class WolfxlReader:
128
+ def __init__(self, filepath_or_buffer: Any) -> None:
129
+ self.book = load_workbook(
130
+ filepath_or_buffer, read_only=True, data_only=True, keep_links=False
131
+ )
132
+
133
+ def parse(self, sheet_name: None = None) -> dict[str, pd.DataFrame]:
134
+ sheets = {}
135
+ for sheet in self.book.worksheets:
136
+ # Producer dimension hints can understate the actual XML data.
137
+ sheet.reset_dimensions()
138
+ rows = []
139
+ for row in sheet.iter_rows():
140
+ values = [_cell_value(cell) for cell in row]
141
+ while values and values[-1] == "":
142
+ values.pop()
143
+ rows.append(values)
144
+ sheets[sheet.title] = _frame(rows)
145
+ return sheets
146
+
147
+ def close(self) -> None:
148
+ self.book.close()
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.5
2
+ Name: markitdown-wolfxl
3
+ Version: 0.1.0
4
+ Summary: An opt-in WolfXL XLSX converter plugin for MarkItDown
5
+ Project-URL: Repository, https://github.com/SynthGL/markitdown-wolfxl
6
+ Project-URL: Issues, https://github.com/SynthGL/markitdown-wolfxl/issues
7
+ Author: Wolfgang Schoenberger
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: excel,markdown,markitdown,markitdown-plugin,wolfxl,xlsx
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
14
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: markitdown<0.2,>=0.1.9
17
+ Requires-Dist: pandas<3,>=2.2
18
+ Requires-Dist: wolfxl<2.1,>=2.0.17
19
+ Provides-Extra: test
20
+ Requires-Dist: openpyxl<4,>=3.1; extra == 'test'
21
+ Requires-Dist: pytest; extra == 'test'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # markitdown-wolfxl
25
+
26
+ A third-party, opt-in XLSX converter plugin for [MarkItDown](https://github.com/microsoft/markitdown) that reads workbooks with [WolfXL](https://github.com/SynthGL/wolfxl-oss). It uses only WolfXL's public workbook API and public pandas constructors. It does not modify MarkItDown, register a pandas engine, or import private pandas modules.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ pip install markitdown-wolfxl
32
+ markitdown --list-plugins
33
+ ```
34
+
35
+ The package requires `wolfxl>=2.0.17,<2.1`, pandas 2.x, and MarkItDown `>=0.1.9,<0.2`. WolfXL ships native wheels for common platforms; see its project page for the current list.
36
+
37
+ ## Explicit activation
38
+
39
+ Installing the package does not change ordinary MarkItDown conversions, because MarkItDown keeps plugins disabled by default. Enable plugins to use it:
40
+
41
+ ```python
42
+ from markitdown import MarkItDown
43
+
44
+ result = MarkItDown(enable_plugins=True).convert("workbook.xlsx")
45
+ print(result.markdown)
46
+ ```
47
+
48
+ ```sh
49
+ markitdown --use-plugins workbook.xlsx
50
+ ```
51
+
52
+ The package follows MarkItDown's version-1 plugin interface: entry point group `markitdown.plugin`, `__plugin_interface_version__ = 1`, and `register_converters`. Its XLSX converter is registered at priority `PRIORITY_SPECIFIC_FILE_FORMAT - 1`, ahead of the built-in converter. Files with an explicit non-XLSX extension, including `.xls`, are declined. XLSX MIME information can identify streams without a filename. Other enabled plugins remain subject to MarkItDown's normal priority rules.
53
+
54
+ ## Output and limitations
55
+
56
+ Each worksheet becomes a Markdown heading and a cell table, in workbook order, using first-row headers, pandas-compatible missing values and dtype inference, and MarkItDown's public HTML converter. Workbooks are closed on success and on conversion failure. The reader ignores understated worksheet-dimension hints instead of silently truncating data.
57
+
58
+ This converter handles worksheet cell content, not embedded-image OCR or custom image hooks. Do not enable it when another XLSX plugin's image processing is required. Malformed ZIP or XML, encrypted, Strict OOXML, and some legacy-producer files can convert differently from the built-in reader. WolfXL's rejection of duplicate ZIP entry names is kept.
59
+
60
+ When this plugin raises an ordinary exception, MarkItDown may fall back to another converter, so a successful MarkItDown result does not by itself show that WolfXL converted the file. For example, WolfXL rejects a workbook with a case-insensitive duplicate ZIP entry, and MarkItDown's built-in reader then accepts it. Enabling this plugin is **not an application-wide ZIP validation policy or security boundary**.
61
+
62
+ On a 353-file XLSX test corpus, 318 files that WolfXL converts produce Markdown byte-identical to MarkItDown's built-in converter. No performance claim is made.
63
+
64
+ ## Tests
65
+
66
+ ```sh
67
+ pip install -e '.[test]'
68
+ pytest
69
+ ```
70
+
71
+ Tests cover entry-point discovery, explicit activation, registration priority, XLSX-only acceptance, public-API dispatch, headers and type inference, dimension hints, workbook closure, and fallback behavior.
72
+
73
+ ## License
74
+
75
+ MIT. This is an independent third-party plugin, not sponsored by or affiliated with Microsoft. MarkItDown's copyright notice is retained in `LICENSE` for the adapted table-to-Markdown assembly.
@@ -0,0 +1,7 @@
1
+ markitdown_wolfxl/__init__.py,sha256=QPCOnyFNy4z0QIivcjhrIn5ma0LNLjc3XKf7Zahk88A,2021
2
+ markitdown_wolfxl/_reader.py,sha256=Q4DmQeF5Fy0yNDGz3RM3eSHHbKMjTJn9LhHg3Hnu6EE,4828
3
+ markitdown_wolfxl-0.1.0.dist-info/METADATA,sha256=47NAcAi75Xz-8OBRIqQi-u2MEmK9BUyVYS5Ayu4eq24,4164
4
+ markitdown_wolfxl-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
5
+ markitdown_wolfxl-0.1.0.dist-info/entry_points.txt,sha256=Y-JB7M9IAXTEc2-dj05DU1rwYlM4FwXs_E353glvJIM,47
6
+ markitdown_wolfxl-0.1.0.dist-info/licenses/LICENSE,sha256=uro6BVddWkWb1qAUszidHnkfUWONBf6kL1zXq_0KlxA,1115
7
+ markitdown_wolfxl-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [markitdown.plugin]
2
+ wolfxl = markitdown_wolfxl
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wolfgang Schoenberger
4
+ Copyright (c) Microsoft Corporation.
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.