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.
- markitdown_wolfxl/__init__.py +65 -0
- markitdown_wolfxl/_reader.py +148 -0
- markitdown_wolfxl-0.1.0.dist-info/METADATA +75 -0
- markitdown_wolfxl-0.1.0.dist-info/RECORD +7 -0
- markitdown_wolfxl-0.1.0.dist-info/WHEEL +4 -0
- markitdown_wolfxl-0.1.0.dist-info/entry_points.txt +2 -0
- markitdown_wolfxl-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -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,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.
|