unitCoverWorker.pas 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. unit unitCoverWorker;
  2. {$mode objfpc}{$H+}
  3. interface
  4. uses
  5. Classes, SysUtils, Process, LCLIntf, Graphics, Math,
  6. IntfGraphics, FPImage, FPReadPNG, FPReadJPEG, GraphType, LazCanvas,
  7. Book, BookCollection, FileUtil, unitLog;
  8. { Call this once after loading your data: it scans the list and enqueues
  9. only the PDFs that still use the generic cover (i.e. ImagePath=''). }
  10. procedure CoverWorkerEnqueueMissingFromBookList(AList: TBookCollection);
  11. { Call this whenever you add/edit a single book and want it queued if needed. }
  12. procedure CoverWorkerEnqueueBookIfMissing(B: TBook);
  13. { Starts the background worker (idempotent). It will exit by itself when the
  14. queue is empty. Call again later to restart if you enqueue more books. }
  15. procedure CoverWorkerStart;
  16. { Stops the background worker and clears any pending books }
  17. procedure CoverWorkerStop;
  18. { Remove a specific book from the pending queue (e.g., before deleting it) }
  19. procedure CoverWorkerRemoveBook(B: TBook);
  20. implementation
  21. type
  22. { Simple worker that drains a TThreadList queue of TBook references }
  23. TCoverWorker = class(TThread)
  24. private
  25. FApplyBook: TBook;
  26. FApplyImg : String;
  27. procedure DoApplyCover; // runs in main thread
  28. protected
  29. procedure Execute; override;
  30. end;
  31. var
  32. GPdfQueue: TThreadList; // holds TBook references
  33. GWorker : TCoverWorker;
  34. {--- helpers ------------------------------------------------------------------}
  35. function IsPdf(const Path: String): Boolean;
  36. begin
  37. Result := LowerCase(ExtractFileExt(Path)) = '.pdf';
  38. end;
  39. function HasGenericCover(B: TBook): Boolean;
  40. begin
  41. // Our Book.SetImage leaves ImagePath='' when using the generic resource
  42. Result := (Trim(B.ImagePath) = '');
  43. end;
  44. function GeneratePdfCover(const PdfPath: String; W, H: Integer): String;
  45. var
  46. OutBase, Converter: String;
  47. Proc: TProcess;
  48. SrcImg: TLazIntfImage;
  49. Img: TLazIntfImage;
  50. Canvas: TLazCanvas;
  51. Png: TPortableNetworkGraphic;
  52. scale: Double;
  53. dstW, dstH, offX, offY: Integer;
  54. begin
  55. Result := '';
  56. // If a sibling PNG or JPG already exists, just return it
  57. if FileExists(ChangeFileExt(PdfPath, '.png')) then
  58. Exit(ChangeFileExt(PdfPath, '.png'));
  59. if FileExists(ChangeFileExt(PdfPath, '.jpg')) then
  60. Exit(ChangeFileExt(PdfPath, '.jpg'));
  61. // look for pdftoppm in PATH (Poppler utilities); fall back to bare name
  62. Converter := FindDefaultExecutablePath('pdftoppm');
  63. if Converter = '' then Converter := 'pdftoppm';
  64. LogInfoFmt('pdftoppm tool: %s', [Converter]);
  65. OutBase := ChangeFileExt(PdfPath, ''); // /path/book.pdf -> /path/book
  66. Proc := TProcess.Create(nil);
  67. try
  68. try
  69. Proc.Executable := Converter;
  70. // pdftoppm -png -singlefile -f 1 -l 1 <pdf> <out_base>
  71. Proc.Parameters.Add('-png');
  72. Proc.Parameters.Add('-singlefile');
  73. Proc.Parameters.Add('-f'); Proc.Parameters.Add('1');
  74. Proc.Parameters.Add('-l'); Proc.Parameters.Add('1');
  75. Proc.Parameters.Add(PdfPath);
  76. Proc.Parameters.Add(OutBase);
  77. Proc.Options := [poWaitOnExit];
  78. Proc.ShowWindow := swoHIDE;
  79. LogDebugFmt('Running: %s -png -singlefile -f 1 -l 1 %s %s', [Proc.Executable, PdfPath, OutBase]);
  80. Proc.Execute;
  81. LogDebugFmt('pdftoppm exit=%d', [Proc.ExitStatus]);
  82. except
  83. on E: Exception do LogErrorFmt('pdftoppm failed: %s', [E.Message]);
  84. end;
  85. finally
  86. Proc.Free;
  87. end;
  88. if FileExists(OutBase + '.png') then
  89. begin
  90. Result := OutBase + '.png';
  91. // Scale down to requested cover size
  92. if (W > 0) and (H > 0) then
  93. begin
  94. SrcImg := TLazIntfImage.Create(0, 0);
  95. Img := TLazIntfImage.Create(W, H);
  96. Canvas := TLazCanvas.Create(Img);
  97. Png := TPortableNetworkGraphic.Create;
  98. try
  99. SrcImg.LoadFromFile(Result);
  100. Img.FillPixels(colTransparent);
  101. if (SrcImg.Width > 0) and (SrcImg.Height > 0) then
  102. begin
  103. scale := Min(W / SrcImg.Width, H / SrcImg.Height);
  104. if scale > 1 then scale := 1;
  105. dstW := Round(SrcImg.Width * scale);
  106. dstH := Round(SrcImg.Height * scale);
  107. offX := (W - dstW) div 2;
  108. offY := (H - dstH) div 2;
  109. Canvas.StretchDraw(offX, offY, dstW, dstH, SrcImg);
  110. end;
  111. Png.Assign(Img);
  112. Png.SaveToFile(Result);
  113. LogInfoFmt('Generated cover: %s', [Result]);
  114. finally
  115. Png.Free;
  116. Canvas.Free;
  117. Img.Free;
  118. SrcImg.Free;
  119. end;
  120. end;
  121. end
  122. else
  123. begin
  124. LogWarnFmt('pdftoppm produced no output for: %s', [PdfPath]);
  125. end;
  126. end;
  127. procedure EnsureQueue;
  128. begin
  129. if GPdfQueue = nil then
  130. GPdfQueue := TThreadList.Create;
  131. end;
  132. {--- public API ----------------------------------------------------------------}
  133. procedure CoverWorkerEnqueueMissingFromBookList(AList: TBookCollection);
  134. var
  135. i: Integer;
  136. l: TList;
  137. begin
  138. if AList = nil then Exit;
  139. EnsureQueue;
  140. l := GPdfQueue.LockList;
  141. try
  142. LogInfoFmt('Scanning list for missing PDF covers (count=%d)', [AList.Count]);
  143. for i := 0 to AList.Count - 1 do
  144. if IsPdf(AList.Books[i].FilePath) and HasGenericCover(AList.Books[i]) then
  145. begin
  146. l.Add(AList.Books[i]);
  147. LogDebugFmt('Enqueued for cover: %s', [AList.Books[i].FilePath]);
  148. end;
  149. finally
  150. GPdfQueue.UnlockList;
  151. end;
  152. end;
  153. procedure CoverWorkerRemoveBook(B: TBook);
  154. var
  155. l: TList;
  156. idx: Integer;
  157. begin
  158. if (B = nil) or (GPdfQueue = nil) then Exit;
  159. l := GPdfQueue.LockList;
  160. try
  161. idx := l.IndexOf(B);
  162. if idx >= 0 then
  163. begin
  164. l.Delete(idx);
  165. LogDebugFmt('Removed book from cover queue: %s', [B.FilePath]);
  166. end;
  167. finally
  168. GPdfQueue.UnlockList;
  169. end;
  170. end;
  171. procedure CoverWorkerEnqueueBookIfMissing(B: TBook);
  172. var
  173. l: TList;
  174. begin
  175. if (B = nil) then Exit;
  176. if not (IsPdf(B.FilePath) and HasGenericCover(B)) then Exit;
  177. EnsureQueue;
  178. l := GPdfQueue.LockList;
  179. try
  180. if l.IndexOf(B) < 0 then
  181. begin
  182. l.Add(B);
  183. LogDebugFmt('Enqueued single book for cover: %s', [B.FilePath]);
  184. end;
  185. finally
  186. GPdfQueue.UnlockList;
  187. end;
  188. end;
  189. procedure CoverWorkerStart;
  190. begin
  191. EnsureQueue;
  192. // If a previous worker finished, free it before creating a new one
  193. if Assigned(GWorker) and GWorker.Finished then
  194. begin
  195. GWorker.Free;
  196. GWorker := nil;
  197. end;
  198. if (GWorker = nil) then
  199. begin
  200. LogInfo('Starting cover worker');
  201. GWorker := TCoverWorker.Create(True);
  202. GWorker.FreeOnTerminate := False; // we manage lifecycle explicitly
  203. GWorker.Start;
  204. end;
  205. end;
  206. { Stops the worker and clears any queued books }
  207. procedure CoverWorkerStop;
  208. var
  209. l: TList;
  210. begin
  211. if GWorker <> nil then
  212. begin
  213. LogInfo('Stopping cover worker');
  214. GWorker.Terminate;
  215. // Process synchronize calls while waiting to avoid potential deadlock
  216. while not GWorker.Finished do
  217. begin
  218. Classes.CheckSynchronize(10);
  219. Sleep(5);
  220. end;
  221. FreeAndNil(GWorker);
  222. LogInfo('Cover worker stopped');
  223. end;
  224. if GPdfQueue <> nil then
  225. begin
  226. l := GPdfQueue.LockList;
  227. try
  228. l.Clear;
  229. finally
  230. GPdfQueue.UnlockList;
  231. end;
  232. end;
  233. end;
  234. {--- worker --------------------------------------------------------------------}
  235. procedure TCoverWorker.DoApplyCover;
  236. begin
  237. // This runs in the main/UI thread
  238. try
  239. if Assigned(FApplyBook) and (FApplyImg <> '') and FileExists(FApplyImg) then
  240. begin
  241. FApplyBook.ImagePath := FApplyImg; // triggers SetImage + pre-scale
  242. FApplyBook.EnsureScaledToCoverSize; // in case layout changed
  243. end;
  244. except
  245. // ignore UI exceptions, keep worker going
  246. end;
  247. end;
  248. procedure TCoverWorker.Execute;
  249. var
  250. l: TList;
  251. B: TBook;
  252. Img: String;
  253. begin
  254. // drain the queue
  255. LogInfo('Worker loop started');
  256. while not Terminated do
  257. begin
  258. // Pop one item
  259. B := nil;
  260. l := GPdfQueue.LockList;
  261. try
  262. if (l <> nil) and (l.Count > 0) then
  263. begin
  264. B := TBook(l[0]);
  265. l.Delete(0);
  266. end;
  267. finally
  268. GPdfQueue.UnlockList;
  269. end;
  270. if B = nil then
  271. begin
  272. LogInfo('Queue empty, exiting worker');
  273. Break; // queue empty → exit thread
  274. end;
  275. // Skip if it no longer needs a cover
  276. if not (IsPdf(B.FilePath) and HasGenericCover(B)) then
  277. begin
  278. Sleep(5);
  279. Continue;
  280. end;
  281. // Generate cover (background thread)
  282. LogInfoFmt('Generating cover for: %s', [B.FilePath]);
  283. Img := GeneratePdfCover(B.FilePath, B.Cover.Width, B.Cover.Height);
  284. if (Img <> '') and FileExists(Img) then
  285. begin
  286. // Pass data to main thread via fields + Synchronize
  287. FApplyBook := B;
  288. FApplyImg := Img;
  289. LogDebugFmt('Applying cover: %s', [Img]);
  290. Synchronize(@DoApplyCover);
  291. FApplyBook := nil;
  292. FApplyImg := '';
  293. end
  294. else
  295. LogWarnFmt('Cover generation produced no file for: %s', [B.FilePath]);
  296. Sleep(5); // be nice to the UI event loop
  297. end;
  298. end;
  299. finalization
  300. // Ensure background thread and queue are cleaned up at program end
  301. try
  302. CoverWorkerStop;
  303. except
  304. end;
  305. if GPdfQueue <> nil then
  306. begin
  307. GPdfQueue.Free;
  308. GPdfQueue := nil;
  309. end;
  310. end.