Coverage for src/local_deep_research/exporters/odt_exporter.py: 95%

93 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""ODT export service using pypandoc. 

2 

3This module provides the ODTExporter class for converting markdown content 

4to OpenDocument Text (ODT) format using pypandoc (Pandoc wrapper). 

5 

6Pandoc is the industry standard for document conversion and handles all 

7markdown features natively. Note: Requires Pandoc to be installed on the 

8system or use pypandoc_binary which bundles it. 

9""" 

10 

11import functools 

12import subprocess 

13from typing import Optional 

14 

15from loguru import logger 

16 

17# pypandoc is optional - used only to locate the bundled pandoc binary 

18try: 

19 import pypandoc 

20 

21 PYPANDOC_AVAILABLE = True 

22except ImportError: 

23 pypandoc = None 

24 PYPANDOC_AVAILABLE = False 

25 

26from .base import BaseExporter, ExportOptions, ExportResult 

27from .registry import ExporterRegistry 

28 

29 

30@ExporterRegistry.register 

31class ODTExporter(BaseExporter): 

32 """Service for converting markdown to ODT using pypandoc. 

33 

34 This exporter uses Pandoc (via pypandoc) to convert markdown content 

35 to OpenDocument Text format, which can be opened in LibreOffice Writer, 

36 Microsoft Word, and other office applications. 

37 

38 Pandoc is the industry standard for document conversion and handles 

39 all markdown features (tables, code blocks, lists, etc.) natively. 

40 

41 The conversion is performed entirely in memory by piping markdown 

42 to pandoc's stdin and capturing ODT output from stdout. 

43 """ 

44 

45 #: Conversion wall clock; a pathological report must not pin an 

46 #: export worker indefinitely. 

47 DEFAULT_CONVERSION_TIMEOUT_S = 120 

48 #: Output-size ceiling, checked after pandoc's stdout has been 

49 #: captured in full, so it rejects oversized output rather than 

50 #: bounding capture memory. The ODT of a maximum-size report stays 

51 #: far below this. 

52 DEFAULT_MAX_OUTPUT_BYTES = 512 * 1024 * 1024 

53 #: The bundled pandoc accepts GHC RTS heap limits. Other builds that 

54 #: reject RTS flags fail the conversion rather than running uncapped. 

55 DEFAULT_MAX_HEAP_MB = 1024 

56 

57 def __init__( 

58 self, 

59 *, 

60 conversion_timeout_s: int = DEFAULT_CONVERSION_TIMEOUT_S, 

61 max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES, 

62 max_heap_mb: int = DEFAULT_MAX_HEAP_MB, 

63 ): 

64 if not isinstance(max_heap_mb, int) or not ( 

65 0 < max_heap_mb <= self.DEFAULT_MAX_HEAP_MB 

66 ): 

67 raise ValueError( 

68 f"max_heap_mb must be between 1 and {self.DEFAULT_MAX_HEAP_MB}" 

69 ) 

70 self._conversion_timeout_s = conversion_timeout_s 

71 self._max_output_bytes = max_output_bytes 

72 self._max_heap_mb = max_heap_mb 

73 

74 @property 

75 def format_name(self) -> str: 

76 return "odt" 

77 

78 @property 

79 def file_extension(self) -> str: 

80 return ".odt" 

81 

82 @property 

83 def mimetype(self) -> str: 

84 return "application/vnd.oasis.opendocument.text" 

85 

86 @staticmethod 

87 @functools.lru_cache(maxsize=1) 

88 def _pandoc_supports_sandbox() -> bool: 

89 """Whether pandoc knows ``--sandbox`` (added in pandoc 2.15, 2021). 

90 

91 Cached: the check spawns a pandoc --version subprocess, and 

92 exports are rare enough that one probe per process is plenty. 

93 Raises if the version cannot be determined; lru_cache does not 

94 cache exceptions, so a transient probe failure is retried on the 

95 next export instead of disabling the sandbox for the process. 

96 """ 

97 version = str(pypandoc.get_pandoc_version()).strip() 

98 parts = tuple(int(x) for x in version.split(".")[:2]) 

99 return parts >= (2, 15) 

100 

101 def export( 

102 self, 

103 markdown_content: str, 

104 options: Optional[ExportOptions] = None, 

105 ) -> ExportResult: 

106 """Convert markdown content to ODT using Pandoc. 

107 

108 The conversion runs entirely in memory: markdown is piped to 

109 pandoc's stdin and ODT bytes are captured from stdout, avoiding 

110 any temporary files on disk. 

111 

112 Args: 

113 markdown_content: The markdown text to convert 

114 options: Optional export options (title, metadata) 

115 

116 Returns: 

117 ExportResult with ODT content as bytes, filename, and mimetype 

118 

119 Raises: 

120 ValueError: If content exceeds maximum size limit 

121 RuntimeError: If Pandoc conversion fails 

122 """ 

123 # Check if pypandoc is available 

124 if not PYPANDOC_AVAILABLE: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true

125 raise RuntimeError( 

126 "ODT export requires pypandoc. Install with: pip install pypandoc-binary" 

127 ) 

128 

129 try: 

130 # Check content size limit to prevent OOM errors 

131 self._validate_content_size(markdown_content) 

132 

133 options = options or ExportOptions() 

134 

135 # Prepend title if needed (for document formats like ODT) 

136 markdown_content = self._prepend_title_if_needed( 

137 markdown_content, options.title 

138 ) 

139 

140 # Add LDR attribution footer 

141 content_with_footer = self._add_footer(markdown_content) 

142 

143 # Build pandoc args for metadata (sanitized) 

144 extra_args = [] 

145 if options.title: 

146 safe_title = self._sanitize_metadata(options.title) 

147 extra_args.append(f"--metadata=title:{safe_title}") 

148 if options.metadata: 

149 if options.metadata.get("author"): 

150 safe_author = self._sanitize_metadata( 

151 options.metadata["author"] 

152 ) 

153 extra_args.append(f"--metadata=author:{safe_author}") 

154 if options.metadata.get("date"): 

155 safe_date = self._sanitize_metadata( 

156 options.metadata["date"] 

157 ) 

158 extra_args.append(f"--metadata=date:{safe_date}") 

159 

160 # Convert in memory: pipe markdown via stdin, capture ODT from stdout 

161 pandoc_path = pypandoc.get_pandoc_path() 

162 # --sandbox keeps pandoc from reading local files or fetching 

163 # network resources referenced in the (web-influenced) report 

164 # markdown. Refuse the export if its support cannot be verified: 

165 # running without the flag would restore arbitrary file reads 

166 # and SSRF through markdown image references. 

167 try: 

168 supports_sandbox = self._pandoc_supports_sandbox() 

169 except Exception as exc: 

170 logger.warning( 

171 "Could not verify pandoc sandbox support ({})", 

172 type(exc).__name__, 

173 ) 

174 raise RuntimeError( 

175 "ODT export requires pandoc 2.15 or newer with --sandbox" 

176 ) from None 

177 if not supports_sandbox: 

178 raise RuntimeError( # noqa: TRY301 — fail closed before subprocess 

179 "ODT export requires pandoc 2.15 or newer with --sandbox" 

180 ) 

181 # A report far below BaseExporter.MAX_CONTENT_SIZE can make 

182 # pandoc consume several GiB. The shipped GHC build honours 

183 # -M; builds without RTS support reject the command before 

184 # processing markdown, and we never retry without the cap. 

185 cmd = [ 

186 pandoc_path, 

187 "+RTS", 

188 f"-M{self._max_heap_mb}m", 

189 "-RTS", 

190 "--sandbox", 

191 ] 

192 cmd.extend(["-f", "markdown", "-t", "odt", "-o", "-"]) 

193 cmd.extend(extra_args) 

194 

195 try: 

196 result = subprocess.run( 

197 cmd, 

198 input=content_with_footer.encode("utf-8"), 

199 capture_output=True, 

200 check=True, 

201 timeout=self._conversion_timeout_s, 

202 ) 

203 except subprocess.TimeoutExpired as exc: 

204 raise RuntimeError( 

205 f"ODT export timed out after {self._conversion_timeout_s}s" 

206 ) from exc 

207 

208 odt_bytes = result.stdout 

209 if len(odt_bytes) > self._max_output_bytes: 

210 raise RuntimeError( # noqa: TRY301 — same pattern as the no-output raise below 

211 "ODT export output is larger than " 

212 f"{self._max_output_bytes} bytes" 

213 ) 

214 

215 if not odt_bytes: 

216 raise RuntimeError( # noqa: TRY301 — except only adds logging before re-raise 

217 "Pandoc conversion failed - no output produced" 

218 ) 

219 

220 filename = self._generate_safe_filename(options.title) 

221 

222 logger.info( 

223 f"Generated ODT in memory, size: {len(odt_bytes)} bytes" 

224 ) 

225 

226 return ExportResult( 

227 content=odt_bytes, 

228 filename=filename, 

229 mimetype=self.mimetype, 

230 ) 

231 

232 except subprocess.CalledProcessError as e: 

233 # pandoc's stderr quotes input lines — i.e. report content. 

234 # Bound what reaches logs and error surfaces. 

235 stderr_full = ( 

236 e.stderr.decode("utf-8", errors="replace") 

237 if e.stderr 

238 else "unknown error" 

239 ) 

240 stderr = stderr_full[-2000:] 

241 logger.exception("Pandoc conversion failed: {}", stderr) 

242 raise RuntimeError(f"Pandoc conversion failed: {stderr}") from e 

243 except Exception: 

244 logger.exception("Error generating ODT") 

245 raise 

246 

247 def _add_footer(self, markdown_content: str) -> str: 

248 """Add LDR attribution footer to markdown content. 

249 

250 Args: 

251 markdown_content: The original markdown text 

252 

253 Returns: 

254 Markdown with footer appended 

255 """ 

256 footer = ( 

257 "\n\n---\n\n" 

258 "*Generated by [LDR - Local Deep Research]" 

259 "(https://github.com/LearningCircuit/local-deep-research) | " 

260 "Open Source AI Research Assistant*" 

261 ) 

262 return markdown_content + footer 

263 

264 def _sanitize_metadata(self, value: str) -> str: 

265 """Sanitize metadata value to prevent argument injection. 

266 

267 Removes potential pandoc argument injection patterns from user-supplied 

268 metadata values. 

269 

270 Also strips NUL characters (U+0000): Python's subprocess rejects any 

271 argv element containing NUL with ``ValueError: embedded null byte`` 

272 before Pandoc starts, so a NUL in the title, author, or date would 

273 otherwise fail the whole export (#5995). 

274 

275 Args: 

276 value: The metadata value to sanitize 

277 

278 Returns: 

279 Sanitized metadata value safe for pandoc arguments 

280 """ 

281 # Remove potential argument injection patterns. NUL is stripped 

282 # first so it cannot join two dashes into a fresh "--" pair after 

283 # the injection-pattern pass has already run. 

284 return value.replace("\x00", "").replace("--", "").replace("\n", " ")