mkv-episode-matcher 0.1.5__py3-none-any.whl → 0.1.9__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.
Potentially problematic release.
This version of mkv-episode-matcher might be problematic. Click here for more details.
- mkv_episode_matcher/__init__.py +1 -1
- mkv_episode_matcher/__main__.py +2 -2
- mkv_episode_matcher/libraries/pgs2srt/.gitignore +2 -2
- mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/SubZero.py +295 -295
- mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/dictionaries/data.py +249 -249
- mkv_episode_matcher/libraries/pgs2srt/Libraries/SubZero/post_processing.py +215 -215
- mkv_episode_matcher/libraries/pgs2srt/README.md +26 -26
- mkv_episode_matcher/libraries/pgs2srt/imagemaker.py +87 -87
- mkv_episode_matcher/libraries/pgs2srt/pgs2srt.py +121 -121
- mkv_episode_matcher/libraries/pgs2srt/pgsreader.py +221 -221
- mkv_episode_matcher/libraries/pgs2srt/requirements.txt +4 -4
- mkv_episode_matcher/mkv_to_srt.py +174 -174
- mkv_episode_matcher/notebooks/get_subtitles_test.ipynb +252 -0
- mkv_episode_matcher/requirements.txt +6 -7
- mkv_episode_matcher/utils.py +5 -2
- {mkv_episode_matcher-0.1.5.dist-info → mkv_episode_matcher-0.1.9.dist-info}/METADATA +53 -37
- mkv_episode_matcher-0.1.9.dist-info/RECORD +25 -0
- {mkv_episode_matcher-0.1.5.dist-info → mkv_episode_matcher-0.1.9.dist-info}/WHEEL +2 -1
- mkv_episode_matcher-0.1.9.dist-info/top_level.txt +1 -0
- mkv_episode_matcher/libraries/pgs2srt/.git +0 -1
- mkv_episode_matcher-0.1.5.dist-info/RECORD +0 -24
- {mkv_episode_matcher-0.1.5.dist-info → mkv_episode_matcher-0.1.9.dist-info}/entry_points.txt +0 -0
|
@@ -1,87 +1,87 @@
|
|
|
1
|
-
import numpy as np
|
|
2
|
-
from PIL import Image
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
def read_rle_bytes(ods_bytes):
|
|
6
|
-
|
|
7
|
-
pixels = []
|
|
8
|
-
line_builder = []
|
|
9
|
-
|
|
10
|
-
i = 0
|
|
11
|
-
while i < len(ods_bytes):
|
|
12
|
-
if ods_bytes[i]:
|
|
13
|
-
incr = 1
|
|
14
|
-
color = ods_bytes[i]
|
|
15
|
-
length = 1
|
|
16
|
-
else:
|
|
17
|
-
check = ods_bytes[i + 1]
|
|
18
|
-
if check == 0:
|
|
19
|
-
incr = 2
|
|
20
|
-
color = 0
|
|
21
|
-
length = 0
|
|
22
|
-
pixels.append(line_builder)
|
|
23
|
-
line_builder = []
|
|
24
|
-
elif check < 64:
|
|
25
|
-
incr = 2
|
|
26
|
-
color = 0
|
|
27
|
-
length = check
|
|
28
|
-
elif check < 128:
|
|
29
|
-
incr = 3
|
|
30
|
-
color = 0
|
|
31
|
-
length = ((check - 64) << 8) + ods_bytes[i + 2]
|
|
32
|
-
elif check < 192:
|
|
33
|
-
incr = 3
|
|
34
|
-
color = ods_bytes[i + 2]
|
|
35
|
-
length = check - 128
|
|
36
|
-
else:
|
|
37
|
-
incr = 4
|
|
38
|
-
color = ods_bytes[i + 3]
|
|
39
|
-
length = ((check - 192) << 8) + ods_bytes[i + 2]
|
|
40
|
-
line_builder.extend([color] * length)
|
|
41
|
-
i += incr
|
|
42
|
-
|
|
43
|
-
if line_builder:
|
|
44
|
-
print(f'Probably an error; hanging pixels: {line_builder}')
|
|
45
|
-
|
|
46
|
-
return pixels
|
|
47
|
-
|
|
48
|
-
def ycbcr2rgb(ar):
|
|
49
|
-
xform = np.array([[1, 0, 1.402], [1, -0.34414, -.71414], [1, 1.772, 0]])
|
|
50
|
-
rgb = ar.astype(float)
|
|
51
|
-
# Subtracting by 128 the R and G channels
|
|
52
|
-
rgb[:, [1, 2]] -= 128
|
|
53
|
-
# .dot is multiplication of the matrices and xform.T is a transpose of the array axes
|
|
54
|
-
rgb = rgb.dot(xform.T)
|
|
55
|
-
# Makes any pixel value greater than 255 just be 255 (Max for RGB colorspace)
|
|
56
|
-
np.putmask(rgb, rgb > 255, 255)
|
|
57
|
-
# Sets any pixel value less than 0 to 0 (Min for RGB colorspace)
|
|
58
|
-
np.putmask(rgb, rgb < 0, 0)
|
|
59
|
-
return np.uint8(rgb)
|
|
60
|
-
|
|
61
|
-
def px_rgb_a(ods, pds, swap):
|
|
62
|
-
px = read_rle_bytes(ods.img_data)
|
|
63
|
-
px = np.array([[255] * (ods.width - len(l)) + l for l in px], dtype=np.uint8)
|
|
64
|
-
|
|
65
|
-
# Extract the YCbCrA palette data, swapping channels if requested.
|
|
66
|
-
if swap:
|
|
67
|
-
ycbcr = np.array([(entry.Y, entry.Cb, entry.Cr) for entry in pds.palette])
|
|
68
|
-
else:
|
|
69
|
-
ycbcr = np.array([(entry.Y, entry.Cr, entry.Cb) for entry in pds.palette])
|
|
70
|
-
try:
|
|
71
|
-
rgb = ycbcr2rgb(ycbcr)
|
|
72
|
-
except AttributeError:
|
|
73
|
-
print("Error: The image is not in YCbCr format.")
|
|
74
|
-
exit(1)
|
|
75
|
-
# Separate the Alpha channel from the YCbCr palette data
|
|
76
|
-
a = [entry.Alpha for entry in pds.palette]
|
|
77
|
-
a = np.array([[a[x] for x in l] for l in px], dtype=np.uint8)
|
|
78
|
-
|
|
79
|
-
return px, rgb, a
|
|
80
|
-
|
|
81
|
-
def make_image(ods, pds, swap=False):
|
|
82
|
-
px, rgb, a = px_rgb_a(ods, pds, swap)
|
|
83
|
-
alpha = Image.fromarray(a, mode='L')
|
|
84
|
-
img = Image.fromarray(px, mode='P')
|
|
85
|
-
img.putalpha(alpha)
|
|
86
|
-
img.putpalette(rgb)
|
|
87
|
-
return img
|
|
1
|
+
import numpy as np
|
|
2
|
+
from PIL import Image
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def read_rle_bytes(ods_bytes):
|
|
6
|
+
|
|
7
|
+
pixels = []
|
|
8
|
+
line_builder = []
|
|
9
|
+
|
|
10
|
+
i = 0
|
|
11
|
+
while i < len(ods_bytes):
|
|
12
|
+
if ods_bytes[i]:
|
|
13
|
+
incr = 1
|
|
14
|
+
color = ods_bytes[i]
|
|
15
|
+
length = 1
|
|
16
|
+
else:
|
|
17
|
+
check = ods_bytes[i + 1]
|
|
18
|
+
if check == 0:
|
|
19
|
+
incr = 2
|
|
20
|
+
color = 0
|
|
21
|
+
length = 0
|
|
22
|
+
pixels.append(line_builder)
|
|
23
|
+
line_builder = []
|
|
24
|
+
elif check < 64:
|
|
25
|
+
incr = 2
|
|
26
|
+
color = 0
|
|
27
|
+
length = check
|
|
28
|
+
elif check < 128:
|
|
29
|
+
incr = 3
|
|
30
|
+
color = 0
|
|
31
|
+
length = ((check - 64) << 8) + ods_bytes[i + 2]
|
|
32
|
+
elif check < 192:
|
|
33
|
+
incr = 3
|
|
34
|
+
color = ods_bytes[i + 2]
|
|
35
|
+
length = check - 128
|
|
36
|
+
else:
|
|
37
|
+
incr = 4
|
|
38
|
+
color = ods_bytes[i + 3]
|
|
39
|
+
length = ((check - 192) << 8) + ods_bytes[i + 2]
|
|
40
|
+
line_builder.extend([color] * length)
|
|
41
|
+
i += incr
|
|
42
|
+
|
|
43
|
+
if line_builder:
|
|
44
|
+
print(f'Probably an error; hanging pixels: {line_builder}')
|
|
45
|
+
|
|
46
|
+
return pixels
|
|
47
|
+
|
|
48
|
+
def ycbcr2rgb(ar):
|
|
49
|
+
xform = np.array([[1, 0, 1.402], [1, -0.34414, -.71414], [1, 1.772, 0]])
|
|
50
|
+
rgb = ar.astype(float)
|
|
51
|
+
# Subtracting by 128 the R and G channels
|
|
52
|
+
rgb[:, [1, 2]] -= 128
|
|
53
|
+
# .dot is multiplication of the matrices and xform.T is a transpose of the array axes
|
|
54
|
+
rgb = rgb.dot(xform.T)
|
|
55
|
+
# Makes any pixel value greater than 255 just be 255 (Max for RGB colorspace)
|
|
56
|
+
np.putmask(rgb, rgb > 255, 255)
|
|
57
|
+
# Sets any pixel value less than 0 to 0 (Min for RGB colorspace)
|
|
58
|
+
np.putmask(rgb, rgb < 0, 0)
|
|
59
|
+
return np.uint8(rgb)
|
|
60
|
+
|
|
61
|
+
def px_rgb_a(ods, pds, swap):
|
|
62
|
+
px = read_rle_bytes(ods.img_data)
|
|
63
|
+
px = np.array([[255] * (ods.width - len(l)) + l for l in px], dtype=np.uint8)
|
|
64
|
+
|
|
65
|
+
# Extract the YCbCrA palette data, swapping channels if requested.
|
|
66
|
+
if swap:
|
|
67
|
+
ycbcr = np.array([(entry.Y, entry.Cb, entry.Cr) for entry in pds.palette])
|
|
68
|
+
else:
|
|
69
|
+
ycbcr = np.array([(entry.Y, entry.Cr, entry.Cb) for entry in pds.palette])
|
|
70
|
+
try:
|
|
71
|
+
rgb = ycbcr2rgb(ycbcr)
|
|
72
|
+
except AttributeError:
|
|
73
|
+
print("Error: The image is not in YCbCr format.")
|
|
74
|
+
exit(1)
|
|
75
|
+
# Separate the Alpha channel from the YCbCr palette data
|
|
76
|
+
a = [entry.Alpha for entry in pds.palette]
|
|
77
|
+
a = np.array([[a[x] for x in l] for l in px], dtype=np.uint8)
|
|
78
|
+
|
|
79
|
+
return px, rgb, a
|
|
80
|
+
|
|
81
|
+
def make_image(ods, pds, swap=False):
|
|
82
|
+
px, rgb, a = px_rgb_a(ods, pds, swap)
|
|
83
|
+
alpha = Image.fromarray(a, mode='L')
|
|
84
|
+
img = Image.fromarray(px, mode='P')
|
|
85
|
+
img.putalpha(alpha)
|
|
86
|
+
img.putpalette(rgb)
|
|
87
|
+
return img
|
|
@@ -1,121 +1,121 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
|
|
3
|
-
import argparse
|
|
4
|
-
import re
|
|
5
|
-
from datetime import datetime, timedelta
|
|
6
|
-
|
|
7
|
-
import pytesseract
|
|
8
|
-
from imagemaker import make_image
|
|
9
|
-
from pgsreader import PGSReader
|
|
10
|
-
from PIL import Image, ImageOps
|
|
11
|
-
|
|
12
|
-
from Libraries.SubZero.post_processing import CommonFixes, FixOCR
|
|
13
|
-
|
|
14
|
-
parser = argparse.ArgumentParser(description='Convert PGS subtitles to SubRip format.')
|
|
15
|
-
|
|
16
|
-
parser.add_argument('input', type=str, help="The input file (a .sup file).")
|
|
17
|
-
parser.add_argument('--output', type=str, help="The output file (a .srt file).")
|
|
18
|
-
parser.add_argument('--oem', type=int, help="The OCR Engine Mode to use (Default: 1).", default=1, choices=range(4))
|
|
19
|
-
parser.add_argument('--language', type=str, help="The language to use (Default: eng).", default='eng')
|
|
20
|
-
parser.add_argument('--fix_common', help='Fixes common whitespace/punctuation issues.',
|
|
21
|
-
dest='fix_common', action='store_true')
|
|
22
|
-
parser.add_argument('--fix_common_ocr', help='Fixes common OCR issues for supported languages.',
|
|
23
|
-
dest='fix_ocr', action='store_true')
|
|
24
|
-
|
|
25
|
-
args = parser.parse_args()
|
|
26
|
-
|
|
27
|
-
assert args.input is not None
|
|
28
|
-
|
|
29
|
-
# Unescape escaped spaces
|
|
30
|
-
file = args.input.replace("\\ ", " ")
|
|
31
|
-
|
|
32
|
-
print(f"Parsing: {file}")
|
|
33
|
-
|
|
34
|
-
# Load a PGS/SUP file.
|
|
35
|
-
pgs = PGSReader(file)
|
|
36
|
-
|
|
37
|
-
# Set index
|
|
38
|
-
i = 0
|
|
39
|
-
|
|
40
|
-
# Complete subtitle track index
|
|
41
|
-
si = 0
|
|
42
|
-
|
|
43
|
-
tesseract_lang = args.language
|
|
44
|
-
tesseract_config = f"-c tessedit_char_blacklist=[] --psm 6 --oem {args.oem}"
|
|
45
|
-
|
|
46
|
-
# If an output file for the subrip output is provided, use that.
|
|
47
|
-
# Otherwise remove the ".sup" extension from the input and append
|
|
48
|
-
# ".srt".
|
|
49
|
-
output_file = args.output if args.output is not None else (args.input.replace('.sup', '') + '.srt')
|
|
50
|
-
|
|
51
|
-
# SubRip output
|
|
52
|
-
output = ""
|
|
53
|
-
|
|
54
|
-
fix_common = CommonFixes() if args.fix_common else None
|
|
55
|
-
fix_ocr = FixOCR(args.language) if args.fix_ocr else None
|
|
56
|
-
|
|
57
|
-
# Iterate the pgs generator
|
|
58
|
-
for ds in pgs.iter_displaysets():
|
|
59
|
-
try:
|
|
60
|
-
# If set has image, parse the image
|
|
61
|
-
if ds.has_image:
|
|
62
|
-
# Get Palette Display Segment
|
|
63
|
-
pds = ds.pds[0]
|
|
64
|
-
# Get Object Display Segment
|
|
65
|
-
ods = ds.ods[0]
|
|
66
|
-
|
|
67
|
-
if pds and ods:
|
|
68
|
-
# Create and show the bitmap image and convert it to RGBA
|
|
69
|
-
src = make_image(ods, pds).convert('RGBA')
|
|
70
|
-
|
|
71
|
-
# Create grayscale image with black background
|
|
72
|
-
img = Image.new("L", src.size, "BLACK")
|
|
73
|
-
# Paste the subtitle bitmap
|
|
74
|
-
img.paste(src, (0, 0), src)
|
|
75
|
-
# Invert images so the text is readable by Tesseract
|
|
76
|
-
img = ImageOps.invert(img)
|
|
77
|
-
|
|
78
|
-
# Parse the image with tesesract
|
|
79
|
-
text = pytesseract.image_to_string(img, lang=tesseract_lang, config=tesseract_config).strip()
|
|
80
|
-
|
|
81
|
-
# Replace "|" with "I"
|
|
82
|
-
# Works better than blacklisting "|" in Tesseract,
|
|
83
|
-
# which results in I becoming "!" "i" and "1"
|
|
84
|
-
text = re.sub(r'[|/\\]', 'I', text)
|
|
85
|
-
text = re.sub(r'[_]', 'L', text)
|
|
86
|
-
|
|
87
|
-
if args.fix_common:
|
|
88
|
-
text = fix_common.process(text)
|
|
89
|
-
if args.fix_ocr:
|
|
90
|
-
text = fix_ocr.modify(text)
|
|
91
|
-
|
|
92
|
-
start = datetime.fromtimestamp(ods.presentation_timestamp / 1000)
|
|
93
|
-
start = start + timedelta(hours=-1)
|
|
94
|
-
|
|
95
|
-
else:
|
|
96
|
-
# Get Presentation Composition Segment
|
|
97
|
-
pcs = ds.pcs[0]
|
|
98
|
-
|
|
99
|
-
if pcs:
|
|
100
|
-
end = datetime.fromtimestamp(pcs.presentation_timestamp / 1000)
|
|
101
|
-
end = end + timedelta(hours=-1)
|
|
102
|
-
|
|
103
|
-
if isinstance(start, datetime) and isinstance(end, datetime) and len(text):
|
|
104
|
-
si = si + 1
|
|
105
|
-
sub_output = str(si) + "\n"
|
|
106
|
-
sub_output += start.strftime("%H:%M:%S,%f")[0:12] + \
|
|
107
|
-
" --> " + end.strftime("%H:%M:%S,%f")[0:12] + "\n"
|
|
108
|
-
sub_output += text + "\n\n"
|
|
109
|
-
|
|
110
|
-
output += sub_output
|
|
111
|
-
start = end = text = None
|
|
112
|
-
i = i + 1
|
|
113
|
-
|
|
114
|
-
except Exception as e:
|
|
115
|
-
print(e)
|
|
116
|
-
exit(1)
|
|
117
|
-
|
|
118
|
-
f = open(output_file, "w")
|
|
119
|
-
f.write(output)
|
|
120
|
-
f.close()
|
|
121
|
-
print(f"Saved to: {output_file}")
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
import pytesseract
|
|
8
|
+
from imagemaker import make_image
|
|
9
|
+
from pgsreader import PGSReader
|
|
10
|
+
from PIL import Image, ImageOps
|
|
11
|
+
|
|
12
|
+
from Libraries.SubZero.post_processing import CommonFixes, FixOCR
|
|
13
|
+
|
|
14
|
+
parser = argparse.ArgumentParser(description='Convert PGS subtitles to SubRip format.')
|
|
15
|
+
|
|
16
|
+
parser.add_argument('input', type=str, help="The input file (a .sup file).")
|
|
17
|
+
parser.add_argument('--output', type=str, help="The output file (a .srt file).")
|
|
18
|
+
parser.add_argument('--oem', type=int, help="The OCR Engine Mode to use (Default: 1).", default=1, choices=range(4))
|
|
19
|
+
parser.add_argument('--language', type=str, help="The language to use (Default: eng).", default='eng')
|
|
20
|
+
parser.add_argument('--fix_common', help='Fixes common whitespace/punctuation issues.',
|
|
21
|
+
dest='fix_common', action='store_true')
|
|
22
|
+
parser.add_argument('--fix_common_ocr', help='Fixes common OCR issues for supported languages.',
|
|
23
|
+
dest='fix_ocr', action='store_true')
|
|
24
|
+
|
|
25
|
+
args = parser.parse_args()
|
|
26
|
+
|
|
27
|
+
assert args.input is not None
|
|
28
|
+
|
|
29
|
+
# Unescape escaped spaces
|
|
30
|
+
file = args.input.replace("\\ ", " ")
|
|
31
|
+
|
|
32
|
+
print(f"Parsing: {file}")
|
|
33
|
+
|
|
34
|
+
# Load a PGS/SUP file.
|
|
35
|
+
pgs = PGSReader(file)
|
|
36
|
+
|
|
37
|
+
# Set index
|
|
38
|
+
i = 0
|
|
39
|
+
|
|
40
|
+
# Complete subtitle track index
|
|
41
|
+
si = 0
|
|
42
|
+
|
|
43
|
+
tesseract_lang = args.language
|
|
44
|
+
tesseract_config = f"-c tessedit_char_blacklist=[] --psm 6 --oem {args.oem}"
|
|
45
|
+
|
|
46
|
+
# If an output file for the subrip output is provided, use that.
|
|
47
|
+
# Otherwise remove the ".sup" extension from the input and append
|
|
48
|
+
# ".srt".
|
|
49
|
+
output_file = args.output if args.output is not None else (args.input.replace('.sup', '') + '.srt')
|
|
50
|
+
|
|
51
|
+
# SubRip output
|
|
52
|
+
output = ""
|
|
53
|
+
|
|
54
|
+
fix_common = CommonFixes() if args.fix_common else None
|
|
55
|
+
fix_ocr = FixOCR(args.language) if args.fix_ocr else None
|
|
56
|
+
|
|
57
|
+
# Iterate the pgs generator
|
|
58
|
+
for ds in pgs.iter_displaysets():
|
|
59
|
+
try:
|
|
60
|
+
# If set has image, parse the image
|
|
61
|
+
if ds.has_image:
|
|
62
|
+
# Get Palette Display Segment
|
|
63
|
+
pds = ds.pds[0]
|
|
64
|
+
# Get Object Display Segment
|
|
65
|
+
ods = ds.ods[0]
|
|
66
|
+
|
|
67
|
+
if pds and ods:
|
|
68
|
+
# Create and show the bitmap image and convert it to RGBA
|
|
69
|
+
src = make_image(ods, pds).convert('RGBA')
|
|
70
|
+
|
|
71
|
+
# Create grayscale image with black background
|
|
72
|
+
img = Image.new("L", src.size, "BLACK")
|
|
73
|
+
# Paste the subtitle bitmap
|
|
74
|
+
img.paste(src, (0, 0), src)
|
|
75
|
+
# Invert images so the text is readable by Tesseract
|
|
76
|
+
img = ImageOps.invert(img)
|
|
77
|
+
|
|
78
|
+
# Parse the image with tesesract
|
|
79
|
+
text = pytesseract.image_to_string(img, lang=tesseract_lang, config=tesseract_config).strip()
|
|
80
|
+
|
|
81
|
+
# Replace "|" with "I"
|
|
82
|
+
# Works better than blacklisting "|" in Tesseract,
|
|
83
|
+
# which results in I becoming "!" "i" and "1"
|
|
84
|
+
text = re.sub(r'[|/\\]', 'I', text)
|
|
85
|
+
text = re.sub(r'[_]', 'L', text)
|
|
86
|
+
|
|
87
|
+
if args.fix_common:
|
|
88
|
+
text = fix_common.process(text)
|
|
89
|
+
if args.fix_ocr:
|
|
90
|
+
text = fix_ocr.modify(text)
|
|
91
|
+
|
|
92
|
+
start = datetime.fromtimestamp(ods.presentation_timestamp / 1000)
|
|
93
|
+
start = start + timedelta(hours=-1)
|
|
94
|
+
|
|
95
|
+
else:
|
|
96
|
+
# Get Presentation Composition Segment
|
|
97
|
+
pcs = ds.pcs[0]
|
|
98
|
+
|
|
99
|
+
if pcs:
|
|
100
|
+
end = datetime.fromtimestamp(pcs.presentation_timestamp / 1000)
|
|
101
|
+
end = end + timedelta(hours=-1)
|
|
102
|
+
|
|
103
|
+
if isinstance(start, datetime) and isinstance(end, datetime) and len(text):
|
|
104
|
+
si = si + 1
|
|
105
|
+
sub_output = str(si) + "\n"
|
|
106
|
+
sub_output += start.strftime("%H:%M:%S,%f")[0:12] + \
|
|
107
|
+
" --> " + end.strftime("%H:%M:%S,%f")[0:12] + "\n"
|
|
108
|
+
sub_output += text + "\n\n"
|
|
109
|
+
|
|
110
|
+
output += sub_output
|
|
111
|
+
start = end = text = None
|
|
112
|
+
i = i + 1
|
|
113
|
+
|
|
114
|
+
except Exception as e:
|
|
115
|
+
print(e)
|
|
116
|
+
exit(1)
|
|
117
|
+
|
|
118
|
+
f = open(output_file, "w")
|
|
119
|
+
f.write(output)
|
|
120
|
+
f.close()
|
|
121
|
+
print(f"Saved to: {output_file}")
|