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 +80 -0
- mcruft/__version__.py +24 -0
- mcruft/editing.py +145 -0
- mcruft/engines/__init__.py +1 -0
- mcruft/engines/base.py +49 -0
- mcruft/engines/cli.py +140 -0
- mcruft/engines/lib.py +166 -0
- mcruft/engines/libs_pdfium.py +55 -0
- mcruft/engines/libs_psd.py +36 -0
- mcruft/engines/libs_resvg.py +46 -0
- mcruft/engines/registry.py +64 -0
- mcruft/mcp.py +72 -0
- mcruft/model.py +173 -0
- mcruft/ops.py +136 -0
- mcruft/package.py +174 -0
- mcruft/page_ops.py +143 -0
- mcruft/report.py +48 -0
- mcruft-0.1.1.dist-info/METADATA +27 -0
- mcruft-0.1.1.dist-info/RECORD +21 -0
- mcruft-0.1.1.dist-info/WHEEL +4 -0
- mcruft-0.1.1.dist-info/entry_points.txt +2 -0
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
|
+
)
|