render.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. # SPDX-FileCopyrightText: 2024 geisserml <geisserml@gmail.com>
  2. # SPDX-License-Identifier: Apache-2.0 OR BSD-3-Clause
  3. import os
  4. import math
  5. import logging
  6. import functools
  7. from pathlib import Path
  8. import multiprocessing as mp
  9. import concurrent.futures as ft
  10. try:
  11. import cv2
  12. except ImportError:
  13. cv2 = None
  14. import pypdfium2._helpers as pdfium
  15. import pypdfium2.internal as pdfium_i
  16. import pypdfium2.raw as pdfium_r
  17. # TODO? consider dotted access
  18. from pypdfium2._cli._parsers import (
  19. add_input, get_input,
  20. setup_logging,
  21. BooleanOptionalAction,
  22. )
  23. logger = logging.getLogger(__name__)
  24. def _bitmap_wrapper_foreign_simple(width, height, format, *args, **kwargs):
  25. if format == pdfium_r.FPDFBitmap_BGRx:
  26. use_alpha = False
  27. elif format == pdfium_r.FPDFBitmap_BGRA:
  28. use_alpha = True
  29. else:
  30. raise RuntimeError(f"Cannot create foreign_simple bitmap with bitmap type {pdfium_i.BitmapTypeToStr[format]}.")
  31. return pdfium.PdfBitmap.new_foreign_simple(width, height, use_alpha, *args, **kwargs)
  32. BitmapMakers = dict(
  33. native = pdfium.PdfBitmap.new_native,
  34. foreign = pdfium.PdfBitmap.new_foreign,
  35. foreign_packed = functools.partial(pdfium.PdfBitmap.new_foreign, force_packed=True),
  36. foreign_simple = _bitmap_wrapper_foreign_simple,
  37. )
  38. CsFields = ("path_fill", "path_stroke", "text_fill", "text_stroke")
  39. ColorOpts = dict(metavar="C", nargs=4, type=int)
  40. SampleTheme = dict(
  41. # TODO improve colors - currently it's just some random ones to distinguish the different drawings
  42. path_fill = (170, 100, 0, 255), # dark orange
  43. path_stroke = (0, 150, 255, 255), # sky blue
  44. text_fill = (255, 255, 255, 255), # white
  45. text_stroke = (150, 255, 0, 255), # green
  46. )
  47. def attach(parser):
  48. add_input(parser, pages=True)
  49. parser.add_argument(
  50. "--output", "-o",
  51. type = Path,
  52. required = True,
  53. help = "Output directory where the serially numbered images shall be placed.",
  54. )
  55. parser.add_argument(
  56. "--prefix",
  57. help = "Custom prefix for the images. Defaults to the input filename's stem.",
  58. )
  59. parser.add_argument(
  60. "--format", "-f",
  61. default = "jpg",
  62. type = str.lower,
  63. help = "The image format to use.",
  64. )
  65. parser.add_argument(
  66. "--engine",
  67. dest = "engine_cls",
  68. type = lambda k: {"pil": PILEngine, "numpy+cv2": NumpyCV2Engine}[k.lower()],
  69. help = "The saver engine to use (pil, numpy+cv2)",
  70. )
  71. parser.add_argument(
  72. "--scale",
  73. default = 1,
  74. type = float,
  75. help = "Define the resolution of the output images. By default, one PDF point (1/72in) is rendered to 1x1 pixel. This factor scales the number of pixels that represent one point.",
  76. )
  77. parser.add_argument(
  78. "--rotation",
  79. default = 0,
  80. type = int,
  81. choices = (0, 90, 180, 270),
  82. help = "Rotate pages by 90, 180 or 270 degrees.",
  83. )
  84. parser.add_argument(
  85. "--fill-color",
  86. help = "Color the bitmap will be filled with before rendering. It shall be given in RGBA format as a sequence of integers ranging from 0 to 255. Defaults to white.",
  87. **ColorOpts,
  88. )
  89. parser.add_argument(
  90. "--optimize-mode",
  91. choices = ("lcd", "print"),
  92. help = "The rendering optimisation mode. None if not given.",
  93. )
  94. parser.add_argument(
  95. "--crop",
  96. nargs = 4,
  97. type = float,
  98. default = (0, 0, 0, 0),
  99. help = "Amount to crop from (left, bottom, right, top).",
  100. )
  101. parser.add_argument(
  102. "--draw-annots",
  103. action = BooleanOptionalAction,
  104. default = True,
  105. help = "Whether annotations may be shown (default: true).",
  106. )
  107. parser.add_argument(
  108. "--draw-forms",
  109. action = BooleanOptionalAction,
  110. default = True,
  111. help = "Whether forms may be shown (default: true).",
  112. )
  113. parser.add_argument(
  114. "--no-antialias",
  115. nargs = "+",
  116. default = [],
  117. choices = ("text", "image", "path"),
  118. type = str.lower,
  119. help = "Item types that shall not be smoothed.",
  120. )
  121. parser.add_argument(
  122. "--force-halftone",
  123. action = "store_true",
  124. help = "Always use halftone for image stretching.",
  125. )
  126. bitmap = parser.add_argument_group(
  127. title = "Bitmap options",
  128. description = "Bitmap config, including pixel format.",
  129. )
  130. bitmap.add_argument(
  131. "--bitmap-maker",
  132. choices = BitmapMakers.keys(),
  133. default = "native",
  134. help = "The bitmap maker to use.",
  135. type = str.lower,
  136. )
  137. bitmap.add_argument(
  138. "--grayscale",
  139. action = "store_true",
  140. help = "Whether to render in grayscale mode (no colors).",
  141. )
  142. bitmap.add_argument(
  143. "--byteorder",
  144. dest = "rev_byteorder",
  145. type = lambda v: {"bgr": False, "rgb": True}[v.lower()],
  146. help = "Whether to use BGR or RGB byteorder (default: conditional).",
  147. )
  148. bitmap.add_argument(
  149. "--x-channel",
  150. dest = "prefer_bgrx",
  151. action = BooleanOptionalAction,
  152. help = "Whether to prefer BGRx/RGBx over BGR/RGB (default: conditional).",
  153. )
  154. parallel = parser.add_argument_group(
  155. title = "Parallelization",
  156. description = "Options for rendering with multiple processes.",
  157. )
  158. parallel.add_argument(
  159. "--linear",
  160. nargs = "?",
  161. type = int,
  162. const = math.inf,
  163. help = "Render non-parallel if page count is less or equal to the specified value (default is conditional). If this flag is given without a value, then render linear regardless of document length.",
  164. )
  165. parallel.add_argument(
  166. "--processes",
  167. default = os.cpu_count(),
  168. type = int,
  169. help = "The maximum number of parallel rendering processes. Defaults to the number of CPU cores.",
  170. )
  171. parallel.add_argument(
  172. "--parallel-strategy",
  173. choices = ("spawn", "forkserver", "fork"),
  174. default = "spawn",
  175. type = str.lower,
  176. help = "The process start method to use. ('fork' is discouraged due to stability issues.)",
  177. )
  178. parallel.add_argument(
  179. "--parallel-lib",
  180. choices = ("mp", "ft"),
  181. default = "mp",
  182. type = str.lower,
  183. help = "The parallelization module to use (mp = multiprocessing, ft = concurrent.futures).",
  184. )
  185. parallel.add_argument(
  186. "--parallel-map",
  187. type = str.lower,
  188. help = "The map function to use (backend specific, the default is an iterative map)."
  189. )
  190. color_scheme = parser.add_argument_group(
  191. title = "Forced color scheme",
  192. description = "Options for using pdfium's forced color scheme renderer. Deprecated, considered not useful.",
  193. )
  194. color_scheme.add_argument(
  195. "--sample-theme",
  196. action = "store_true",
  197. help = "Use a dark background sample theme as base. Explicit color params override selectively."
  198. )
  199. color_scheme.add_argument(
  200. "--path-fill",
  201. **ColorOpts
  202. )
  203. color_scheme.add_argument(
  204. "--path-stroke",
  205. **ColorOpts
  206. )
  207. color_scheme.add_argument(
  208. "--text-fill",
  209. **ColorOpts
  210. )
  211. color_scheme.add_argument(
  212. "--text-stroke",
  213. **ColorOpts
  214. )
  215. color_scheme.add_argument(
  216. "--fill-to-stroke",
  217. action = "store_true",
  218. help = "Only draw borders around fill areas using the `path_stroke` color, instead of filling with the `path_fill` color.",
  219. )
  220. class SavingEngine:
  221. def __init__(self, path_parts):
  222. self._path_parts = path_parts
  223. def _get_path(self, i):
  224. output_dir, prefix, n_digits, format = self._path_parts
  225. return output_dir / f"{prefix}{i+1:0{n_digits}d}.{format}"
  226. def __call__(self, bitmap, i):
  227. out_path = self._get_path(i)
  228. self._saving_hook(out_path, bitmap)
  229. logger.info(f"Wrote page {i+1} as {out_path.name}")
  230. class PILEngine (SavingEngine):
  231. def _saving_hook(self, out_path, bitmap):
  232. bitmap.to_pil().save(out_path)
  233. class NumpyCV2Engine (SavingEngine):
  234. def _saving_hook(self, out_path, bitmap):
  235. cv2.imwrite(str(out_path), bitmap.to_numpy())
  236. def _render_parallel_init(extra_init, input, password, may_init_forms, kwargs, engine):
  237. if extra_init:
  238. extra_init()
  239. logger.info(f"Initializing data for process {os.getpid()}")
  240. pdf = pdfium.PdfDocument(input, password=password, autoclose=True)
  241. if may_init_forms:
  242. pdf.init_forms()
  243. global ProcObjs
  244. ProcObjs = (pdf, kwargs, engine)
  245. def _render_job(i, pdf, kwargs, engine):
  246. # logger.info(f"Started page {i+1} ...")
  247. page = pdf[i]
  248. bitmap = page.render(**kwargs)
  249. engine(bitmap, i)
  250. def _render_parallel_job(i):
  251. global ProcObjs; _render_job(i, *ProcObjs)
  252. def main(args):
  253. # TODO turn into a python-usable API yielding output paths as they are written
  254. pdf = get_input(args, init_forms=args.draw_forms)
  255. # TODO move to parsers?
  256. pdf_len = len(pdf)
  257. if not all(0 <= i < pdf_len for i in args.pages):
  258. raise ValueError("Out-of-bounds page indices are prohibited.")
  259. if len(args.pages) != len(set(args.pages)):
  260. raise ValueError("Duplicate page indices are prohibited.")
  261. if args.prefix is None:
  262. args.prefix = f"{args.input.stem}_"
  263. if args.fill_color is None:
  264. args.fill_color = (0, 0, 0, 255) if args.sample_theme else (255, 255, 255, 255)
  265. if args.linear is None:
  266. args.linear = 6 if args.format == "jpg" else 3
  267. # numpy+cv2 is much faster for PNG, and PIL faster for JPG, but this might simply be due to different encoding defaults
  268. if args.engine_cls is None:
  269. if cv2 != None and args.format == "png":
  270. args.engine_cls = NumpyCV2Engine
  271. else:
  272. args.engine_cls = PILEngine
  273. # PIL is faster with rev_byteorder and prefer_bgrx = True, as this achieves a natively supported pixel format. For numpy+cv2 there doesn't seem to be a difference.
  274. if args.rev_byteorder is None:
  275. args.rev_byteorder = args.engine_cls is PILEngine
  276. if args.prefer_bgrx is None:
  277. # PIL can't save BGRX as PNG
  278. args.prefer_bgrx = args.engine_cls is PILEngine and args.format != "png"
  279. cs_kwargs = dict()
  280. if args.sample_theme:
  281. cs_kwargs.update(**SampleTheme)
  282. cs_kwargs.update(**{f: getattr(args, f) for f in CsFields if getattr(args, f)})
  283. cs = pdfium.PdfColorScheme(**cs_kwargs) if len(cs_kwargs) > 0 else None
  284. kwargs = dict(
  285. scale = args.scale,
  286. rotation = args.rotation,
  287. crop = args.crop,
  288. grayscale = args.grayscale,
  289. fill_color = args.fill_color,
  290. color_scheme = cs,
  291. fill_to_stroke = args.fill_to_stroke,
  292. optimize_mode = args.optimize_mode,
  293. draw_annots = args.draw_annots,
  294. may_draw_forms = args.draw_forms,
  295. force_halftone = args.force_halftone,
  296. rev_byteorder = args.rev_byteorder,
  297. prefer_bgrx = args.prefer_bgrx,
  298. bitmap_maker = BitmapMakers[args.bitmap_maker],
  299. )
  300. for type in args.no_antialias:
  301. kwargs[f"no_smooth{type}"] = True
  302. # TODO dump all args except password?
  303. logger.info(f"{args.engine_cls.__name__}, Format: {args.format}, rev_byteorder: {args.rev_byteorder}, prefer_bgrx {args.prefer_bgrx}")
  304. n_digits = len(str(pdf_len))
  305. path_parts = (args.output, args.prefix, n_digits, args.format)
  306. engine = args.engine_cls(path_parts)
  307. if len(args.pages) <= args.linear:
  308. logger.info("Linear rendering ...")
  309. for i in args.pages:
  310. _render_job(i, pdf, kwargs, engine)
  311. else:
  312. logger.info("Parallel rendering ...")
  313. ctx = mp.get_context(args.parallel_strategy)
  314. # TODO unify using mp.pool.Pool(context=...) ?
  315. pool_backends = dict(
  316. mp = (ctx.Pool, "imap"),
  317. ft = (functools.partial(ft.ProcessPoolExecutor, mp_context=ctx), "map"),
  318. )
  319. pool_ctor, map_attr = pool_backends[args.parallel_lib]
  320. if args.parallel_map:
  321. map_attr = args.parallel_map
  322. extra_init = (setup_logging if args.parallel_strategy in ("spawn", "forkserver") else None)
  323. pool_kwargs = dict(
  324. initializer = _render_parallel_init,
  325. initargs = (extra_init, pdf._input, args.password, args.draw_forms, kwargs, engine),
  326. )
  327. n_procs = min(args.processes, len(args.pages))
  328. with pool_ctor(n_procs, **pool_kwargs) as pool:
  329. map_func = getattr(pool, map_attr)
  330. for _ in map_func(_render_parallel_job, args.pages):
  331. pass