pixedit 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.
Files changed (61) hide show
  1. pixedit/__init__.py +10 -0
  2. pixedit/__main__.py +10 -0
  3. pixedit/adjust.py +175 -0
  4. pixedit/app.py +63 -0
  5. pixedit/cli.py +135 -0
  6. pixedit/composite.py +212 -0
  7. pixedit/factory.py +72 -0
  8. pixedit/geom.py +646 -0
  9. pixedit/io.py +260 -0
  10. pixedit/model.py +534 -0
  11. pixedit/output_folder.py +306 -0
  12. pixedit/paint.py +518 -0
  13. pixedit/pixels.py +127 -0
  14. pixedit/plugins/__init__.py +6 -0
  15. pixedit/plugins/api.py +80 -0
  16. pixedit/plugins/examples/__init__.py +1 -0
  17. pixedit/plugins/examples/basic.py +135 -0
  18. pixedit/plugins/loader.py +98 -0
  19. pixedit/resources/about.md +25 -0
  20. pixedit/resources/icons/brush.svg +8 -0
  21. pixedit/resources/icons/close.svg +4 -0
  22. pixedit/resources/icons/color.svg +6 -0
  23. pixedit/resources/icons/crop.svg +5 -0
  24. pixedit/resources/icons/eraser.svg +5 -0
  25. pixedit/resources/icons/export.svg +5 -0
  26. pixedit/resources/icons/fill.svg +5 -0
  27. pixedit/resources/icons/folder.svg +6 -0
  28. pixedit/resources/icons/hand.svg +3 -0
  29. pixedit/resources/icons/marker.svg +4 -0
  30. pixedit/resources/icons/mask.svg +7 -0
  31. pixedit/resources/icons/new.svg +4 -0
  32. pixedit/resources/icons/open.svg +4 -0
  33. pixedit/resources/icons/place.svg +6 -0
  34. pixedit/resources/icons/redo.svg +4 -0
  35. pixedit/resources/icons/save-as.svg +6 -0
  36. pixedit/resources/icons/save.svg +5 -0
  37. pixedit/resources/icons/select.svg +4 -0
  38. pixedit/resources/icons/shape.svg +4 -0
  39. pixedit/resources/icons/text.svg +4 -0
  40. pixedit/resources/icons/transform.svg +4 -0
  41. pixedit/resources/icons/undo.svg +4 -0
  42. pixedit/resources/icons/zoom.svg +5 -0
  43. pixedit/resources/style.qss +196 -0
  44. pixedit/session.py +1027 -0
  45. pixedit/textutil.py +199 -0
  46. pixedit/tools/__init__.py +5 -0
  47. pixedit/tools/base.py +109 -0
  48. pixedit/tools/builtins.py +1146 -0
  49. pixedit/ui/__init__.py +1 -0
  50. pixedit/ui/canvas.py +548 -0
  51. pixedit/ui/dialogs.py +324 -0
  52. pixedit/ui/icons.py +23 -0
  53. pixedit/ui/main_window.py +971 -0
  54. pixedit/ui/palette.py +410 -0
  55. pixedit/ui/panels.py +903 -0
  56. pixedit-0.1.1.dist-info/METADATA +179 -0
  57. pixedit-0.1.1.dist-info/RECORD +61 -0
  58. pixedit-0.1.1.dist-info/WHEEL +5 -0
  59. pixedit-0.1.1.dist-info/entry_points.txt +5 -0
  60. pixedit-0.1.1.dist-info/licenses/LICENSE +21 -0
  61. pixedit-0.1.1.dist-info/top_level.txt +1 -0
pixedit/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """PixEdit, a layered desktop image editor.
2
+
3
+ Launch the GUI with the ``pix`` console script or ``python -m pixedit``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ __version__ = "0.1.1"
9
+
10
+ __all__ = ["__version__"]
pixedit/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Allow ``python -m pixedit`` in addition to the ``pix`` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from pixedit.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
pixedit/adjust.py ADDED
@@ -0,0 +1,175 @@
1
+ """Color adjustments and destructive filters.
2
+
3
+ Each function returns a new image. Callers intersect with the selection and
4
+ wrap the result in ``MutateLayerCommand``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ from PIL import Image, ImageFilter
11
+
12
+ from pixedit.pixels import gaussian_image, pixelate_image
13
+
14
+
15
+ def _rgba(image: Image.Image) -> np.ndarray:
16
+ return np.array(image.convert("RGBA"))
17
+
18
+
19
+ def _from_rgb(arr: np.ndarray) -> Image.Image:
20
+ return Image.fromarray(np.ascontiguousarray(np.clip(np.rint(arr), 0, 255).astype(np.uint8)), "RGBA")
21
+
22
+
23
+ def apply_masked(before: Image.Image, after: Image.Image, selection: Image.Image | None) -> Image.Image:
24
+ """Keep ``before`` outside the selection and ``after`` inside it."""
25
+ if selection is None:
26
+ return after
27
+ original = _rgba(before).astype(np.float32)
28
+ edited = _rgba(after).astype(np.float32)
29
+ mask = np.asarray(selection.convert("L")).astype(np.float32) / 255.0
30
+ if mask.shape != original.shape[:2]:
31
+ return after
32
+ mix = mask[..., None]
33
+ out = edited * mix + original * (1.0 - mix)
34
+ return _from_rgb(out)
35
+
36
+
37
+ def brightness_contrast(image: Image.Image, brightness: float, contrast: float) -> Image.Image:
38
+ """``brightness`` and ``contrast`` are each -100..100."""
39
+ arr = _rgba(image).astype(np.float32)
40
+ factor = (100.0 + float(contrast)) / 100.0
41
+ shift = float(brightness) / 100.0 * 128.0
42
+ rgb = (arr[..., :3] - 128.0) * factor + 128.0 + shift
43
+ arr[..., :3] = np.clip(rgb, 0, 255)
44
+ return _from_rgb(arr)
45
+
46
+
47
+ def levels(
48
+ image: Image.Image,
49
+ in_black: float,
50
+ in_white: float,
51
+ gamma: float,
52
+ out_black: float = 0.0,
53
+ out_white: float = 255.0,
54
+ ) -> Image.Image:
55
+ """A compact levels adjustment: input window, gamma, output window."""
56
+ arr = _rgba(image).astype(np.float32)
57
+ span = max(1.0, float(in_white) - float(in_black))
58
+ rgb = np.clip((arr[..., :3] - float(in_black)) / span, 0.0, 1.0)
59
+ rgb = np.power(rgb, 1.0 / max(0.05, float(gamma)))
60
+ rgb = rgb * (float(out_white) - float(out_black)) + float(out_black)
61
+ arr[..., :3] = np.clip(rgb, 0, 255)
62
+ return _from_rgb(arr)
63
+
64
+
65
+ def _rgb_to_hsv(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
66
+ r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
67
+ maxc = np.max(rgb, axis=-1)
68
+ minc = np.min(rgb, axis=-1)
69
+ delta = maxc - minc
70
+ safe = np.where(delta == 0, 1.0, delta)
71
+ rc = (maxc - r) / safe
72
+ gc = (maxc - g) / safe
73
+ bc = (maxc - b) / safe
74
+ hue = np.zeros_like(maxc)
75
+ hue = np.where((maxc == r) & (delta > 0), (bc - gc) % 6.0, hue)
76
+ hue = np.where((maxc == g) & (delta > 0), 2.0 + rc - bc, hue)
77
+ hue = np.where((maxc == b) & (delta > 0), 4.0 + gc - rc, hue)
78
+ hue = (hue / 6.0) % 1.0
79
+ sat = np.where(maxc > 1e-8, delta / np.maximum(maxc, 1e-8), 0.0)
80
+ return hue, sat, maxc
81
+
82
+
83
+ def _hsv_to_rgb(h: np.ndarray, s: np.ndarray, v: np.ndarray) -> np.ndarray:
84
+ i = np.floor(h * 6.0).astype(np.int32)
85
+ f = h * 6.0 - i
86
+ p = v * (1.0 - s)
87
+ q = v * (1.0 - f * s)
88
+ t = v * (1.0 - (1.0 - f) * s)
89
+ i_mod = i % 6
90
+ r = np.choose(i_mod, [v, q, p, p, t, v])
91
+ g = np.choose(i_mod, [t, v, v, q, p, p])
92
+ b = np.choose(i_mod, [p, p, t, v, v, q])
93
+ return np.stack([r, g, b], axis=-1)
94
+
95
+
96
+ def hue_saturation(
97
+ image: Image.Image,
98
+ hue_degrees: float = 0.0,
99
+ saturation: float = 0.0,
100
+ lightness: float = 0.0,
101
+ ) -> Image.Image:
102
+ """Shift hue (degrees), saturation (-100..100), and value (-100..100)."""
103
+ arr = _rgba(image).astype(np.float32)
104
+ rgb = arr[..., :3] / 255.0
105
+ hue, sat, val = _rgb_to_hsv(rgb)
106
+ hue = (hue + float(hue_degrees) / 360.0) % 1.0
107
+ sat = np.clip(sat * (1.0 + float(saturation) / 100.0), 0.0, 1.0)
108
+ val = np.clip(val + float(lightness) / 100.0, 0.0, 1.0)
109
+ arr[..., :3] = _hsv_to_rgb(hue, sat, val) * 255.0
110
+ return _from_rgb(arr)
111
+
112
+
113
+ def highlights_shadows(image: Image.Image, highlight: float, shade: float) -> Image.Image:
114
+ """Push the bright end and the dark end. Each argument is -100..100.
115
+
116
+ Positive highlight lightens pixels that are already bright and barely
117
+ touches dark ones. Positive shade lightens dark pixels and barely touches
118
+ bright ones. A negative value darkens that end. Midtones barely move.
119
+ Alpha is left as it was.
120
+ """
121
+ arr = _rgba(image).astype(np.float32)
122
+ rgb = arr[..., :3]
123
+ luma = (rgb[..., 0] * 0.2126 + rgb[..., 1] * 0.7152 + rgb[..., 2] * 0.0722) / 255.0
124
+ # Squared weights fall off before the middle, so a highlight slider does
125
+ # not also lift the shadows.
126
+ highlight_weight = luma ** 2
127
+ shade_weight = (1.0 - luma) ** 2
128
+ rgb = rgb + (float(highlight) / 100.0) * 128.0 * highlight_weight[..., None]
129
+ rgb = rgb + (float(shade) / 100.0) * 128.0 * shade_weight[..., None]
130
+ arr[..., :3] = np.clip(rgb, 0, 255)
131
+ return _from_rgb(arr)
132
+
133
+
134
+ def color_grade(
135
+ image: Image.Image,
136
+ hue: float = 0.0,
137
+ saturation: float = 0.0,
138
+ brightness: float = 0.0,
139
+ contrast: float = 0.0,
140
+ highlight: float = 0.0,
141
+ shade: float = 0.0,
142
+ ) -> Image.Image:
143
+ """One combined grade: hue and saturation, then brightness and contrast, then highlight and shade."""
144
+ if all(abs(float(value)) < 1e-9 for value in (hue, saturation, brightness, contrast, highlight, shade)):
145
+ return image.copy()
146
+ graded = hue_saturation(image, hue_degrees=hue, saturation=saturation, lightness=0.0)
147
+ graded = brightness_contrast(graded, brightness, contrast)
148
+ return highlights_shadows(graded, highlight, shade)
149
+
150
+
151
+ def desaturate(image: Image.Image) -> Image.Image:
152
+ arr = _rgba(image).astype(np.float32)
153
+ luma = arr[..., 0] * 0.2126 + arr[..., 1] * 0.7152 + arr[..., 2] * 0.0722
154
+ arr[..., 0] = luma
155
+ arr[..., 1] = luma
156
+ arr[..., 2] = luma
157
+ return _from_rgb(arr)
158
+
159
+
160
+ def invert(image: Image.Image) -> Image.Image:
161
+ arr = _rgba(image)
162
+ arr[..., :3] = 255 - arr[..., :3]
163
+ return Image.fromarray(arr, "RGBA")
164
+
165
+
166
+ def blur(image: Image.Image, radius: float) -> Image.Image:
167
+ return gaussian_image(image.convert("RGBA"), radius)
168
+
169
+
170
+ def sharpen(image: Image.Image) -> Image.Image:
171
+ return image.convert("RGBA").filter(ImageFilter.UnsharpMask(radius=2, percent=150, threshold=0))
172
+
173
+
174
+ def pixelate(image: Image.Image, block: int) -> Image.Image:
175
+ return pixelate_image(image.convert("RGBA"), block)
pixedit/app.py ADDED
@@ -0,0 +1,63 @@
1
+ """Qt application entry used by the ``pix`` console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from PyQt6.QtGui import QColor, QIcon, QPixmap
9
+ from PyQt6.QtWidgets import QApplication
10
+
11
+ from pixedit import __version__
12
+
13
+
14
+ def _stylesheet() -> str:
15
+ path = Path(__file__).resolve().parent / "resources" / "style.qss"
16
+ if path.is_file():
17
+ return path.read_text(encoding="utf-8")
18
+ return ""
19
+
20
+
21
+ def _icon() -> QIcon:
22
+ pixmap = QPixmap(32, 32)
23
+ pixmap.fill(QColor("#143544"))
24
+ return QIcon(pixmap)
25
+
26
+
27
+ def create_app() -> QApplication:
28
+ app = QApplication.instance() or QApplication(sys.argv)
29
+ app.setApplicationName("PixEdit")
30
+ app.setApplicationVersion(__version__)
31
+ app.setStyle("Fusion")
32
+ style = _stylesheet()
33
+ if style:
34
+ app.setStyleSheet(style)
35
+ app.setWindowIcon(_icon())
36
+ return app
37
+
38
+
39
+ def run(path: str | None = None) -> int:
40
+ """Show the editor and enter the Qt event loop."""
41
+ from pixedit.ui.main_window import MainWindow
42
+
43
+ app = create_app()
44
+ window = MainWindow()
45
+ window.show()
46
+ if path:
47
+ window.open_path(path)
48
+ return int(app.exec())
49
+
50
+
51
+ def check() -> int:
52
+ """Construct the window offscreen and return. Used by ``pix --check``."""
53
+ from pixedit.ui.main_window import MainWindow
54
+
55
+ app = create_app()
56
+ window = MainWindow()
57
+ window.show()
58
+ app.processEvents()
59
+ if window.session.document.width < 1 or not window.tool_by_id:
60
+ return 1
61
+ window.close()
62
+ app.processEvents()
63
+ return 0
pixedit/cli.py ADDED
@@ -0,0 +1,135 @@
1
+ """Console entry point.
2
+
3
+ ``pix`` launches the GUI, the same shape as ``rr`` launching ``rr_app``:
4
+ a short ``[project.scripts]`` command whose ``main`` imports the Qt app only
5
+ after argument parsing, and reports startup failures instead of dying silently.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import sys
13
+ import traceback
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ from pixedit import __version__
18
+
19
+
20
+ def _runtime_log_dir() -> Path:
21
+ if sys.platform == "darwin":
22
+ return Path.home() / "Library" / "Logs" / "pixedit"
23
+ return Path.home() / ".pixedit" / "logs"
24
+
25
+
26
+ def _write_startup_log(title: str, message: str, exc: BaseException | None = None) -> Path | None:
27
+ try:
28
+ log_dir = _runtime_log_dir()
29
+ log_dir.mkdir(parents=True, exist_ok=True)
30
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
31
+ path = log_dir / f"startup-{stamp}.log"
32
+ lines = [f"{title}\n", f"{message}\n"]
33
+ if exc is not None:
34
+ lines.append("\nTraceback:\n")
35
+ lines.append("".join(traceback.format_exception(exc)))
36
+ path.write_text("".join(lines), encoding="utf-8")
37
+ return path
38
+ except Exception:
39
+ return None
40
+
41
+
42
+ def _missing_dep_hint(mod: str) -> str:
43
+ return (
44
+ f"Missing dependency: {mod}\n\n"
45
+ "From a local checkout:\n"
46
+ " python -m venv .venv\n"
47
+ " source .venv/bin/activate\n"
48
+ " pip install -e .\n"
49
+ )
50
+
51
+
52
+ def _display_available() -> bool:
53
+ if os.environ.get("QT_QPA_PLATFORM"):
54
+ return True
55
+ if sys.platform in {"win32", "darwin"}:
56
+ return True
57
+ return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
58
+
59
+
60
+ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
61
+ parser = argparse.ArgumentParser(
62
+ prog="pix",
63
+ description="Launch the PixEdit layered image editor.",
64
+ )
65
+ parser.add_argument("path", nargs="?", help="Image, PDF, or .pix project to open")
66
+ parser.add_argument("--version", action="version", version=f"pixedit {__version__}")
67
+ parser.add_argument(
68
+ "--check",
69
+ action="store_true",
70
+ help="Construct the window and exit. Uses the offscreen Qt platform when needed.",
71
+ )
72
+ return parser.parse_args(argv)
73
+
74
+
75
+ def main(argv: list[str] | None = None) -> int:
76
+ # Do not write bytecode into the checkout when the console script starts.
77
+ sys.dont_write_bytecode = True
78
+ args = _parse_args(argv)
79
+
80
+ if args.check:
81
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
82
+ try:
83
+ from pixedit.app import check
84
+ except ModuleNotFoundError as exc:
85
+ print(_missing_dep_hint(exc.name or "unknown"), file=sys.stderr)
86
+ return 1
87
+ try:
88
+ return int(check())
89
+ except Exception as exc:
90
+ log_path = _write_startup_log("PixEdit check failed", "GUI failed to construct.", exc)
91
+ print(f"PixEdit check failed: {exc}", file=sys.stderr)
92
+ if log_path:
93
+ print(f"Details: {log_path}", file=sys.stderr)
94
+ return 1
95
+
96
+ if not _display_available():
97
+ print(
98
+ "PixEdit needs a display to open the editor.\n"
99
+ "On a headless machine, run `pix --check` to construct the UI offscreen,\n"
100
+ "or `pytest` to exercise the document, layer, and redaction code.",
101
+ file=sys.stderr,
102
+ )
103
+ return 1
104
+
105
+ try:
106
+ from pixedit.app import run
107
+ except ModuleNotFoundError as exc:
108
+ message = _missing_dep_hint(exc.name or "unknown")
109
+ log_path = _write_startup_log("PixEdit failed to start", message, exc)
110
+ print(message, file=sys.stderr)
111
+ if log_path:
112
+ print(f"Details: {log_path}", file=sys.stderr)
113
+ return 1
114
+ except Exception as exc:
115
+ log_path = _write_startup_log("PixEdit failed to initialize", "Unexpected startup error.", exc)
116
+ print(f"PixEdit failed to initialize: {exc}", file=sys.stderr)
117
+ if log_path:
118
+ print(f"Details: {log_path}", file=sys.stderr)
119
+ return 1
120
+
121
+ try:
122
+ return int(run(args.path) or 0)
123
+ except KeyboardInterrupt:
124
+ return 0
125
+ except Exception as exc:
126
+ log_path = _write_startup_log("PixEdit crashed on launch", "Unexpected error while starting the GUI.", exc)
127
+ print(f"PixEdit crashed on launch: {exc}", file=sys.stderr)
128
+ if log_path:
129
+ print(f"Details: {log_path}", file=sys.stderr)
130
+ traceback.print_exc()
131
+ return 1
132
+
133
+
134
+ if __name__ == "__main__":
135
+ raise SystemExit(main())
pixedit/composite.py ADDED
@@ -0,0 +1,212 @@
1
+ """Flatten a document into one RGBA image.
2
+
3
+ Pipeline for each visible layer, bottom to top:
4
+
5
+ 1. Redaction layers do not draw their own pixels. They take the canvas so
6
+ far, run the mask effect (pixelate, blur, solid, noise, and the rest),
7
+ and mix that back through the mask. Source layers underneath stay untouched.
8
+ 2. Raster and text layers are blended with a CSS-style "simple alpha
9
+ compositing" formula (source-over, with the blend mode applied only where
10
+ both coverages overlap). That keeps Multiply/Screen/Overlay from turning
11
+ into black when the backdrop is transparent.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+ from PIL import Image
18
+
19
+ from pixedit.model import BLEND_MODES, Document, Layer
20
+ from pixedit.pixels import mask_effect
21
+
22
+ # geom.place_image is imported lazily inside mask placement to keep import
23
+ # order boring: composite -> model, and geom -> model, neither imports the other
24
+ # at module level for placement helpers defined here.
25
+
26
+
27
+ def _blend_color(mode: str, dst: np.ndarray, src: np.ndarray) -> np.ndarray:
28
+ if mode == "multiply":
29
+ return src * dst
30
+ if mode == "screen":
31
+ return 1.0 - (1.0 - src) * (1.0 - dst)
32
+ if mode == "overlay":
33
+ # Overlay is multiply on the dark half of the backdrop and screen on the light half.
34
+ return np.where(
35
+ dst < 0.5,
36
+ 2.0 * src * dst,
37
+ 1.0 - 2.0 * (1.0 - src) * (1.0 - dst),
38
+ )
39
+ return src
40
+
41
+
42
+ def blend_arrays(
43
+ dst: np.ndarray,
44
+ src: np.ndarray,
45
+ opacity: float = 1.0,
46
+ mode: str = "normal",
47
+ ) -> np.ndarray:
48
+ """Composite ``src`` over ``dst``. Both are float HxWx4 straight alpha, 0..1."""
49
+ if mode not in BLEND_MODES:
50
+ mode = "normal"
51
+ src_a = np.clip(src[..., 3:4] * float(opacity), 0.0, 1.0)
52
+ dst_a = np.clip(dst[..., 3:4], 0.0, 1.0)
53
+ src_rgb = src[..., :3]
54
+ dst_rgb = dst[..., :3]
55
+ blended = _blend_color(mode, dst_rgb, src_rgb)
56
+ out_a = src_a + dst_a * (1.0 - src_a)
57
+ # Where the backdrop is clear, fall back to the source color so blend
58
+ # modes do not mix against an imaginary black canvas.
59
+ numer = src_a * (src_rgb * (1.0 - dst_a) + blended * dst_a) + dst_rgb * dst_a * (1.0 - src_a)
60
+ out_rgb = np.where(out_a > 1e-6, numer / np.maximum(out_a, 1e-6), 0.0)
61
+ return np.concatenate(
62
+ [np.clip(out_rgb, 0.0, 1.0), np.clip(out_a, 0.0, 1.0)],
63
+ axis=-1,
64
+ ).astype(np.float32)
65
+
66
+
67
+ def pil_to_float(image: Image.Image) -> np.ndarray:
68
+ rgba = image.convert("RGBA")
69
+ return np.asarray(rgba).astype(np.float32) / 255.0
70
+
71
+
72
+ def float_to_pil(array: np.ndarray) -> Image.Image:
73
+ uint8 = np.clip(np.rint(array * 255.0), 0, 255).astype(np.uint8)
74
+ return Image.fromarray(np.ascontiguousarray(uint8), "RGBA")
75
+
76
+
77
+ def _intersection(
78
+ ax: int, ay: int, aw: int, ah: int, bw: int, bh: int
79
+ ) -> tuple[int, int, int, int, int, int] | None:
80
+ """Overlap of a placed image (ax, ay, aw, ah) with a canvas of size bw×bh.
81
+
82
+ Returns canvas x, y, width, height and the source origin inside the image.
83
+ """
84
+ x0 = max(0, ax)
85
+ y0 = max(0, ay)
86
+ x1 = min(bw, ax + aw)
87
+ y1 = min(bh, ay + ah)
88
+ if x1 <= x0 or y1 <= y0:
89
+ return None
90
+ return x0, y0, x1 - x0, y1 - y0, x0 - ax, y0 - ay
91
+
92
+
93
+ def blit_layer(canvas: np.ndarray, layer: Layer) -> np.ndarray:
94
+ image = layer.image
95
+ overlap = _intersection(layer.x, layer.y, image.width, image.height, canvas.shape[1], canvas.shape[0])
96
+ if overlap is None or layer.opacity <= 0:
97
+ return canvas
98
+ x0, y0, width, height, sx, sy = overlap
99
+ cropped = image.crop((sx, sy, sx + width, sy + height))
100
+ src = pil_to_float(cropped)
101
+ dst = canvas[y0 : y0 + height, x0 : x0 + width]
102
+ canvas[y0 : y0 + height, x0 : x0 + width] = blend_arrays(dst, src, layer.opacity, layer.blend_mode)
103
+ return canvas
104
+
105
+
106
+ def mask_to_canvas(layer: Layer, width: int, height: int) -> np.ndarray:
107
+ """Place a redaction mask into a float HxW array covering the document."""
108
+ full = np.zeros((height, width), dtype=np.float32)
109
+ if layer.mask is None:
110
+ return full
111
+ overlap = _intersection(layer.x, layer.y, layer.mask.width, layer.mask.height, width, height)
112
+ if overlap is None:
113
+ return full
114
+ x0, y0, mw, mh, sx, sy = overlap
115
+ cropped = layer.mask.crop((sx, sy, sx + mw, sy + mh))
116
+ full[y0 : y0 + mh, x0 : x0 + mw] = np.asarray(cropped).astype(np.float32) / 255.0
117
+ return full
118
+
119
+
120
+ def apply_redaction(canvas: np.ndarray, layer: Layer) -> np.ndarray:
121
+ """Mix the mask effect through ``layer``'s mask.
122
+
123
+ The effect is computed from the stack *below* this layer, so pixels in
124
+ earlier layers are not rewritten. Later layers still composite normally
125
+ and can sit above the mask (annotations, for example).
126
+ """
127
+ height, width = canvas.shape[:2]
128
+ mask = mask_to_canvas(layer, width, height) * float(layer.opacity)
129
+ if float(mask.max()) <= 0.0:
130
+ return canvas
131
+ info = layer.redaction
132
+ kind = info.kind if info is not None else "pixelate"
133
+ amount = info.amount if info is not None else 12.0
134
+ color = info.color if info is not None else (0, 0, 0, 255)
135
+ seed = info.seed if info is not None else 1
136
+ effect = mask_effect(canvas, kind, amount, color, seed)
137
+ mix = mask[..., None]
138
+ return (effect * mix + canvas * (1.0 - mix)).astype(np.float32)
139
+
140
+
141
+ def composite(document: Document) -> Image.Image:
142
+ """Flatten ``document`` to one RGBA image the size of the canvas."""
143
+ canvas = np.zeros((document.height, document.width, 4), dtype=np.float32)
144
+ for layer in document.layers:
145
+ if not layer.visible or layer.opacity <= 0:
146
+ continue
147
+ if layer.kind == "redaction":
148
+ canvas = apply_redaction(canvas, layer)
149
+ else:
150
+ canvas = blit_layer(canvas, layer)
151
+ return float_to_pil(canvas)
152
+
153
+
154
+ def composite_over(document: Document, background: tuple[int, int, int, int]) -> Image.Image:
155
+ """Flatten onto a solid background. Used by JPEG export."""
156
+ flat = composite(document)
157
+ base = Image.new("RGBA", flat.size, background)
158
+ return Image.alpha_composite(base, flat)
159
+
160
+
161
+ def merge_pair(lower: Layer, upper: Layer) -> Layer:
162
+ """Bake ``upper`` into ``lower`` and return a new Normal raster layer.
163
+
164
+ The temporary canvas covers the union of both bitmaps, including pixels
165
+ that sit outside the document, so a merge does not clip overflow.
166
+ Redaction is baked by running the normal compositor on just these two
167
+ layers: the mask still samples only ``lower``, which is the whole stack
168
+ below the redaction *within this pair*.
169
+ """
170
+ from pixedit.model import Layer as LayerCls
171
+ from pixedit.model import new_id
172
+
173
+ def extent(layer: Layer) -> tuple[int, int, int, int]:
174
+ image = layer.bitmap()
175
+ return layer.x, layer.y, layer.x + image.width, layer.y + image.height
176
+
177
+ boxes = [extent(lower), extent(upper)]
178
+ min_x = min(box[0] for box in boxes)
179
+ min_y = min(box[1] for box in boxes)
180
+ max_x = max(box[2] for box in boxes)
181
+ max_y = max(box[3] for box in boxes)
182
+ width = max(1, max_x - min_x)
183
+ height = max(1, max_y - min_y)
184
+ a = lower.clone()
185
+ b = upper.clone()
186
+ a.x -= min_x
187
+ a.y -= min_y
188
+ b.x -= min_x
189
+ b.y -= min_y
190
+ temp = Document(width=width, height=height, layers=[a, b])
191
+ flat = composite(temp)
192
+ alpha = np.asarray(flat)[..., 3]
193
+ ys, xs = np.nonzero(alpha > 0)
194
+ if len(xs) == 0:
195
+ image = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
196
+ x = y = 0
197
+ else:
198
+ left, top, right, bottom = int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
199
+ image = flat.crop((left, top, right, bottom))
200
+ x = min_x + left
201
+ y = min_y + top
202
+ return LayerCls(
203
+ id=new_id(),
204
+ name=lower.name,
205
+ kind="raster",
206
+ visible=lower.visible,
207
+ opacity=1.0,
208
+ blend_mode="normal",
209
+ image=image,
210
+ x=x,
211
+ y=y,
212
+ )
pixedit/factory.py ADDED
@@ -0,0 +1,72 @@
1
+ """Constructors for the layer kinds the editor creates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+
7
+ from PIL import Image
8
+
9
+ from pixedit.model import Layer, RedactionInfo, TextInfo, new_id
10
+ from pixedit.textutil import render_text
11
+
12
+
13
+ def raster_layer(name: str, image: Image.Image, x: int = 0, y: int = 0) -> Layer:
14
+ return Layer(
15
+ id=new_id(),
16
+ name=name,
17
+ kind="raster",
18
+ image=image.convert("RGBA"),
19
+ x=int(x),
20
+ y=int(y),
21
+ )
22
+
23
+
24
+ def blank_layer(name: str, width: int, height: int, fill: tuple[int, int, int, int] = (0, 0, 0, 0)) -> Layer:
25
+ """A document-sized plate (flatten, or an explicit full-canvas fill)."""
26
+ return raster_layer(name, Image.new("RGBA", (width, height), fill))
27
+
28
+
29
+ def empty_raster_layer(name: str) -> Layer:
30
+ """A content-sized blank layer. Painting grows it around the new marks."""
31
+ return raster_layer(name, Image.new("RGBA", (1, 1), (0, 0, 0, 0)), 0, 0)
32
+
33
+
34
+ def text_layer(name: str, info: TextInfo, x: int, y: int) -> Layer:
35
+ return Layer(
36
+ id=new_id(),
37
+ name=name,
38
+ kind="text",
39
+ image=render_text(info),
40
+ x=int(x),
41
+ y=int(y),
42
+ text=info,
43
+ )
44
+
45
+
46
+ def redaction_layer(
47
+ name: str,
48
+ mask: Image.Image,
49
+ kind: str,
50
+ amount: float,
51
+ x: int = 0,
52
+ y: int = 0,
53
+ color: tuple[int, int, int, int] = (0, 0, 0, 255),
54
+ seed: int | None = None,
55
+ ) -> Layer:
56
+ if seed is None:
57
+ seed = random.randrange(1, 2**31)
58
+ return Layer(
59
+ id=new_id(),
60
+ name=name,
61
+ kind="redaction",
62
+ image=Image.new("RGBA", (1, 1), (0, 0, 0, 0)),
63
+ x=int(x),
64
+ y=int(y),
65
+ mask=mask.convert("L"),
66
+ redaction=RedactionInfo(
67
+ kind=kind,
68
+ amount=float(amount),
69
+ color=tuple(int(channel) for channel in color),
70
+ seed=int(seed),
71
+ ),
72
+ )