BeatPrints 1.0.0__tar.gz
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.
- beatprints-1.0.0/BeatPrints/__init__.py +4 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSans/NotoSans-Bold.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSans/NotoSans-Light.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSans/NotoSans-Regular.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansJP/NotoSansJP-Bold.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansJP/NotoSansJP-Light.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansJP/NotoSansJP-Regular.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansKR/NotoSansKR-Bold.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansKR/NotoSansKR-Light.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansKR/NotoSansKR-Regular.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansSC/NotoSansSC-Bold.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansSC/NotoSansSC-Light.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansSC/NotoSansSC-Regular.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansTC/NotoSansTC-Bold.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansTC/NotoSansTC-Light.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/NotoSansTC/NotoSansTC-Regular.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/Oswald/Oswald-Bold.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/Oswald/Oswald-Light.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/fonts/Oswald/Oswald-Regular.ttf +0 -0
- beatprints-1.0.0/BeatPrints/assets/templates/banner_dark.png +0 -0
- beatprints-1.0.0/BeatPrints/assets/templates/banner_light.png +0 -0
- beatprints-1.0.0/BeatPrints/consts.py +51 -0
- beatprints-1.0.0/BeatPrints/errors.py +79 -0
- beatprints-1.0.0/BeatPrints/image.py +210 -0
- beatprints-1.0.0/BeatPrints/lyrics.py +97 -0
- beatprints-1.0.0/BeatPrints/poster.py +213 -0
- beatprints-1.0.0/BeatPrints/spotify.py +218 -0
- beatprints-1.0.0/BeatPrints/utils.py +96 -0
- beatprints-1.0.0/BeatPrints/write.py +258 -0
- beatprints-1.0.0/LICENSE +437 -0
- beatprints-1.0.0/PKG-INFO +234 -0
- beatprints-1.0.0/README.md +207 -0
- beatprints-1.0.0/pyproject.toml +32 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: consts.py
|
|
3
|
+
|
|
4
|
+
Contains all the necessary co-ordinates that'll
|
|
5
|
+
be needed to place the texts/images on the poster.
|
|
6
|
+
|
|
7
|
+
Prefixes
|
|
8
|
+
---------
|
|
9
|
+
S = Size
|
|
10
|
+
C = Cords
|
|
11
|
+
P = Path
|
|
12
|
+
CL = Color
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
MAX_ROWS = 5
|
|
18
|
+
MAX_WIDTH = 1020
|
|
19
|
+
|
|
20
|
+
S_TRACKS = 35
|
|
21
|
+
S_SPACING = 45
|
|
22
|
+
S_COVER = (1020, 1020)
|
|
23
|
+
S_SPOTIFY_CODE = (330, 85)
|
|
24
|
+
S_HEADING = 80
|
|
25
|
+
S_ARTIST = 60
|
|
26
|
+
S_DURATION = 45
|
|
27
|
+
S_LYRICS = 42
|
|
28
|
+
S_LABEL = 30
|
|
29
|
+
|
|
30
|
+
C_COVER = (60, 60)
|
|
31
|
+
C_HEADING = (60, 1275)
|
|
32
|
+
C_ARTIST = (60, 1350)
|
|
33
|
+
C_LYRICS = (60, 1395)
|
|
34
|
+
C_TRACKS = (60, 1390)
|
|
35
|
+
C_LABEL = (1080, 1615)
|
|
36
|
+
|
|
37
|
+
C_DURATION = (1080, 1275)
|
|
38
|
+
C_PALETTE = (60, 1120)
|
|
39
|
+
C_ACCENT = (0, 1720, 1140, 1740)
|
|
40
|
+
C_SPOTIFY_CODE = (45, 1610)
|
|
41
|
+
|
|
42
|
+
CL_FONT_DARK_MODE = (193, 189, 178)
|
|
43
|
+
CL_FONT_LIGHT_MODE = (50, 47, 48)
|
|
44
|
+
|
|
45
|
+
CL_WHITE = (255, 255, 255, 255)
|
|
46
|
+
CL_TRANSPARENT = (0, 0, 0, 0)
|
|
47
|
+
|
|
48
|
+
P_FULLPATH = os.path.join(os.path.dirname(__file__))
|
|
49
|
+
P_ASSETS = os.path.join(P_FULLPATH, "assets")
|
|
50
|
+
P_FONTS = os.path.join(P_ASSETS, "fonts")
|
|
51
|
+
P_TEMPLATES = os.path.join(P_ASSETS, "templates")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: errors.py
|
|
3
|
+
|
|
4
|
+
Handles custom exceptions for error handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
class NoMatchingTrackFound(Exception):
|
|
8
|
+
"""
|
|
9
|
+
Exception raised when no song matching the specified query is found.
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self, message="No track was found matching the query."):
|
|
12
|
+
self.message = message
|
|
13
|
+
super().__init__(self.message)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NoMatchingAlbumFound(Exception):
|
|
17
|
+
"""
|
|
18
|
+
Exception raised when no album matching the specified query is found.
|
|
19
|
+
"""
|
|
20
|
+
def __init__(self, message="No album was found matching the query."):
|
|
21
|
+
self.message = message
|
|
22
|
+
super().__init__(self.message)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NoLyricsAvailable(Exception):
|
|
26
|
+
"""
|
|
27
|
+
Exception raised when no lyrics are available for the specified song.
|
|
28
|
+
"""
|
|
29
|
+
def __init__(self, message="No lyrics were found for the specified song"):
|
|
30
|
+
self.message = message
|
|
31
|
+
super().__init__(self.message)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InvalidSearchLimit(Exception):
|
|
35
|
+
"""
|
|
36
|
+
Exception raised when an invalid search limit is specified for tracks or albums.
|
|
37
|
+
"""
|
|
38
|
+
def __init__(self, message="The search limit must be set to at least 1."):
|
|
39
|
+
self.message = message
|
|
40
|
+
super().__init__(self.message)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InvalidSelectionError(Exception):
|
|
44
|
+
"""
|
|
45
|
+
Exception raised when an invalid selection range is provided for lyrics.
|
|
46
|
+
"""
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
message="Invalid range format. Please use 'start-end', ensuring start is less than end.",
|
|
50
|
+
):
|
|
51
|
+
self.message = message
|
|
52
|
+
super().__init__(self.message)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LineLimitExceededError(Exception):
|
|
56
|
+
"""
|
|
57
|
+
Exception raised when the selection in lyrics contains more or fewer than 4 lines.
|
|
58
|
+
"""
|
|
59
|
+
def __init__(self, message="Exactly 4 lines must be selected, no more, no less."):
|
|
60
|
+
self.message = message
|
|
61
|
+
super().__init__(self.message)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class InvalidFormatError(Exception):
|
|
65
|
+
"""
|
|
66
|
+
Exception raised when the format of the lyrics selection is invalid.
|
|
67
|
+
"""
|
|
68
|
+
def __init__(self, message="Use format 'x-y' where x and y are numbers."):
|
|
69
|
+
self.message = message
|
|
70
|
+
super().__init__(self.message)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class PathNotFoundError(Exception):
|
|
74
|
+
"""
|
|
75
|
+
Exception raised when the specified path for saving images cannot be found.
|
|
76
|
+
"""
|
|
77
|
+
def __init__(self, message="The specified path for saving images could not be found."):
|
|
78
|
+
self.message = message
|
|
79
|
+
super().__init__(self.message)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: image.py
|
|
3
|
+
|
|
4
|
+
Provides essential image functions to generate posters.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import random
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from io import BytesIO
|
|
11
|
+
from typing import List, Tuple, Optional
|
|
12
|
+
|
|
13
|
+
from Pylette import extract_colors
|
|
14
|
+
from PIL import Image, ImageDraw, ImageEnhance
|
|
15
|
+
|
|
16
|
+
from .consts import *
|
|
17
|
+
from .errors import PathNotFoundError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_palette(image: Image.Image) -> List[Tuple]:
|
|
21
|
+
"""
|
|
22
|
+
Extracts a color palette from an image.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
image (Image.Image): The image from which the color palette will be extracted.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
List[Tuple]: A list of RGB tuples representing the dominant colors in the image.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
# Save the image to an in-memory byte stream in PNG format
|
|
32
|
+
with BytesIO() as byte_stream:
|
|
33
|
+
image.save(byte_stream, format="PNG")
|
|
34
|
+
|
|
35
|
+
# Get the byte data
|
|
36
|
+
img_bytes = byte_stream.getvalue()
|
|
37
|
+
|
|
38
|
+
# Extract the color palette
|
|
39
|
+
colors = extract_colors(image=img_bytes, palette_size=6, sort_mode="luminance")
|
|
40
|
+
|
|
41
|
+
# Return the color palette
|
|
42
|
+
return [tuple(color.rgb) for color in colors]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def draw_palette(
|
|
46
|
+
draw: ImageDraw.ImageDraw, image: Image.Image, accent: bool = False
|
|
47
|
+
) -> None:
|
|
48
|
+
"""
|
|
49
|
+
Draws a color palette on the given image using Pillow.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
draw (ImageDraw.ImageDraw): The drawing context for the image.
|
|
53
|
+
image (Image.Image): The image on which to draw the palette.
|
|
54
|
+
accent (bool): If True, highlights an accent color in the palette.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
palette = get_palette(image)
|
|
58
|
+
|
|
59
|
+
# Draw the palette as rectangles on the image
|
|
60
|
+
for i in range(6):
|
|
61
|
+
|
|
62
|
+
# Calculate the position and size for each color rectangle
|
|
63
|
+
x, y = C_PALETTE
|
|
64
|
+
start, end = 170 * i, 170 * (i + 1)
|
|
65
|
+
|
|
66
|
+
# Draw the color rectangle
|
|
67
|
+
draw.rectangle(((x + start, y), (x + end, 1160)), fill=palette[i])
|
|
68
|
+
|
|
69
|
+
# Optionally draw the accent color at the bottom
|
|
70
|
+
if accent:
|
|
71
|
+
draw.rectangle(C_ACCENT, fill=palette[random.randint(0, 2)])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def crop(path: str) -> Image.Image:
|
|
75
|
+
"""
|
|
76
|
+
Crops an image to a square (1:1) aspect ratio.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
path (str): The path to the image file.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
Image.Image: The cropped image with a 1:1 aspect ratio.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
PathNotFoundError: If the path to the image file doesn't exists
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def chop(image: Image.Image) -> Image.Image:
|
|
89
|
+
# Get the dimensions of the image
|
|
90
|
+
width, height = image.size
|
|
91
|
+
min_dim = min(width, height)
|
|
92
|
+
|
|
93
|
+
# Calculate the cropping box to make the image square
|
|
94
|
+
left = (width - min_dim) / 2
|
|
95
|
+
top = (height - min_dim) / 2
|
|
96
|
+
right = (width + min_dim) / 2
|
|
97
|
+
bottom = (height + min_dim) / 2
|
|
98
|
+
|
|
99
|
+
# Crop and return the image
|
|
100
|
+
return image.crop((left, top, right, bottom))
|
|
101
|
+
|
|
102
|
+
if os.path.exists(path):
|
|
103
|
+
with Image.open(path) as img:
|
|
104
|
+
return chop(img)
|
|
105
|
+
else:
|
|
106
|
+
raise PathNotFoundError
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def magicify(image: Image.Image) -> Image.Image:
|
|
110
|
+
"""
|
|
111
|
+
Adjusts the brightness and contrast of the image.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
image (Image.Image): The image to be adjusted.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Image.Image: The adjusted image with modified brightness and contrast.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
# Reduce brightness by 10%
|
|
121
|
+
brightness = ImageEnhance.Brightness(image)
|
|
122
|
+
image_brightness = brightness.enhance(0.9)
|
|
123
|
+
|
|
124
|
+
# Reduce contrast by 20%
|
|
125
|
+
contrast = ImageEnhance.Contrast(image_brightness)
|
|
126
|
+
image_contrast = contrast.enhance(0.8)
|
|
127
|
+
|
|
128
|
+
return image_contrast
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def scannable(id: str, darktheme: bool = False, is_album: bool = False) -> Image.Image:
|
|
132
|
+
"""
|
|
133
|
+
Generates a Spotify scannable code (QR code) for a track or album.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
id (str): The ID of the track or album on Spotify.
|
|
137
|
+
darktheme (bool): Flag to indicate whether to use a dark theme (default is False).
|
|
138
|
+
is_album (bool): Flag to indicate if the ID is for an album (default is False).
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Image.Image: A resized and transparent Spotify scannable code image.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
# Set the color based on the theme
|
|
145
|
+
color = CL_FONT_DARK_MODE if darktheme else CL_FONT_LIGHT_MODE
|
|
146
|
+
|
|
147
|
+
# Build the URL for the Spotify scannable code
|
|
148
|
+
item_type = "album" if is_album else "track"
|
|
149
|
+
scan_url = f"https://scannables.scdn.co/uri/plain/png/101010/white/1280/spotify:{item_type}:{id}"
|
|
150
|
+
|
|
151
|
+
# Download the scannable image data
|
|
152
|
+
data = requests.get(scan_url).content
|
|
153
|
+
img_bytes = BytesIO(data)
|
|
154
|
+
|
|
155
|
+
# Process the image to make white pixels transparent
|
|
156
|
+
with Image.open(img_bytes) as scan_code:
|
|
157
|
+
|
|
158
|
+
# Convert the image to RGBA mode to support transparency
|
|
159
|
+
scan_code = scan_code.convert("RGBA")
|
|
160
|
+
width, height = scan_code.size
|
|
161
|
+
pixels = scan_code.load()
|
|
162
|
+
|
|
163
|
+
# Replace white pixels with transparency, others with the selected color
|
|
164
|
+
for x in range(width):
|
|
165
|
+
for y in range(height):
|
|
166
|
+
if pixels is not None:
|
|
167
|
+
pixels[x, y] = CL_TRANSPARENT if pixels[x, y] != CL_WHITE else color
|
|
168
|
+
|
|
169
|
+
# Resize the image to specific size
|
|
170
|
+
resized = scan_code.resize(S_SPOTIFY_CODE, Image.Resampling.BICUBIC)
|
|
171
|
+
|
|
172
|
+
return resized
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cover(image_url: str, path: Optional[str]) -> Image.Image:
|
|
176
|
+
"""
|
|
177
|
+
Fetches and processes an image from a URL or a local path.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
image_url (str): The URL to fetch the image from.
|
|
181
|
+
path (Optional[str]): Local path to the custom image.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Image.Image: The processed image.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
# Load image from a path or fetch from URL
|
|
188
|
+
img = crop(path) if path else Image.open(BytesIO(requests.get(image_url).content))
|
|
189
|
+
|
|
190
|
+
# Apply magic filter and resize
|
|
191
|
+
return magicify(img.resize(S_COVER))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_theme(dark_theme: bool):
|
|
195
|
+
"""
|
|
196
|
+
Determines theme-related properties.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
dark_theme (bool): Whether to use a dark theme.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Tuple containing theme color, template path
|
|
203
|
+
"""
|
|
204
|
+
color, template = (
|
|
205
|
+
(CL_FONT_DARK_MODE, "banner_dark.png")
|
|
206
|
+
if dark_theme
|
|
207
|
+
else (CL_FONT_LIGHT_MODE, "banner_light.png")
|
|
208
|
+
)
|
|
209
|
+
template_path = os.path.join(P_TEMPLATES, template)
|
|
210
|
+
return color, template_path
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module: lyrics.py
|
|
3
|
+
|
|
4
|
+
Provide lyrics from LRClib API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from lrclib import LrcLibAPI
|
|
9
|
+
|
|
10
|
+
from .spotify import TrackMetadata
|
|
11
|
+
from .errors import (
|
|
12
|
+
NoLyricsAvailable,
|
|
13
|
+
InvalidFormatError,
|
|
14
|
+
InvalidSelectionError,
|
|
15
|
+
LineLimitExceededError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Lyrics:
|
|
20
|
+
"""
|
|
21
|
+
This class helps to retrieve lyrics through LRClib API.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def get_lyrics(self, metadata: TrackMetadata) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Retrieve lyrics from LRClib.net for a given track and artist.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
metadata (TrackMetadata): Instance that holds the metadata about a track.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
str: The lyrics in plain text if found.
|
|
33
|
+
|
|
34
|
+
Raises:
|
|
35
|
+
NoLyricsAvailable: If no lyrics are found for the given track and artist.
|
|
36
|
+
"""
|
|
37
|
+
user_agent = (
|
|
38
|
+
"Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Prepare the API
|
|
42
|
+
api = LrcLibAPI(user_agent=user_agent)
|
|
43
|
+
id = api.search_lyrics(track_name=metadata.name, artist_name=metadata.artist)
|
|
44
|
+
|
|
45
|
+
# Check if lyrics are available
|
|
46
|
+
if len(id) != 0:
|
|
47
|
+
lyrics = api.get_lyrics_by_id(id[0].id)
|
|
48
|
+
return str(lyrics.plain_lyrics)
|
|
49
|
+
else:
|
|
50
|
+
raise NoLyricsAvailable
|
|
51
|
+
|
|
52
|
+
def select_lines(self, lyrics: str, selection: str) -> str:
|
|
53
|
+
"""
|
|
54
|
+
Selects a range of lines from the song lyrics.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
lyrics (str): The lyrics of the song.
|
|
58
|
+
selection (str): Line range to extract in "start-end" format.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
str: The selected lines.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
InvalidFormatError: If the selection format is invalid.
|
|
65
|
+
InvalidSelectionError: If the selection range is invalid.
|
|
66
|
+
LineLimitExceededError: If the selected range doesn't contain exactly 4 lines.
|
|
67
|
+
"""
|
|
68
|
+
lines = [line for line in lyrics.split("\n")]
|
|
69
|
+
line_count = len(lines)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
pattern = r"^\d+-\d+$"
|
|
73
|
+
|
|
74
|
+
if not re.match(pattern, selection):
|
|
75
|
+
raise InvalidFormatError
|
|
76
|
+
|
|
77
|
+
selected = [int(num) for num in selection.split("-")]
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
len(selected) != 2
|
|
81
|
+
or selected[0] >= selected[1]
|
|
82
|
+
or selected[0] <= 0
|
|
83
|
+
or selected[1] > line_count
|
|
84
|
+
):
|
|
85
|
+
raise InvalidSelectionError
|
|
86
|
+
|
|
87
|
+
extracted = lines[selected[0] - 1 : selected[1]]
|
|
88
|
+
selected_lines = [line for line in extracted if line != ""]
|
|
89
|
+
|
|
90
|
+
if len(selected_lines) != 4:
|
|
91
|
+
raise LineLimitExceededError
|
|
92
|
+
|
|
93
|
+
quatrain = "\n".join(selected_lines).strip()
|
|
94
|
+
return quatrain
|
|
95
|
+
|
|
96
|
+
except Exception as e:
|
|
97
|
+
raise e
|