Coverage for haystack/components/converters/docx.py: 99%

146 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 13:53 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import csv 

6import io 

7import os 

8from dataclasses import asdict, dataclass 

9from enum import Enum 

10from io import StringIO 

11from pathlib import Path 

12from typing import Any 

13 

14from haystack import Document, component, default_from_dict, default_to_dict, logging 

15from haystack.components.converters.utils import LinkFormat, get_bytestream_from_source, normalize_metadata 

16from haystack.dataclasses import ByteStream 

17from haystack.lazy_imports import LazyImport 

18 

19logger = logging.getLogger(__name__) 

20 

21with LazyImport("Run 'pip install python-docx'") as docx_import: 

22 import docx 

23 from docx.document import Document as DocxDocument 

24 from docx.table import Table 

25 from docx.text.hyperlink import Hyperlink 

26 from docx.text.paragraph import Paragraph 

27 from docx.text.run import Run 

28 from lxml.etree import _Comment 

29 

30 

31@dataclass 

32class DOCXMetadata: 

33 """ 

34 Describes the metadata of Docx file. 

35 

36 :param author: The author 

37 :param category: The category 

38 :param comments: The comments 

39 :param content_status: The content status 

40 :param created: The creation date (ISO formatted string) 

41 :param identifier: The identifier 

42 :param keywords: Available keywords 

43 :param language: The language of the document 

44 :param last_modified_by: User who last modified the document 

45 :param last_printed: The last printed date (ISO formatted string) 

46 :param modified: The last modification date (ISO formatted string) 

47 :param revision: The revision number 

48 :param subject: The subject 

49 :param title: The title 

50 :param version: The version 

51 """ 

52 

53 author: str 

54 category: str 

55 comments: str 

56 content_status: str 

57 created: str | None 

58 identifier: str 

59 keywords: str 

60 language: str 

61 last_modified_by: str 

62 last_printed: str | None 

63 modified: str | None 

64 revision: int 

65 subject: str 

66 title: str 

67 version: str 

68 

69 

70class DOCXTableFormat(Enum): 

71 """ 

72 Supported formats for storing DOCX tabular data in a Document. 

73 """ 

74 

75 MARKDOWN = "markdown" 

76 CSV = "csv" 

77 

78 def __str__(self) -> str: 

79 return self.value 

80 

81 @staticmethod 

82 def from_str(string: str) -> "DOCXTableFormat": 

83 """ 

84 Convert a string to a DOCXTableFormat enum. 

85 """ 

86 enum_map = {e.value: e for e in DOCXTableFormat} 

87 table_format = enum_map.get(string.lower()) 

88 if table_format is None: 

89 msg = f"Unknown table format '{string}'. Supported formats are: {list(enum_map.keys())}" 

90 raise ValueError(msg) 

91 return table_format 

92 

93 

94DOCXLinkFormat = LinkFormat 

95 

96 

97@component 

98class DOCXToDocument: 

99 """ 

100 Converts DOCX files to Documents. 

101 

102 Uses `python-docx` library to convert the DOCX file to a document. 

103 This component does not preserve page breaks in the original document. 

104 

105 Usage example: 

106 

107 ```python 

108 from haystack.components.converters.docx import DOCXToDocument, DOCXTableFormat, DOCXLinkFormat 

109 from datetime import datetime 

110 

111 converter = DOCXToDocument(table_format=DOCXTableFormat.CSV, link_format=DOCXLinkFormat.MARKDOWN) 

112 results = converter.run( 

113 sources=["test/test_files/docx/sample_docx.docx"], meta={"date_added": datetime.now().isoformat()} 

114 ) 

115 documents = results["documents"] 

116 

117 print(documents[0].content) 

118 # >> 'This is a text from the DOCX file.' 

119 ``` 

120 """ 

121 

122 def __init__( 

123 self, 

124 table_format: str | DOCXTableFormat = DOCXTableFormat.CSV, 

125 link_format: str | DOCXLinkFormat = DOCXLinkFormat.NONE, 

126 store_full_path: bool = False, 

127 ) -> None: 

128 """ 

129 Create a DOCXToDocument component. 

130 

131 :param table_format: The format for table output. Can be either DOCXTableFormat.MARKDOWN, 

132 DOCXTableFormat.CSV, "markdown", or "csv". 

133 :param link_format: The format for link output. Can be either: 

134 DOCXLinkFormat.MARKDOWN or "markdown" to get `[text](address)`, 

135 DOCXLinkFormat.PLAIN or "plain" to get text (address), 

136 DOCXLinkFormat.NONE or "none" to get text without links. 

137 :param store_full_path: 

138 If True, the full path of the file is stored in the metadata of the document. 

139 If False, only the file name is stored. 

140 """ 

141 docx_import.check() 

142 self.table_format = DOCXTableFormat.from_str(table_format) if isinstance(table_format, str) else table_format 

143 self.link_format = DOCXLinkFormat.from_str(link_format) if isinstance(link_format, str) else link_format 

144 self.store_full_path = store_full_path 

145 

146 def to_dict(self) -> dict[str, Any]: 

147 """ 

148 Serializes the component to a dictionary. 

149 

150 :returns: 

151 Dictionary with serialized data. 

152 """ 

153 return default_to_dict( 

154 self, 

155 table_format=str(self.table_format), 

156 link_format=str(self.link_format), 

157 store_full_path=self.store_full_path, 

158 ) 

159 

160 @classmethod 

161 def from_dict(cls, data: dict[str, Any]) -> "DOCXToDocument": 

162 """ 

163 Deserializes the component from a dictionary. 

164 

165 :param data: 

166 The dictionary to deserialize from. 

167 :returns: 

168 The deserialized component. 

169 """ 

170 if "table_format" in data["init_parameters"]: 

171 data["init_parameters"]["table_format"] = DOCXTableFormat.from_str(data["init_parameters"]["table_format"]) 

172 if "link_format" in data["init_parameters"]: 

173 data["init_parameters"]["link_format"] = DOCXLinkFormat.from_str(data["init_parameters"]["link_format"]) 

174 return default_from_dict(cls, data) 

175 

176 @component.output_types(documents=list[Document]) 

177 def run( 

178 self, sources: list[str | Path | ByteStream], meta: dict[str, Any] | list[dict[str, Any]] | None = None 

179 ) -> dict[str, Any]: 

180 """ 

181 Converts DOCX files to Documents. 

182 

183 :param sources: 

184 List of file paths or ByteStream objects. 

185 :param meta: 

186 Optional metadata to attach to the Documents. 

187 This value can be either a list of dictionaries or a single dictionary. 

188 If it's a single dictionary, its content is added to the metadata of all produced Documents. 

189 If it's a list, the length of the list must match the number of sources, because the two lists will 

190 be zipped. 

191 If `sources` contains ByteStream objects, their `meta` will be added to the output Documents. 

192 :returns: 

193 A dictionary with the following keys: 

194 - `documents`: Created Documents 

195 """ 

196 documents = [] 

197 meta_list = normalize_metadata(meta=meta, sources_count=len(sources)) 

198 

199 for source, metadata in zip(sources, meta_list, strict=True): 

200 try: 

201 bytestream = get_bytestream_from_source(source) 

202 except Exception as e: 

203 logger.warning("Could not read {source}. Skipping it. Error: {error}", source=source, error=e) 

204 continue 

205 try: 

206 docx_document = docx.Document(io.BytesIO(bytestream.data)) 

207 elements = self._extract_elements(docx_document) 

208 text = "\n".join(elements) 

209 except Exception as e: 

210 logger.warning( 

211 "Could not read {source} and convert it to a DOCX Document, skipping. Error: {error}", 

212 source=source, 

213 error=e, 

214 ) 

215 continue 

216 

217 docx_metadata = asdict(self._get_docx_metadata(document=docx_document)) 

218 merged_metadata = {**bytestream.meta, **metadata, "docx": docx_metadata} 

219 

220 if not self.store_full_path and "file_path" in bytestream.meta: 

221 file_path = bytestream.meta.get("file_path") 

222 if file_path: # Ensure the value is not None for mypy 

223 merged_metadata["file_path"] = os.path.basename(file_path) 

224 

225 document = Document(content=text, meta=merged_metadata) 

226 documents.append(document) 

227 

228 return {"documents": documents} 

229 

230 def _extract_elements(self, document: "DocxDocument") -> list[str]: 

231 """ 

232 Extracts elements from a DOCX file. 

233 

234 :param document: The DOCX Document object. 

235 :returns: List of strings (paragraph texts and table representations) with page breaks added as '\f' characters. 

236 """ 

237 elements = [] 

238 for element in document.element.body: 

239 if isinstance(element, _Comment): 

240 continue 

241 if element.tag.endswith("p"): 

242 paragraph = Paragraph(element, document) 

243 if paragraph.contains_page_break: 

244 para_text = self._process_paragraph_with_page_breaks(paragraph) 

245 else: 

246 para_text = self._process_links_in_paragraph(paragraph) 

247 elements.append(para_text) 

248 elif element.tag.endswith("tbl"): 

249 table = docx.table.Table(element, document) 

250 table_str = ( 

251 self._table_to_markdown(table) 

252 if self.table_format == DOCXTableFormat.MARKDOWN 

253 else self._table_to_csv(table) 

254 ) 

255 elements.append(table_str) 

256 

257 return elements 

258 

259 def _process_paragraph_with_page_breaks(self, paragraph: "Paragraph") -> str: 

260 """ 

261 Processes a paragraph with page breaks. 

262 

263 :param paragraph: The DOCX paragraph to process. 

264 :returns: A string with page breaks added as '\f' characters. 

265 """ 

266 para_text = "" 

267 # Usually, just 1 page break exists, but could be more if paragraph is really long, so we loop over them 

268 for pb_index, page_break in enumerate(paragraph.rendered_page_breaks): 

269 # Can only extract text from first paragraph page break, unfortunately 

270 if pb_index == 0: 

271 if page_break.preceding_paragraph_fragment: 

272 para_text += self._process_links_in_paragraph(page_break.preceding_paragraph_fragment) 

273 para_text += "\f" 

274 if page_break.following_paragraph_fragment: 

275 # following_paragraph_fragment contains all text for remainder of paragraph. 

276 # However, if the remainder of the paragraph spans multiple page breaks, it won't include 

277 # those later page breaks so we have to add them at end of text in the `else` block below. 

278 # This is not ideal, but this case should be very rare and this is likely good enough. 

279 para_text += self._process_links_in_paragraph(page_break.following_paragraph_fragment) 

280 else: 

281 para_text += "\f" 

282 return para_text 

283 

284 def _process_links_in_paragraph(self, paragraph: "Paragraph") -> str: 

285 """ 

286 Processes links in a paragraph and formats them according to the specified link format. 

287 

288 :param paragraph: The DOCX paragraph to process. 

289 :returns: A string with links formatted according to the specified format. 

290 """ 

291 if self.link_format == DOCXLinkFormat.NONE: 

292 return paragraph.text 

293 text = "" 

294 # Iterate over all hyperlinks and other content in the paragraph 

295 # https://python-docx.readthedocs.io/en/latest/api/text.html#docx.text.paragraph.Paragraph.iter_inner_content 

296 for content in paragraph.iter_inner_content(): 

297 if isinstance(content, Run): 

298 text += content.text 

299 elif isinstance(content, Hyperlink): 

300 if self.link_format == DOCXLinkFormat.MARKDOWN: 

301 formatted_link = f"[{content.text}]({content.address})" 

302 else: # PLAIN format 

303 formatted_link = f"{content.text} ({content.address})" 

304 text += formatted_link 

305 

306 return text 

307 

308 def _table_to_markdown(self, table: "Table") -> str: 

309 """ 

310 Converts a DOCX table to a Markdown string. 

311 

312 :param table: The DOCX table to convert. 

313 :returns: A Markdown string representation of the table. 

314 """ 

315 markdown: list[str] = [] 

316 max_col_widths: list[int] = [] 

317 

318 # Calculate max width for each column 

319 for row in table.rows: 

320 for i, cell in enumerate(row.cells): 

321 cell_text = cell.text.strip() 

322 if i >= len(max_col_widths): 

323 max_col_widths.append(len(cell_text)) 

324 else: 

325 max_col_widths[i] = max(max_col_widths[i], len(cell_text)) 

326 

327 # Process rows 

328 for i, row in enumerate(table.rows): 

329 md_row = [cell.text.strip().ljust(max_col_widths[j]) for j, cell in enumerate(row.cells)] 

330 markdown.append("| " + " | ".join(md_row) + " |") 

331 

332 # Add separator after header row 

333 if i == 0: 

334 separator = ["-" * max_col_widths[j] for j in range(len(row.cells))] 

335 markdown.append("| " + " | ".join(separator) + " |") 

336 

337 return "\n".join(markdown) 

338 

339 def _table_to_csv(self, table: "Table") -> str: 

340 """ 

341 Converts a DOCX table to a CSV string. 

342 

343 :param table: The DOCX table to convert. 

344 :returns: A CSV string representation of the table. 

345 """ 

346 csv_output = StringIO() 

347 csv_writer = csv.writer(csv_output, quoting=csv.QUOTE_MINIMAL) 

348 

349 # Process rows 

350 for row in table.rows: 

351 csv_row = [cell.text.strip() for cell in row.cells] 

352 csv_writer.writerow(csv_row) 

353 

354 # Get the CSV as a string and strip any trailing newlines 

355 csv_string = csv_output.getvalue().strip() 

356 csv_output.close() 

357 

358 return csv_string 

359 

360 def _get_docx_metadata(self, document: "DocxDocument") -> DOCXMetadata: 

361 """ 

362 Get all relevant data from the 'core_properties' attribute from a DOCX Document. 

363 

364 :param document: 

365 The DOCX Document you want to extract metadata from 

366 

367 :returns: 

368 A `DOCXMetadata` dataclass all the relevant fields from the 'core_properties' 

369 """ 

370 return DOCXMetadata( 

371 author=document.core_properties.author, 

372 category=document.core_properties.category, 

373 comments=document.core_properties.comments, 

374 content_status=document.core_properties.content_status, 

375 created=(document.core_properties.created.isoformat() if document.core_properties.created else None), 

376 identifier=document.core_properties.identifier, 

377 keywords=document.core_properties.keywords, 

378 language=document.core_properties.language, 

379 last_modified_by=document.core_properties.last_modified_by, 

380 last_printed=( 

381 document.core_properties.last_printed.isoformat() if document.core_properties.last_printed else None 

382 ), 

383 modified=(document.core_properties.modified.isoformat() if document.core_properties.modified else None), 

384 revision=document.core_properties.revision, 

385 subject=document.core_properties.subject, 

386 title=document.core_properties.title, 

387 version=document.core_properties.version, 

388 )