Coverage for src/local_deep_research/web/services/pdf_extraction_service.py: 98%

83 statements  

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

1""" 

2PDF text extraction service. 

3 

4Provides efficient PDF text extraction with single-pass processing. 

5Complements pdf_service.py which handles PDF generation. 

6""" 

7 

8import io 

9import time 

10from typing import Any, Dict, List 

11 

12import pdfplumber 

13from loguru import logger 

14 

15from local_deep_research.security.filename_sanitizer import sanitize_filename 

16from local_deep_research.utilities.pdf_extraction_limits import ( 

17 MAX_PDF_EXTRACTED_CHARS, 

18 MAX_PDF_EXTRACTION_CPU_SECONDS, 

19 MAX_PDF_EXTRACTION_PAGES, 

20) 

21from local_deep_research.utilities.pdf_page_walk import iter_pdfplumber_pages 

22from local_deep_research.utilities.pdf_stream_release import ( 

23 PdfplumberWalkReleaser, 

24) 

25from local_deep_research.utilities.resource_utils import safe_close 

26 

27 

28class PDFExtractionService: 

29 """Service for extracting text and metadata from PDF files.""" 

30 

31 @staticmethod 

32 def extract_text_and_metadata( 

33 pdf_content: bytes, filename: str 

34 ) -> Dict[str, Any]: 

35 """ 

36 Extract text and metadata from PDF in a single pass. 

37 

38 This method opens the PDF only once and extracts both text content 

39 and metadata (page count) in the same operation, avoiding the 

40 performance issue of opening the file multiple times. 

41 

42 What is bounded (``utilities/pdf_extraction_limits``): text is 

43 extracted from at most ``MAX_PDF_EXTRACTION_PAGES`` pages, at most 

44 ``MAX_PDF_EXTRACTED_CHARS`` characters are kept (separators 

45 included; the page that crosses the ceiling is truncated to the 

46 budget left), and no new page is started once the calling thread 

47 has spent ``MAX_PDF_EXTRACTION_CPU_SECONDS`` of its own CPU time 

48 (``time.thread_time()``) since entry. The page tree is walked 

49 lazily (``utilities/pdf_page_walk``), so a document declaring more 

50 pages than the ceiling has at most the ceiling's pages (plus the 

51 one that shows there are more) built, not all of them. Every page 

52 the walk reaches is closed once done with, which releases its 

53 per-page layout caches; what pdfminer decodes and parses for a 

54 page stays cached on the document after that, so the walk hands 

55 each page to a releaser (``utilities/pdf_stream_release``) that 

56 releases those caches once they reach 

57 ``MAX_PDF_RETAINED_DECODED_BYTES``. 

58 

59 What is NOT bounded: the budget is checked between pages only, 

60 so neither ``pdfplumber.open`` (the cross-reference parse) nor a 

61 single page's ``extract_text()`` can be interrupted, and what one 

62 page decodes and parses is held until the release after it. 

63 

64 Args: 

65 pdf_content: Raw PDF file bytes 

66 filename: Original filename (for logging) 

67 

68 Returns: 

69 Dictionary with keys: 

70 - 'text': Extracted text content 

71 - 'pages': Number of pages the walk reached. When the walk 

72 stops early (a ceiling, or the CPU budget) the pages after 

73 it are never built, so this is a lower bound and 

74 'truncated' is True. The page tree's declared ``/Count`` 

75 is deliberately not read: resolving it can loop inside 

76 pdfminer (a self-referencing object, or an object-stream 

77 or ``/Length`` reference cycle) after the CPU budget has 

78 stopped applying 

79 - 'size': File size in bytes 

80 - 'filename': Original filename 

81 - 'truncated': True if a page or character ceiling, or the 

82 CPU budget, stopped the extraction early (False otherwise, including failures) 

83 - 'success': Boolean indicating success 

84 - 'error': Error message if failed (None if successful) 

85 

86 Raises: 

87 No exceptions - errors are captured in return dict 

88 """ 

89 # Defense in depth: re-sanitize even though callers should 

90 # have sanitized already — guards against new callers that forget 

91 try: 

92 filename = sanitize_filename(filename) 

93 except Exception: 

94 filename = "unnamed.pdf" 

95 

96 # Started at entry so the budget covers the whole call, the open 

97 # included, as in the library download paths. Per-thread CPU time, 

98 # not wall time: waiting on other requests is not charged. 

99 deadline = time.thread_time() + MAX_PDF_EXTRACTION_CPU_SECONDS 

100 try: 

101 # Not a ``with`` block: ``PDF.close()`` accesses ``PDF.pages``, 

102 # which builds every page of the document. The stream is an 

103 # in-memory buffer, so nothing else needs closing; each page 

104 # the walk reaches is closed below. 

105 pdf = pdfplumber.open(io.BytesIO(pdf_content)) 

106 text_parts = [] 

107 extracted_chars = 0 

108 truncated = False 

109 pages_seen = 0 

110 releaser = PdfplumberWalkReleaser(pdf) 

111 # The walk calls releaser.before_page() before building each 

112 # page, so what building it resolves and caches (its 

113 # /Resources, /Contents, page-tree nodes) is counted too. 

114 for page_number, page in enumerate( 

115 iter_pdfplumber_pages(pdf, releaser) 

116 ): 

117 pages_seen = page_number + 1 

118 # Close every page this loop reaches, on the break 

119 # paths too: pdfplumber keeps a page's parsed layout 

120 # objects and text map cached on it until close(). 

121 try: 

122 if page_number >= MAX_PDF_EXTRACTION_PAGES: 

123 truncated = True 

124 logger.warning( 

125 f"PDF extraction stopped at page ceiling " 

126 f"({MAX_PDF_EXTRACTION_PAGES}) for {filename}" 

127 ) 

128 break 

129 if time.thread_time() >= deadline: 

130 truncated = True 

131 logger.warning( 

132 "PDF extraction stopped at the CPU-time " 

133 f"ceiling ({MAX_PDF_EXTRACTION_CPU_SECONDS}s) " 

134 f"after {page_number} pages for {filename}" 

135 ) 

136 break 

137 separator_chars = 1 if text_parts else 0 

138 remaining = ( 

139 MAX_PDF_EXTRACTED_CHARS 

140 - extracted_chars 

141 - separator_chars 

142 ) 

143 if remaining <= 0: 

144 truncated = True 

145 logger.warning( 

146 f"PDF extraction stopped at character " 

147 f"ceiling ({MAX_PDF_EXTRACTED_CHARS}) " 

148 f"for {filename}" 

149 ) 

150 break 

151 page_text = page.extract_text() 

152 if page_text: 

153 if len(page_text) > remaining: 

154 # Keep the slice that fits rather than 

155 # dropping the tripping page whole: a first 

156 # page over the ceiling still yields the 

157 # budget's text. 

158 text_parts.append(page_text[:remaining]) 

159 extracted_chars += separator_chars + remaining 

160 truncated = True 

161 logger.warning( 

162 f"PDF extraction truncated at character " 

163 f"ceiling ({MAX_PDF_EXTRACTED_CHARS}) " 

164 f"for {filename}" 

165 ) 

166 break 

167 extracted_chars += separator_chars + len(page_text) 

168 text_parts.append(page_text) 

169 finally: 

170 # safe_close: a failing close() must not mask the 

171 # page's own exception. 

172 safe_close(page, "pdfplumber page") 

173 # close() leaves what pdfminer decoded and parsed 

174 # for the page cached on the document; release it 

175 # once it reaches MAX_PDF_RETAINED_DECODED_BYTES. 

176 releaser.after_page() 

177 

178 # A lower bound when truncated: the pages after the stop 

179 # were never built, and /Count is not consulted (see the 

180 # docstring) because no reference is resolved after the walk. 

181 page_count = pages_seen 

182 

183 # Combine all text 

184 full_text = "\n".join(text_parts) 

185 

186 # Check if any text was extracted 

187 if not full_text.strip(): 

188 logger.warning(f"No extractable text found in {filename}") 

189 return { 

190 "text": "", 

191 "pages": page_count, 

192 "size": len(pdf_content), 

193 "filename": filename, 

194 "truncated": truncated, 

195 "success": False, 

196 "error": "No extractable text found", 

197 } 

198 

199 logger.info( 

200 f"Successfully extracted text from {filename} " 

201 f"({len(full_text)} chars, {page_count} pages)" 

202 ) 

203 

204 return { 

205 "text": full_text.strip(), 

206 "pages": page_count, 

207 "size": len(pdf_content), 

208 "filename": filename, 

209 "truncated": truncated, 

210 "success": True, 

211 "error": None, 

212 } 

213 

214 except Exception: 

215 # Log full exception details server-side for debugging 

216 logger.exception(f"Error extracting text from {filename}") 

217 # Return generic error message to avoid exposing internal details 

218 return { 

219 "text": "", 

220 "pages": 0, 

221 "size": len(pdf_content), 

222 "filename": filename, 

223 "truncated": False, 

224 "success": False, 

225 "error": "Failed to extract text from PDF", 

226 } 

227 

228 @staticmethod 

229 def extract_batch(files_data: List[Dict[str, Any]]) -> Dict[str, Any]: 

230 """ 

231 Extract text from multiple PDF files. 

232 

233 Args: 

234 files_data: List of dicts with 'content' (bytes) and 'filename' (str) 

235 

236 Returns: 

237 Dictionary with: 

238 - 'results': List of extraction results 

239 - 'total_files': Total number of files processed 

240 - 'successful': Number of successfully processed files 

241 - 'failed': Number of failed files 

242 - 'errors': List of error messages 

243 """ 

244 results: list[Dict[str, Any]] = [] 

245 successful = 0 

246 failed = 0 

247 errors: list[str] = [] 

248 

249 for file_data in files_data: 

250 result = PDFExtractionService.extract_text_and_metadata( 

251 file_data["content"], file_data["filename"] 

252 ) 

253 

254 results.append(result) 

255 

256 if result["success"]: 

257 successful += 1 

258 else: 

259 failed += 1 

260 errors.append(f"{file_data['filename']}: {result['error']}") 

261 

262 return { 

263 "results": results, 

264 "total_files": len(files_data), 

265 "successful": successful, 

266 "failed": failed, 

267 "errors": errors, 

268 } 

269 

270 

271# Singleton pattern for service 

272_pdf_extraction_service = None 

273 

274 

275def get_pdf_extraction_service() -> PDFExtractionService: 

276 """Get the singleton PDF extraction service instance.""" 

277 global _pdf_extraction_service 

278 if _pdf_extraction_service is None: 

279 _pdf_extraction_service = PDFExtractionService() 

280 return _pdf_extraction_service