Skip to content

Commit 4ae7c26

Browse files
committed
Add border.py and tests. Finish the logic of adding border
1 parent a1e03ba commit 4ae7c26

File tree

9 files changed

+220
-27
lines changed

9 files changed

+220
-27
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,13 @@ Commands:
4242
✗ batch_img border --help
4343
Usage: batch_img border [OPTIONS] SRC_PATH
4444
45-
Add border to image file(s)
45+
Add internal border to image file(s), not expand the size
4646
4747
Options:
4848
-bw, --border_width INTEGER RANGE
4949
Add border to image file(s) with the
5050
border_width. 0 - no border [default: 5;
51-
x>=0]
51+
0<=x<=20]
5252
-bc, --border_color TEXT Add border to image file(s) with the
5353
border_color string [default: gray]
5454
-o, --output TEXT Output file path. If skipped, use the

batch_img/border.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""class Border: add border to the image file(s)
2+
Copyright © 2025 John Liu
3+
"""
4+
5+
from pathlib import Path
6+
7+
import piexif
8+
import pillow_heif
9+
from loguru import logger
10+
from PIL import Image
11+
12+
from batch_img.common import Common
13+
14+
pillow_heif.register_heif_opener() # allow Pillow to open HEIC files
15+
16+
17+
class Border:
18+
@staticmethod
19+
def get_crop_box(width, height, border_width) -> tuple[float, float, float, float]:
20+
"""Get the crop box tuple
21+
22+
Args:
23+
width: image width int
24+
height: image height int
25+
border_width: border width int
26+
27+
Returns:
28+
tuple[float, float, float, float]
29+
"""
30+
crop_left = border_width
31+
crop_top = border_width
32+
crop_right = width - border_width
33+
crop_bottom = height - border_width
34+
return crop_left, crop_top, crop_right, crop_bottom
35+
36+
@staticmethod
37+
def add_border_1_image(
38+
in_path: Path, out_path: Path, border_width: int, border_color: str
39+
) -> tuple:
40+
"""Add internal border to an image file, not to expand the size
41+
42+
Args:
43+
in_path: input file path
44+
out_path: output dir path
45+
border_width: border width int
46+
border_color: border color str
47+
48+
Returns:
49+
tuple: bool, str
50+
"""
51+
try:
52+
with Image.open(in_path) as img:
53+
width, height = img.size
54+
box = Border.get_crop_box(width, height, border_width)
55+
cropped_img = img.crop(box)
56+
bd_img = Image.new(img.mode, (width, height), border_color)
57+
bd_img.paste(cropped_img, (border_width, border_width))
58+
59+
out_path.mkdir(parents=True, exist_ok=True)
60+
out_file = out_path
61+
if out_path.is_dir():
62+
filename = f"{in_path.stem}_bw{border_width}{in_path.suffix}"
63+
out_file = Path(f"{out_path}/{filename}")
64+
exif_dict = None
65+
if "exif" in img.info:
66+
exif_dict = piexif.load(img.info["exif"])
67+
if exif_dict:
68+
exif_bytes = piexif.dump(exif_dict)
69+
bd_img.save(out_file, img.format, optimize=True, exif=exif_bytes)
70+
else:
71+
bd_img.save(out_file, img.format, optimize=True)
72+
logger.info(f"Saved {out_file}")
73+
return True, out_file
74+
except (AttributeError, FileNotFoundError, ValueError) as e:
75+
return False, f"{in_path}:\n{e}"
76+
77+
@staticmethod
78+
def add_border_all_in_dir(
79+
in_path: Path, out_path: Path, border_width: int, border_color: str
80+
) -> bool:
81+
"""Add border to all image files in the given dir
82+
83+
Args:
84+
in_path: input dir path
85+
out_path: output dir path
86+
border_width: border width int
87+
border_color: border color str
88+
89+
Returns:
90+
bool: True - Success. False - Error
91+
"""
92+
image_files = Common.prepare_all_files(in_path, out_path)
93+
if not image_files:
94+
logger.error(f"No image files at {in_path}")
95+
return False
96+
tasks = [(f, out_path, border_width, border_color) for f in image_files]
97+
files_cnt = len(tasks)
98+
99+
logger.info(f"Add border to {files_cnt} image files in multiprocess ...")
100+
success_cnt = Common.multiprocess_progress_bar(
101+
Border.add_border_1_image, "Add border to image files", tasks
102+
)
103+
logger.info(f"\nSuccessfully added border to {success_cnt}/{files_cnt} files")
104+
return True

batch_img/common.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,7 @@ def multiprocess_progress_bar(func, desc, tasks: list) -> int:
227227
"""
228228
success_cnt = 0
229229
files_cnt = len(tasks)
230-
# Limit to 4 workers if cpu cores cnt > 4
231-
workers = min(cpu_count(), 4)
230+
workers = max(cpu_count(), 4)
232231

233232
with Pool(workers) as pool:
234233
with tqdm(total=files_cnt, desc=desc) as pbar:

batch_img/interface.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def cli(ctx, version): # pragma: no cover
1818
click.secho(Common.get_version())
1919

2020

21-
@cli.command(help="Add border to image file(s)")
21+
@cli.command(help="Add internal border to image file(s), not expand the size")
2222
@click.argument(
2323
"src_path",
2424
required=True,
@@ -28,7 +28,7 @@ def cli(ctx, version): # pragma: no cover
2828
"--border_width",
2929
default=5,
3030
show_default=True,
31-
type=click.IntRange(min=0),
31+
type=click.IntRange(min=0, max=20),
3232
help="Add border to image file(s) with the border_width. 0 - no border",
3333
)
3434
@click.option(

batch_img/main.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from loguru import logger
1111

12+
from batch_img.border import Border
1213
from batch_img.const import PKG_NAME, TS_FORMAT
1314
from batch_img.resize import Resize
1415
from batch_img.rotate import Rotate
@@ -96,8 +97,22 @@ def border(options: dict) -> bool:
9697
"""
9798
logger.info(f"{json.dumps(options, indent=2)}")
9899
Main.init_log_file()
99-
# To-do
100-
return True
100+
in_path = Path(options["src_path"])
101+
bd_width = options.get("border_width")
102+
bd_color = options.get("border_color")
103+
output = options.get("output")
104+
if not bd_width or bd_width == 0:
105+
logger.warning(f"No add border due to bad {bd_width=}")
106+
return False
107+
if not output:
108+
output = Path(os.getcwd())
109+
else:
110+
output = Path(output)
111+
if in_path.is_file():
112+
ok, _ = Border.add_border_1_image(in_path, output, bd_width, bd_color)
113+
else:
114+
ok = Border.add_border_all_in_dir(in_path, output, bd_width, bd_color)
115+
return ok
101116

102117
@staticmethod
103118
def default_run(options: dict) -> bool:

batch_img/resize.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ def resize_an_image(in_path: Path, out_path: Path, length: int) -> tuple:
4242
exif_dict = None
4343
if "exif" in img.info:
4444
exif_dict = piexif.load(img.info["exif"])
45-
# Save to the same format: HEIF, JPEG, PNG, etc.
4645
if exif_dict:
4746
exif_bytes = piexif.dump(exif_dict)
4847
img.save(out_file, img.format, optimize=True, exif=exif_bytes)
@@ -69,17 +68,10 @@ def resize_all_progress_bar(in_path: Path, out_path: Path, length: int) -> bool:
6968
if not image_files:
7069
logger.error(f"No image files at {in_path}")
7170
return False
72-
tasks = [
73-
(
74-
f,
75-
out_path,
76-
length,
77-
)
78-
for f in image_files
79-
]
71+
tasks = [(f, out_path, length) for f in image_files]
8072
files_cnt = len(tasks)
8173

82-
logger.info(f"Resize {files_cnt} image files with multiprocess ...")
74+
logger.info(f"Resize {files_cnt} image files in multiprocess ...")
8375
success_cnt = Common.multiprocess_progress_bar(
8476
Resize.resize_an_image, "Resize image files", tasks
8577
)

batch_img/rotate.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,10 @@ def rotate_all_in_dir(in_path: Path, out_path: Path, angle_cw: int) -> bool:
7878
if not image_files:
7979
logger.error(f"No image files at {in_path}")
8080
return False
81-
tasks = [
82-
(
83-
f,
84-
out_path,
85-
angle_cw,
86-
)
87-
for f in image_files
88-
]
81+
tasks = [(f, out_path, angle_cw) for f in image_files]
8982
files_cnt = len(tasks)
9083

91-
logger.info(f"Rotate {files_cnt} image files with multiprocess ...")
84+
logger.info(f"Rotate {files_cnt} image files in multiprocess ...")
9285
success_cnt = Common.multiprocess_progress_bar(
9386
Rotate.rotate_1_image_file, "Rotate image files", tasks
9487
)

tests/test_border.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Test border.py
2+
pytest -sv tests/test_border.py
3+
Copyright © 2025 John Liu
4+
"""
5+
6+
from os.path import dirname
7+
from pathlib import Path
8+
from unittest.mock import patch
9+
10+
import pytest
11+
12+
from batch_img.border import Border
13+
14+
15+
@pytest.fixture(
16+
params=[
17+
(
18+
Path(f"{dirname(__file__)}/data/HEIC/IMG_0070.HEIC"),
19+
Path(f"{dirname(__file__)}/.out/"),
20+
9,
21+
"green",
22+
(True, Path(f"{dirname(__file__)}/.out/IMG_0070_bw9.HEIC")),
23+
),
24+
(
25+
Path(f"{dirname(__file__)}/data/PNG/Checkmark.PNG"),
26+
Path(f"{dirname(__file__)}/.out/"),
27+
18,
28+
"purple",
29+
(True, Path(f"{dirname(__file__)}/.out/Checkmark_bw18.PNG")),
30+
),
31+
(
32+
Path(f"{dirname(__file__)}/data/JPG/152.JPG"),
33+
Path(f"{dirname(__file__)}/.out/"),
34+
4,
35+
"#AABBCC",
36+
(True, Path(f"{dirname(__file__)}/.out/152_bw4.JPG")),
37+
),
38+
]
39+
)
40+
def data_add_border_1_image(request):
41+
return request.param
42+
43+
44+
def test_add_border_1_image(data_add_border_1_image):
45+
in_path, out_path, bd_width, bd_color, expected = data_add_border_1_image
46+
actual = Border.add_border_1_image(in_path, out_path, bd_width, bd_color)
47+
assert actual == expected
48+
49+
50+
@patch("PIL.Image.open")
51+
def test_error_add_border_1_image(mock_open):
52+
mock_open.side_effect = ValueError("VE")
53+
actual = Border.add_border_1_image(Path("img/file"), Path("out/path"), 9, "red")
54+
assert "img/file" in actual[1]
55+
56+
57+
@pytest.fixture(
58+
params=[
59+
(
60+
Path(f"{dirname(__file__)}/data/mixed"),
61+
Path(f"{dirname(__file__)}/.out/"),
62+
9,
63+
"#CCBBAA",
64+
True,
65+
)
66+
]
67+
)
68+
def data_add_border_all_in_dir(request):
69+
return request.param
70+
71+
72+
def test_add_border_all_in_dir(data_add_border_all_in_dir):
73+
in_path, out_path, bd_width, bd_color, expected = data_add_border_all_in_dir
74+
actual = Border.add_border_all_in_dir(in_path, out_path, bd_width, bd_color)
75+
assert actual == expected

tests/test_interface.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,21 @@ def test_border(mock_add_border, data_border):
3535
assert result.output == expected
3636

3737

38+
@pytest.fixture(params=["img/file -bw -9", "img/file --border_width 21"])
39+
def data_error_border(request):
40+
return request.param
41+
42+
43+
@patch("batch_img.main.Main.border")
44+
def test_error_border(mock_add_border, data_error_border):
45+
_input = data_error_border
46+
mock_add_border.return_value = True
47+
runner = CliRunner()
48+
result = runner.invoke(border, args=_input.split())
49+
print(result.output)
50+
assert result.exception
51+
52+
3853
@pytest.fixture(
3954
params=[
4055
("src_path --output out/dir", True, MSG_OK),

0 commit comments

Comments
 (0)