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
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""ODT export service using pypandoc.
3This module provides the ODTExporter class for converting markdown content
4to OpenDocument Text (ODT) format using pypandoc (Pandoc wrapper).
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"""
11import functools
12import subprocess
13from typing import Optional
15from loguru import logger
17# pypandoc is optional - used only to locate the bundled pandoc binary
18try:
19 import pypandoc
21 PYPANDOC_AVAILABLE = True
22except ImportError:
23 pypandoc = None
24 PYPANDOC_AVAILABLE = False
26from .base import BaseExporter, ExportOptions, ExportResult
27from .registry import ExporterRegistry
30@ExporterRegistry.register
31class ODTExporter(BaseExporter):
32 """Service for converting markdown to ODT using pypandoc.
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.
38 Pandoc is the industry standard for document conversion and handles
39 all markdown features (tables, code blocks, lists, etc.) natively.
41 The conversion is performed entirely in memory by piping markdown
42 to pandoc's stdin and capturing ODT output from stdout.
43 """
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
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
74 @property
75 def format_name(self) -> str:
76 return "odt"
78 @property
79 def file_extension(self) -> str:
80 return ".odt"
82 @property
83 def mimetype(self) -> str:
84 return "application/vnd.oasis.opendocument.text"
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).
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)
101 def export(
102 self,
103 markdown_content: str,
104 options: Optional[ExportOptions] = None,
105 ) -> ExportResult:
106 """Convert markdown content to ODT using Pandoc.
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.
112 Args:
113 markdown_content: The markdown text to convert
114 options: Optional export options (title, metadata)
116 Returns:
117 ExportResult with ODT content as bytes, filename, and mimetype
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 )
129 try:
130 # Check content size limit to prevent OOM errors
131 self._validate_content_size(markdown_content)
133 options = options or ExportOptions()
135 # Prepend title if needed (for document formats like ODT)
136 markdown_content = self._prepend_title_if_needed(
137 markdown_content, options.title
138 )
140 # Add LDR attribution footer
141 content_with_footer = self._add_footer(markdown_content)
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}")
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)
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
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 )
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 )
220 filename = self._generate_safe_filename(options.title)
222 logger.info(
223 f"Generated ODT in memory, size: {len(odt_bytes)} bytes"
224 )
226 return ExportResult(
227 content=odt_bytes,
228 filename=filename,
229 mimetype=self.mimetype,
230 )
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
247 def _add_footer(self, markdown_content: str) -> str:
248 """Add LDR attribution footer to markdown content.
250 Args:
251 markdown_content: The original markdown text
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
264 def _sanitize_metadata(self, value: str) -> str:
265 """Sanitize metadata value to prevent argument injection.
267 Removes potential pandoc argument injection patterns from user-supplied
268 metadata values.
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).
275 Args:
276 value: The metadata value to sanitize
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", " ")