mcruft 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mcruft/__init__.py ADDED
@@ -0,0 +1,80 @@
1
+ # this_file: packages/mcruft/src/mcruft/__init__.py
2
+ from pathlib import Path
3
+
4
+ from .engines.base import Capability, Engine, EngineInfo
5
+ from .engines.registry import engines
6
+ from .model import Asset, Document, Frame, Media, Page, merge
7
+ from .ops import convert
8
+ from .package import load, refresh, save
9
+ from .report import (
10
+ EngineFailed,
11
+ EngineUnavailable,
12
+ Loss,
13
+ McruftError,
14
+ PackageError,
15
+ Policy,
16
+ Report,
17
+ UnsupportedRoute,
18
+ UnsupportedSchema,
19
+ )
20
+
21
+ try:
22
+ from .__version__ import __version__ as __version__
23
+ except ImportError:
24
+ from importlib.metadata import version
25
+
26
+ __version__ = version("mcruft")
27
+
28
+ __all__ = [
29
+ "Asset",
30
+ "Capability",
31
+ "Document",
32
+ "Engine",
33
+ "EngineFailed",
34
+ "EngineInfo",
35
+ "EngineUnavailable",
36
+ "Frame",
37
+ "Loss",
38
+ "McruftError",
39
+ "Media",
40
+ "PackageError",
41
+ "Page",
42
+ "Policy",
43
+ "Report",
44
+ "UnsupportedRoute",
45
+ "UnsupportedSchema",
46
+ "__version__",
47
+ "convert",
48
+ "convert_editable",
49
+ "convert_fidelity",
50
+ "engines",
51
+ "load",
52
+ "merge",
53
+ "new",
54
+ "open",
55
+ "refresh",
56
+ "save",
57
+ ]
58
+
59
+
60
+ def new(title: str = "") -> Document:
61
+ doc = Document()
62
+ doc.meta["title"] = title
63
+ return doc
64
+
65
+
66
+ def open(path: Path | str) -> Document:
67
+ path = Path(path)
68
+ if path.is_dir():
69
+ return load(path)
70
+ doc = new(path.stem)
71
+ doc.add_file(path)
72
+ return doc
73
+
74
+
75
+ def convert_editable(src: Path | str, dst: Path | str) -> Report:
76
+ return convert(src, dst, policy="editable")
77
+
78
+
79
+ def convert_fidelity(src: Path | str, dst: Path | str) -> Report:
80
+ return convert(src, dst, policy="fidelity")
mcruft/__version__.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.1'
22
+ __version_tuple__ = version_tuple = (0, 1, 1)
23
+
24
+ __commit_id__ = commit_id = None
mcruft/editing.py ADDED
@@ -0,0 +1,145 @@
1
+ # this_file: packages/mcruft/src/mcruft/editing.py
2
+ """Page edit transactions and optional PDF previews."""
3
+
4
+ import mimetypes
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+
11
+ from .engines.base import normalize_format
12
+ from .engines.registry import inspect_file
13
+ from .model import Asset, Document, Page, identifier
14
+ from .ops import convert, export, owner
15
+ from .package import digest, member
16
+ from .page_ops import joined, run
17
+ from .report import Report, UnsupportedRoute
18
+
19
+
20
+ def previews(doc: Document, root: Path) -> Report:
21
+ reports = []
22
+ pending = []
23
+ with tempfile.TemporaryDirectory(dir=root) as directory:
24
+ for n, page in enumerate(doc.pages):
25
+ previous = doc.assets.get(page.preview)
26
+ source = doc.assets[page.source["asset"]]
27
+ path = member(root, f"previews/page-{n:04d}.pdf")
28
+ valid = (
29
+ previous
30
+ and previous.role == "preview"
31
+ and previous.derived_from == source.id
32
+ and previous.path.is_file()
33
+ and digest(previous.path) == previous.sha256
34
+ and digest(source.path) == source.sha256
35
+ )
36
+ if valid and previous.path == path:
37
+ continue
38
+ staged = Path(directory) / path.name
39
+ if valid:
40
+ shutil.copyfile(previous.path, staged)
41
+ via = previous.via
42
+ else:
43
+ report = export(doc, staged, policy="editable", pages=[n], fonts="embed")
44
+ reports.append(report)
45
+ via = {
46
+ "engine": report.engine,
47
+ "engine_version": report.engine_version,
48
+ "op": "export_pages",
49
+ "policy": "editable",
50
+ }
51
+ asset = Asset(
52
+ identifier(), path, "application/pdf", digest(staged), "preview", source.id, via
53
+ )
54
+ doc.assets[asset.id] = asset
55
+ page.preview = asset.id
56
+ pending.append((staged, path))
57
+ # Stage every page before replacing paths: reordering must not overwrite another preview.
58
+ for staged, path in pending:
59
+ member(root, "previews").mkdir(parents=True, exist_ok=True)
60
+ os.replace(staged, path)
61
+ referenced = {page.preview for page in doc.pages}
62
+ doc.assets = {
63
+ key: a for key, a in doc.assets.items() if a.role != "preview" or key in referenced
64
+ }
65
+ return joined("save", None, reports, [])
66
+
67
+
68
+ @contextmanager
69
+ def edit_in(page: Page, engine: str):
70
+ """Register changed edits before inspection; inspection errors retain the old size and propagate.
71
+
72
+ A failed body leaves the document unchanged but preserves a changed working file.
73
+ """
74
+ native = {
75
+ "photocraft": {"png", "jpg", "psd", "pcraft"},
76
+ "vectorcraft": {"svg", "ai", "vectorcraft", "pdf"},
77
+ "deckcraft": {"pptx", "deckcraft"},
78
+ "pdfcraft": {"pdf"},
79
+ "designcraft": {"idml", "designcraft"},
80
+ }
81
+ formats = {
82
+ "photocraft": "psd",
83
+ "vectorcraft": "vectorcraft",
84
+ "deckcraft": "pptx",
85
+ "pdfcraft": "pdf",
86
+ "designcraft": "designcraft",
87
+ }
88
+ if engine not in formats:
89
+ raise UnsupportedRoute(f"Unknown edit engine: {engine}")
90
+ doc = owner(page)
91
+ source = doc.assets[page.source["asset"]]
92
+ fmt = normalize_format(source.path.suffix[1:])
93
+ copied = fmt in native[engine]
94
+ index = page.source["index"] if copied else 0
95
+ folder = (
96
+ member(doc.path, "derived") if doc.path else Path(tempfile.mkdtemp(prefix="mcruft-edit-"))
97
+ )
98
+ folder.mkdir(parents=True, exist_ok=True)
99
+ suffix = source.path.suffix if copied else f".{formats[engine]}"
100
+ path = folder / f"{identifier()}{suffix}"
101
+ retained = False
102
+ try:
103
+ if copied:
104
+ shutil.copyfile(source.path, path)
105
+ elif formats[engine] == "pdf":
106
+ with tempfile.TemporaryDirectory(dir=folder) as directory:
107
+ whole = Path(directory) / "converted.pdf"
108
+ convert(source.path, whole, policy="editable")
109
+ run(
110
+ "extract_pages",
111
+ whole,
112
+ path,
113
+ "editable",
114
+ pages=[page.source["index"]],
115
+ )
116
+ else:
117
+ if len(inspect_file(source.path)) != 1:
118
+ raise UnsupportedRoute(f"No single-page editable route: {fmt} -> {formats[engine]}")
119
+ convert(source.path, path, policy="editable")
120
+ before = digest(path)
121
+ try:
122
+ yield path
123
+ finally:
124
+ retained = True
125
+ after = digest(path)
126
+ retained = after != before
127
+ if retained:
128
+ asset = Asset(
129
+ identifier(),
130
+ path,
131
+ mimetypes.guess_type(path)[0] or "application/octet-stream",
132
+ after,
133
+ "derived",
134
+ page.source["asset"],
135
+ {"engine": engine, "op": "edit_in"},
136
+ )
137
+ doc.assets[asset.id] = asset
138
+ page.source = {"asset": asset.id, "index": index}
139
+ page.preview = None
140
+ page.size = inspect_file(path)[index]
141
+ finally:
142
+ if not retained:
143
+ path.unlink(missing_ok=True)
144
+ if doc.path is None:
145
+ folder.rmdir()
@@ -0,0 +1 @@
1
+ # this_file: packages/mcruft/src/mcruft/engines/__init__.py
mcruft/engines/base.py ADDED
@@ -0,0 +1,49 @@
1
+ # this_file: packages/mcruft/src/mcruft/engines/base.py
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Literal
5
+
6
+ from ..report import Policy, Report, UnsupportedRoute
7
+
8
+
9
+ def normalize_format(fmt: str) -> str:
10
+ return {"jpeg": "jpg", "tif": "tiff"}.get(fmt.lower(), fmt.lower())
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Capability:
15
+ op: str
16
+ src: str
17
+ dst: str
18
+ policy: Policy
19
+ verified_with: str | None = None
20
+ note: str = ""
21
+
22
+
23
+ class Engine:
24
+ name: str
25
+ kind: Literal["cli", "mcp", "native", "lib"]
26
+
27
+ def available(self) -> bool:
28
+ return False
29
+
30
+ def version(self) -> str | None:
31
+ return None
32
+
33
+ def capabilities(self) -> list[Capability]:
34
+ return []
35
+
36
+ def run(self, op: str, src: Path, dst: Path, *, policy: Policy, **opts) -> Report:
37
+ raise UnsupportedRoute(f"{self.name}: {op} {policy}")
38
+
39
+ def inspect(self, path: Path) -> list[tuple[float, float]]:
40
+ raise UnsupportedRoute(f"{self.name}: cannot inspect {path.suffix}")
41
+
42
+
43
+ @dataclass
44
+ class EngineInfo:
45
+ name: str
46
+ kind: str
47
+ available: bool
48
+ version: str | None
49
+ capabilities: list[Capability]
mcruft/engines/cli.py ADDED
@@ -0,0 +1,140 @@
1
+ # this_file: packages/mcruft/src/mcruft/engines/cli.py
2
+ import importlib
3
+ import json
4
+ import math
5
+ import os
6
+ import re
7
+ import shlex
8
+ import shutil
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+ from ..report import EngineFailed, EngineUnavailable, Policy, Report, UnsupportedRoute
13
+ from .base import Engine, normalize_format
14
+
15
+
16
+ def run_cli(argv: list[str | Path], timeout: float = 600) -> subprocess.CompletedProcess:
17
+ args = [str(arg.absolute()) if isinstance(arg, Path) else arg for arg in argv]
18
+ try:
19
+ return subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=True)
20
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc:
21
+ tail = getattr(exc, "stderr", "") or str(exc)
22
+ if isinstance(tail, bytes):
23
+ tail = tail.decode(errors="replace")
24
+ raise EngineFailed(f"{shlex.join(args)}\n{tail[-4000:]}") from exc
25
+
26
+
27
+ def parse_version(text: str) -> str:
28
+ match = re.search(r"\b\d+\.\d+\.\d+(?:[a-zA-Z0-9.+-]*)?", text)
29
+ if match is None:
30
+ raise EngineFailed(f"No version number in CLI output: {text[:200]}")
31
+ return match[0]
32
+
33
+
34
+ def page_indexes(pages: list[int] | None, count: int) -> list[int]:
35
+ indexes = list(range(count)) if pages is None else list(pages)
36
+ if not indexes:
37
+ raise ValueError("Select at least one page")
38
+ if any(type(i) is not int or i < 0 or i >= count for i in indexes):
39
+ raise IndexError(f"Page indexes must be integers in 0..{count - 1}")
40
+ return indexes
41
+
42
+
43
+ def pixel_scale(dpi: float) -> float:
44
+ if not math.isfinite(dpi) or dpi <= 0:
45
+ raise ValueError("DPI must be finite and positive")
46
+ return dpi / 72
47
+
48
+
49
+ def json_warnings(value) -> list[str]:
50
+ if isinstance(value, list):
51
+ return [warning for item in value for warning in json_warnings(item)]
52
+ if not isinstance(value, dict):
53
+ return []
54
+ return [
55
+ warning
56
+ for key, item in value.items()
57
+ for warning in (
58
+ item
59
+ if key in ("warnings", "importWarnings") and isinstance(item, list)
60
+ else json_warnings(item)
61
+ )
62
+ ]
63
+
64
+
65
+ class CliEngine(Engine):
66
+ PRODUCT: str
67
+ MIN_VERSION = "0.0.0"
68
+ kind = "cli"
69
+
70
+ @property
71
+ def name(self) -> str:
72
+ return self.PRODUCT
73
+
74
+ def binary(self) -> Path | None:
75
+ override = os.environ.get(f"MCRUFT_{self.PRODUCT.upper()}_CLI")
76
+ if override:
77
+ path = Path(override).expanduser().absolute()
78
+ else:
79
+ package = f"mcruft_{self.PRODUCT}_bin"
80
+ try:
81
+ path = Path(importlib.import_module(package).BIN).absolute()
82
+ except ModuleNotFoundError as exc:
83
+ if exc.name != package:
84
+ raise
85
+ found = shutil.which(f"{self.PRODUCT}-cli")
86
+ path = Path(found).absolute() if found else None
87
+ return path if path and path.is_file() and os.access(path, os.X_OK) else None
88
+
89
+ def available(self) -> bool:
90
+ return self.binary() is not None
91
+
92
+ def version(self) -> str | None:
93
+ binary = self.binary()
94
+ return parse_version(run_cli([binary, "--version"]).stdout) if binary else None
95
+
96
+ def report(self, op: str, policy: Policy, outputs: list[Path] | None = None) -> Report:
97
+ current = self.version()
98
+ report = Report(op, policy, self.name, current, outputs or [])
99
+ if current and tuple(map(int, re.match(r"\d+\.\d+\.\d+", current)[0].split("."))) < tuple(
100
+ map(int, self.MIN_VERSION.split("."))
101
+ ):
102
+ report.warnings.append(f"{self.name} {current} is older than {self.MIN_VERSION}")
103
+ return report
104
+
105
+ def check_route(self, op: str, src: Path, dst: Path, policy: Policy) -> None:
106
+ if op == "inspect" or not any(
107
+ (c.op, normalize_format(c.src), normalize_format(c.dst), c.policy)
108
+ == (op, normalize_format(src.suffix[1:]), normalize_format(dst.suffix[1:]), policy)
109
+ for c in self.capabilities()
110
+ ):
111
+ raise UnsupportedRoute(f"{self.name}: {op} {src.suffix} -> {dst.suffix} ({policy})")
112
+
113
+ def call(self, args: list[str | Path], report: Report | None = None) -> str:
114
+ binary = self.binary()
115
+ if binary is None:
116
+ raise EngineUnavailable(f"Missing {self.PRODUCT}-cli")
117
+ result = run_cli([binary, *args])
118
+ if report is not None:
119
+ for line in result.stdout.splitlines():
120
+ try:
121
+ report.warnings.extend(json_warnings(json.loads(line)))
122
+ except json.JSONDecodeError:
123
+ pass # Some CLIs print human-readable progress instead of JSON.
124
+ report.warnings.extend(
125
+ line.removeprefix("warning: ")
126
+ for line in result.stderr.splitlines()
127
+ if line.startswith("warning: ")
128
+ )
129
+ return result.stdout
130
+
131
+ def run(self, op: str, src: Path, dst: Path, *, policy: Policy, **opts) -> Report:
132
+ raise UnsupportedRoute("implemented in Task 6")
133
+
134
+ async def session(self, read_root: Path, write_root: Path, extra_args: list[str] | None = None):
135
+ from ..mcp import McpSession
136
+
137
+ binary = self.binary()
138
+ if binary is None:
139
+ raise EngineUnavailable(f"Missing {self.PRODUCT}-cli")
140
+ return await McpSession().open(binary, read_root, write_root, extra_args)
mcruft/engines/lib.py ADDED
@@ -0,0 +1,166 @@
1
+ # this_file: packages/mcruft/src/mcruft/engines/lib.py
2
+ import math
3
+ import re
4
+ import xml.etree.ElementTree as ET
5
+ from importlib.metadata import version
6
+ from pathlib import Path
7
+
8
+ from PIL import Image
9
+ from pptx import Presentation
10
+ from pypdf import PdfReader, PdfWriter
11
+
12
+ from ..report import EngineFailed, Loss, Policy, Report, UnsupportedRoute
13
+ from . import libs_pdfium, libs_psd, libs_resvg
14
+ from .base import Capability, Engine, normalize_format
15
+ from .cli import page_indexes, pixel_scale
16
+
17
+
18
+ def svg_size(path: Path) -> tuple[float, float]:
19
+ root = ET.parse(path).getroot()
20
+ dimensions = [root.get(axis, "").strip() for axis in ("width", "height")]
21
+ if "viewBox" in root.attrib and any(not value or value.endswith("%") for value in dimensions):
22
+ values = [float(v) for v in re.split(r"[\s,]+", root.attrib["viewBox"].strip())]
23
+ if len(values) != 4:
24
+ raise UnsupportedRoute("SVG viewBox must have four numbers")
25
+ size = values[2] * 0.75, values[3] * 0.75
26
+ else:
27
+ units = {"": 0.75, "px": 0.75, "pt": 1, "mm": 72 / 25.4, "cm": 72 / 2.54, "in": 72}
28
+ size = []
29
+ for axis in ("width", "height"):
30
+ match = re.fullmatch(
31
+ r"\s*(\d*\.?\d+(?:[eE][+-]?\d+)?)\s*(px|pt|mm|cm|in)?\s*", root.get(axis, "")
32
+ )
33
+ if match is None:
34
+ raise UnsupportedRoute("SVG requires viewBox or absolute width/height")
35
+ size.append(float(match[1]) * units[match[2] or ""])
36
+ if not all(math.isfinite(v) and v > 0 for v in size):
37
+ raise UnsupportedRoute("Page dimensions must be finite and positive")
38
+ return tuple(size)
39
+
40
+
41
+ class LibEngine(Engine):
42
+ name, kind = "lib", "lib"
43
+
44
+ def available(self) -> bool:
45
+ return True
46
+
47
+ def version(self) -> str:
48
+ return version("pypdf")
49
+
50
+ def capabilities(self) -> list[Capability]:
51
+ inspect = [
52
+ Capability("inspect", fmt, fmt, policy, "lib " + library)
53
+ for fmt, library in [
54
+ ("pdf", "pypdf"),
55
+ ("pptx", "python-pptx"),
56
+ ("svg", "XML"),
57
+ ("png", "Pillow"),
58
+ ("jpg", "Pillow"),
59
+ ("jpeg", "Pillow"),
60
+ ]
61
+ for policy in ("editable", "fidelity")
62
+ ]
63
+ raster = [
64
+ Capability("export_pages", fmt, "png", "fidelity", "lib Pillow")
65
+ for fmt in ("png", "jpg", "jpeg")
66
+ ]
67
+ return (
68
+ inspect
69
+ + raster
70
+ + libs_pdfium.capabilities()
71
+ + libs_resvg.capabilities()
72
+ + libs_psd.capabilities()
73
+ + [
74
+ Capability(op, "pdf", "pdf", policy, "lib pypdf")
75
+ for op in ("extract_pages", "combine")
76
+ for policy in ("editable", "fidelity")
77
+ ]
78
+ )
79
+
80
+ def inspect(self, path: Path) -> list[tuple[float, float]]:
81
+ path = Path(path)
82
+ match path.suffix.lower():
83
+ case ".pdf":
84
+ sizes = []
85
+ for page in PdfReader(path).pages:
86
+ size = float(page.mediabox.width), float(page.mediabox.height)
87
+ sizes.append(size[::-1] if page.rotation % 180 else size)
88
+ return sizes
89
+ case ".pptx":
90
+ prs = Presentation(str(path))
91
+ return [(prs.slide_width / 12700, prs.slide_height / 12700)] * len(prs.slides)
92
+ case ".svg":
93
+ return [svg_size(path)]
94
+ case ".psd" if libs_psd.capabilities():
95
+ return libs_psd.inspect(path)
96
+ case ".png" | ".jpg" | ".jpeg":
97
+ with Image.open(path) as image:
98
+ dpi = image.info.get("dpi", (72, 72))
99
+ if any(d <= 0 for d in dpi):
100
+ raise UnsupportedRoute("Image DPI must be positive")
101
+ return [(image.width * 72 / dpi[0], image.height * 72 / dpi[1])]
102
+ case _:
103
+ return super().inspect(path)
104
+
105
+ def run(self, op: str, src: Path, dst: Path, *, policy: Policy, **opts) -> Report:
106
+ if (
107
+ not any(
108
+ (c.op, normalize_format(c.src), normalize_format(c.dst), c.policy)
109
+ == (
110
+ op,
111
+ normalize_format(Path(src).suffix[1:]),
112
+ normalize_format(Path(dst).suffix[1:]),
113
+ policy,
114
+ )
115
+ for c in self.capabilities()
116
+ )
117
+ or op == "inspect"
118
+ ):
119
+ raise UnsupportedRoute(f"lib: {op} ({policy})")
120
+ if Path(src).suffix.lower() == ".svg":
121
+ return libs_resvg.render(op, src, dst, policy, **opts)
122
+ if op == "export_pages" and Path(src).suffix.lower() == ".pdf":
123
+ return libs_pdfium.export_pages(src, dst, policy, **opts)
124
+ if op == "export_pages":
125
+ return self.render_image(src, dst, policy, **opts)
126
+ writer = PdfWriter()
127
+ try:
128
+ if op == "extract_pages":
129
+ reader = PdfReader(src)
130
+ indexes = opts["pages"]
131
+ if any(i < 0 or i >= len(reader.pages) for i in indexes):
132
+ raise IndexError("PDF page index out of range")
133
+ for index in indexes:
134
+ writer.add_page(reader.pages[index])
135
+ else:
136
+ for path in opts.get("paths", [src]):
137
+ writer.append(path)
138
+ writer.write(dst)
139
+ except OSError as exc:
140
+ raise EngineFailed(f"lib {op}: {exc}") from exc
141
+ finally:
142
+ writer.close()
143
+ return Report(op, policy, self.name, self.version(), [Path(dst)])
144
+
145
+ def render_image(self, src: Path, dst: Path, policy: Policy, **opts) -> Report:
146
+ pages = page_indexes(opts.get("pages"), 1)
147
+ dpi = opts.get("dpi", 150)
148
+ scale = pixel_scale(dpi)
149
+ size = tuple(max(1, round(v * scale)) for v in self.inspect(src)[0])
150
+ outputs = []
151
+ losses = []
152
+ try:
153
+ with Image.open(src) as image:
154
+ if image.info.get("icc_profile"):
155
+ losses.append(Loss("effects", "ICC profile not kept"))
156
+ rendered = image.convert("RGBA").resize(size, Image.Resampling.LANCZOS)
157
+ for page in pages:
158
+ out = dst.with_name(f"{dst.stem}-{page:04d}.png")
159
+ rendered.save(out, dpi=(dpi, dpi), icc_profile=None)
160
+ outputs.append(out)
161
+ except OSError as exc:
162
+ raise EngineFailed(f"lib export_pages: {exc}") from exc
163
+ return Report("export_pages", policy, self.name, self.version(), outputs, losses)
164
+
165
+
166
+ engine = LibEngine()
@@ -0,0 +1,55 @@
1
+ # this_file: packages/mcruft/src/mcruft/engines/libs_pdfium.py
2
+ """Optional PDF rasterization; PDFium is not thread-safe, even across documents."""
3
+
4
+ import importlib.util
5
+ from contextlib import closing
6
+ from importlib.metadata import version
7
+ from pathlib import Path
8
+ from threading import Lock
9
+
10
+ from ..report import EngineFailed, Loss, Policy, Report
11
+ from .base import Capability
12
+ from .cli import page_indexes, pixel_scale
13
+
14
+ PDFIUM_LOCK = Lock()
15
+
16
+
17
+ def capabilities() -> list[Capability]:
18
+ if importlib.util.find_spec("pypdfium2") is None:
19
+ return []
20
+ return [
21
+ Capability(
22
+ "export_pages", "pdf", "png", "fidelity", f"lib pypdfium2 {version('pypdfium2')}"
23
+ )
24
+ ]
25
+
26
+
27
+ def export_pages(src: Path, dst: Path, policy: Policy, **opts) -> Report:
28
+ dpi = opts.get("dpi", 150)
29
+ scale = pixel_scale(dpi)
30
+ outputs = []
31
+ with PDFIUM_LOCK:
32
+ # Import initializes PDFium; creation, rendering and cleanup all need the lock.
33
+ import pypdfium2 as pdfium
34
+
35
+ try:
36
+ with pdfium.PdfDocument(src) as document:
37
+ for index in page_indexes(opts.get("pages"), len(document)):
38
+ out = dst.with_name(f"{dst.stem}-{index:04d}.png")
39
+ with (
40
+ closing(document[index]) as page,
41
+ closing(page.render(scale=scale)) as bitmap,
42
+ bitmap.to_pil() as image,
43
+ ):
44
+ image.save(out, dpi=(dpi, dpi))
45
+ outputs.append(out)
46
+ except (OSError, pdfium.PdfiumError) as exc:
47
+ raise EngineFailed(f"lib pypdfium2 export_pages: {exc}") from exc
48
+ return Report(
49
+ "export_pages",
50
+ policy,
51
+ "lib",
52
+ version("pypdfium2"),
53
+ outputs,
54
+ [Loss("raster", "PDF pages rasterised")],
55
+ )