assignmentwriter 0.1.0__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.
- assignmentwriter/__init__.py +33 -0
- assignmentwriter/__main__.py +263 -0
- assignmentwriter/layouts.py +119 -0
- assignmentwriter/modules.py +447 -0
- assignmentwriter/res/fonts/AdineKirnberg.ttf +0 -0
- assignmentwriter/res/fonts/AlanisHand.ttf +0 -0
- assignmentwriter/res/fonts/Architex.ttf +0 -0
- assignmentwriter/res/fonts/GreatVibes-Regular.ttf +0 -0
- assignmentwriter/res/fonts/Hand_Note.otf +0 -0
- assignmentwriter/res/fonts/HomemadeApple.ttf +0 -0
- assignmentwriter/res/fonts/HoneyScript.ttf +0 -0
- assignmentwriter/res/fonts/JottFLF.ttf +0 -0
- assignmentwriter/res/fonts/NOTICE.md +26 -0
- assignmentwriter/res/fonts/Note_this.ttf +0 -0
- assignmentwriter/res/fonts/S_Hand.ttf +0 -0
- assignmentwriter/res/fonts/caveat.ttf +0 -0
- assignmentwriter/res/fonts/pleasewritemeasong.ttf +0 -0
- assignmentwriter/res/papers/a4paper.jpg +0 -0
- assignmentwriter/res/papers/cream.jpg +0 -0
- assignmentwriter/res/papers/cyan.jpg +0 -0
- assignmentwriter/res/papers/lightblue.jpg +0 -0
- assignmentwriter/res/papers/lightgreen.jpg +0 -0
- assignmentwriter/res/papers/lime.jpg +0 -0
- assignmentwriter/res/papers/lined.png +0 -0
- assignmentwriter/res/papers/orange.jpg +0 -0
- assignmentwriter/res/papers/pink.jpg +0 -0
- assignmentwriter/res/papers/rough.jpg +0 -0
- assignmentwriter/res/papers/spiral.png +0 -0
- assignmentwriter/res/papers/wrinkled.jpg +0 -0
- assignmentwriter-0.1.0.dist-info/METADATA +266 -0
- assignmentwriter-0.1.0.dist-info/RECORD +35 -0
- assignmentwriter-0.1.0.dist-info/WHEEL +5 -0
- assignmentwriter-0.1.0.dist-info/entry_points.txt +2 -0
- assignmentwriter-0.1.0.dist-info/licenses/LICENSE +21 -0
- assignmentwriter-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from .modules import (
|
|
4
|
+
INKS,
|
|
5
|
+
FONTS,
|
|
6
|
+
PAPERS,
|
|
7
|
+
LAYOUTS,
|
|
8
|
+
Layout,
|
|
9
|
+
Assignment,
|
|
10
|
+
AssignmentResult,
|
|
11
|
+
AssignmentConfigOptions,
|
|
12
|
+
available_fonts,
|
|
13
|
+
available_papers,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
__version__ = version("assignmentwriter")
|
|
18
|
+
except PackageNotFoundError:
|
|
19
|
+
__version__ = "unknown"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"INKS",
|
|
23
|
+
"FONTS",
|
|
24
|
+
"PAPERS",
|
|
25
|
+
"LAYOUTS",
|
|
26
|
+
"Layout",
|
|
27
|
+
"Assignment",
|
|
28
|
+
"AssignmentResult",
|
|
29
|
+
"AssignmentConfigOptions",
|
|
30
|
+
"available_fonts",
|
|
31
|
+
"available_papers",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from . import __version__
|
|
5
|
+
from .modules import (
|
|
6
|
+
INKS,
|
|
7
|
+
FONTS,
|
|
8
|
+
PAPERS,
|
|
9
|
+
DEFAULT_FILENAME,
|
|
10
|
+
DEFAULT_PDF_FILENAME,
|
|
11
|
+
Assignment,
|
|
12
|
+
AssignmentConfigOptions,
|
|
13
|
+
resolve_ink,
|
|
14
|
+
resolve_font,
|
|
15
|
+
resolve_paper,
|
|
16
|
+
read_text_file,
|
|
17
|
+
available_fonts,
|
|
18
|
+
available_papers,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.markup import escape
|
|
24
|
+
from rich.prompt import Prompt
|
|
25
|
+
from rich.table import Table
|
|
26
|
+
except ImportError:
|
|
27
|
+
Console = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _use_rich(as_text: bool) -> bool:
|
|
31
|
+
return Console is not None and not as_text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _say(paths: list, as_text: bool) -> None:
|
|
35
|
+
for path in paths:
|
|
36
|
+
if _use_rich(as_text):
|
|
37
|
+
Console().print(f"[bold green]Saved:[/bold green] {escape(path)}")
|
|
38
|
+
else:
|
|
39
|
+
print(path)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fail(message: str) -> int:
|
|
43
|
+
if Console is not None:
|
|
44
|
+
Console(stderr=True).print(f"[bold red]Error:[/bold red] {escape(message)}")
|
|
45
|
+
else:
|
|
46
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
47
|
+
return 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ask(label, default=None, validate=None, choices=None, as_text=False):
|
|
51
|
+
"""Ask a question until the answer is accepted. Raises EOFError if input ends."""
|
|
52
|
+
while True:
|
|
53
|
+
if _use_rich(as_text):
|
|
54
|
+
options = {}
|
|
55
|
+
if default is not None:
|
|
56
|
+
options["default"] = default
|
|
57
|
+
hint = f" ({'/'.join(choices)})" if choices else ""
|
|
58
|
+
answer = Prompt.ask(f"{label}{hint}", **options)
|
|
59
|
+
else:
|
|
60
|
+
hint = f" [{'/'.join(choices)}]" if choices else ""
|
|
61
|
+
shown = f" ({default})" if default is not None else ""
|
|
62
|
+
answer = input(f"{label}{hint}{shown}: ")
|
|
63
|
+
|
|
64
|
+
answer = answer.strip()
|
|
65
|
+
if not answer:
|
|
66
|
+
if default is None:
|
|
67
|
+
continue
|
|
68
|
+
answer = default
|
|
69
|
+
if validate is None:
|
|
70
|
+
return answer
|
|
71
|
+
try:
|
|
72
|
+
return validate(answer)
|
|
73
|
+
except ValueError as error:
|
|
74
|
+
_fail(str(error))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _create(text, paper, font, ink, keep_line_breaks, save, as_text, pdf=False) -> int:
|
|
78
|
+
try:
|
|
79
|
+
config = AssignmentConfigOptions(
|
|
80
|
+
Text=text,
|
|
81
|
+
Paper=paper,
|
|
82
|
+
Font=font,
|
|
83
|
+
Ink=ink,
|
|
84
|
+
KeepLineBreaks=keep_line_breaks,
|
|
85
|
+
)
|
|
86
|
+
except ValueError as error:
|
|
87
|
+
return _fail(str(error))
|
|
88
|
+
|
|
89
|
+
result = Assignment(config).create()
|
|
90
|
+
if not result:
|
|
91
|
+
return _fail("Could not create the assignment pages")
|
|
92
|
+
|
|
93
|
+
if pdf:
|
|
94
|
+
path = result.save_pdf(save)
|
|
95
|
+
paths = [path] if path else None
|
|
96
|
+
else:
|
|
97
|
+
paths = result.save(save)
|
|
98
|
+
if not paths:
|
|
99
|
+
return _fail("Could not save the assignment pages")
|
|
100
|
+
|
|
101
|
+
_say(paths, as_text)
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _interactive(args) -> int:
|
|
106
|
+
try:
|
|
107
|
+
text = _ask("Text to write", as_text=args.as_text)
|
|
108
|
+
paper = _ask(
|
|
109
|
+
"Paper", args.paper, resolve_paper, available_papers(), args.as_text
|
|
110
|
+
)
|
|
111
|
+
font = _ask("Font", args.font, resolve_font, available_fonts(), args.as_text)
|
|
112
|
+
ink = _ask("Ink", args.ink, resolve_ink, list(INKS), args.as_text)
|
|
113
|
+
default_name = DEFAULT_PDF_FILENAME if args.pdf else DEFAULT_FILENAME
|
|
114
|
+
save = _ask("Save as", args.save or default_name, as_text=args.as_text)
|
|
115
|
+
except (EOFError, KeyboardInterrupt):
|
|
116
|
+
print(file=sys.stderr)
|
|
117
|
+
return 1
|
|
118
|
+
return _create(
|
|
119
|
+
text, paper, font, ink, args.keep_line_breaks, save, args.as_text, args.pdf
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _list(argv) -> int:
|
|
124
|
+
parser = argparse.ArgumentParser(
|
|
125
|
+
prog="assignmentwriter list",
|
|
126
|
+
description="Show the papers, fonts and inks that can be used.",
|
|
127
|
+
)
|
|
128
|
+
parser.add_argument(
|
|
129
|
+
"--as-text",
|
|
130
|
+
action="store_true",
|
|
131
|
+
help="plain line-by-line output instead of tables",
|
|
132
|
+
)
|
|
133
|
+
args = parser.parse_args(argv)
|
|
134
|
+
fonts = available_fonts()
|
|
135
|
+
papers = available_papers()
|
|
136
|
+
|
|
137
|
+
if _use_rich(args.as_text):
|
|
138
|
+
console = Console()
|
|
139
|
+
|
|
140
|
+
table = Table(title="Papers")
|
|
141
|
+
table.add_column("Name", style="cyan")
|
|
142
|
+
for name in papers:
|
|
143
|
+
table.add_row(name)
|
|
144
|
+
console.print(table)
|
|
145
|
+
|
|
146
|
+
table = Table(title="Fonts")
|
|
147
|
+
table.add_column("No.", justify="right")
|
|
148
|
+
table.add_column("Name", style="cyan")
|
|
149
|
+
for number, name in enumerate(FONTS, start=1):
|
|
150
|
+
if name in fonts:
|
|
151
|
+
table.add_row(str(number), name)
|
|
152
|
+
console.print(table)
|
|
153
|
+
|
|
154
|
+
table = Table(title="Inks")
|
|
155
|
+
table.add_column("Name", style="cyan")
|
|
156
|
+
table.add_column("RGB")
|
|
157
|
+
for name, rgb in INKS.items():
|
|
158
|
+
table.add_row(name, ", ".join(str(value) for value in rgb))
|
|
159
|
+
console.print(table)
|
|
160
|
+
else:
|
|
161
|
+
for name in papers:
|
|
162
|
+
print(f"paper\t{name}")
|
|
163
|
+
for name in fonts:
|
|
164
|
+
print(f"font\t{name}")
|
|
165
|
+
for name in INKS:
|
|
166
|
+
print(f"ink\t{name}")
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
171
|
+
parser = argparse.ArgumentParser(
|
|
172
|
+
prog="assignmentwriter",
|
|
173
|
+
description=(
|
|
174
|
+
"Write an assignment on paper in your choice of handwriting and ink. "
|
|
175
|
+
"Long text continues on more pages."
|
|
176
|
+
),
|
|
177
|
+
epilog=(
|
|
178
|
+
"Run with no arguments for an interactive prompt, or "
|
|
179
|
+
"'assignmentwriter list' to see every paper, font and ink."
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
parser.add_argument("text", nargs="?", help="the text to write")
|
|
183
|
+
parser.add_argument("--file", help="read the text from this UTF-8 text file")
|
|
184
|
+
parser.add_argument(
|
|
185
|
+
"--paper",
|
|
186
|
+
default="a4",
|
|
187
|
+
help=f"{', '.join(PAPERS)} (default: a4)",
|
|
188
|
+
)
|
|
189
|
+
parser.add_argument(
|
|
190
|
+
"--font", default="caveat", help="font name or number (default: caveat)"
|
|
191
|
+
)
|
|
192
|
+
parser.add_argument(
|
|
193
|
+
"--ink",
|
|
194
|
+
default="blue",
|
|
195
|
+
help=f"{', '.join(INKS)} or a hex colour (default: blue)",
|
|
196
|
+
)
|
|
197
|
+
parser.add_argument(
|
|
198
|
+
"--save",
|
|
199
|
+
help=(
|
|
200
|
+
f"where to save (default: {DEFAULT_FILENAME}, or {DEFAULT_PDF_FILENAME} "
|
|
201
|
+
"with --pdf). Long text goes on several pages: name_1.png, name_2.png, ..."
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
parser.add_argument(
|
|
205
|
+
"--pdf",
|
|
206
|
+
action="store_true",
|
|
207
|
+
help="save all the pages together as one PDF instead of images",
|
|
208
|
+
)
|
|
209
|
+
parser.add_argument(
|
|
210
|
+
"--keep-line-breaks",
|
|
211
|
+
action="store_true",
|
|
212
|
+
help="start a new line wherever the text has one",
|
|
213
|
+
)
|
|
214
|
+
parser.add_argument(
|
|
215
|
+
"--as-text",
|
|
216
|
+
action="store_true",
|
|
217
|
+
help="plain output (just the saved path, no colors)",
|
|
218
|
+
)
|
|
219
|
+
parser.add_argument(
|
|
220
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
221
|
+
)
|
|
222
|
+
return parser
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main(argv=None) -> int:
|
|
226
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
227
|
+
|
|
228
|
+
# "list" is a command only when it is the first word, to write the word
|
|
229
|
+
# "list" on its own put it in a file and use --file
|
|
230
|
+
if argv and argv[0] == "list":
|
|
231
|
+
return _list(argv[1:])
|
|
232
|
+
|
|
233
|
+
parser = _build_parser()
|
|
234
|
+
args = parser.parse_args(argv)
|
|
235
|
+
|
|
236
|
+
text = args.text
|
|
237
|
+
if args.file is not None:
|
|
238
|
+
if text is not None:
|
|
239
|
+
parser.error("give the text directly or with --file, not both")
|
|
240
|
+
try:
|
|
241
|
+
text = read_text_file(args.file)
|
|
242
|
+
except ValueError as error:
|
|
243
|
+
return _fail(str(error))
|
|
244
|
+
elif text is None:
|
|
245
|
+
if sys.stdin is not None and not sys.stdin.isatty():
|
|
246
|
+
text = sys.stdin.read()
|
|
247
|
+
else:
|
|
248
|
+
return _interactive(args)
|
|
249
|
+
|
|
250
|
+
return _create(
|
|
251
|
+
text,
|
|
252
|
+
args.paper,
|
|
253
|
+
args.font,
|
|
254
|
+
args.ink,
|
|
255
|
+
args.keep_line_breaks,
|
|
256
|
+
args.save,
|
|
257
|
+
args.as_text,
|
|
258
|
+
args.pdf,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
if __name__ == "__main__":
|
|
263
|
+
sys.exit(main())
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from typing import NamedTuple
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Layout(NamedTuple):
|
|
5
|
+
max_line_length: int
|
|
6
|
+
margin_left: int
|
|
7
|
+
margin_top: int
|
|
8
|
+
font_size: int
|
|
9
|
+
line_spacing: float
|
|
10
|
+
margin_bottom: int
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
COLORED = ("cream", "cyan", "lightblue", "lightgreen", "lime", "orange", "pink")
|
|
14
|
+
|
|
15
|
+
_A4 = {
|
|
16
|
+
"adinekirnberg": (76, 20, 20, 40, 10),
|
|
17
|
+
"alanishand": (54, 20, 20, 35, 13),
|
|
18
|
+
"architex": (46, 20, 20, 43, 13),
|
|
19
|
+
"caveat": (43, 20, 20, 40, 10),
|
|
20
|
+
"greatvibesregular": (62, 20, 20, 30, 10),
|
|
21
|
+
"hand_note": (56, 20, 20, 40, 12),
|
|
22
|
+
"homemadeapple": (42, 20, 20, 25, 6),
|
|
23
|
+
"honeyscript": (56, 20, 20, 45, 13),
|
|
24
|
+
"jottflf": (43, 20, 20, 35, 12),
|
|
25
|
+
"note_this": (49, 20, 20, 30, 12),
|
|
26
|
+
"pleasewritemeasong": (52, 20, 20, 35, 13),
|
|
27
|
+
"s_hand": (43, 20, 20, 50, 13),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_LINED = {
|
|
31
|
+
"adinekirnberg": (60, 370, 330, 115, 2),
|
|
32
|
+
"alanishand": (60, 370, 338, 80, 50),
|
|
33
|
+
"architex": (63, 370, 348, 80, 22),
|
|
34
|
+
"caveat": (52, 370, 333, 85, 22),
|
|
35
|
+
"greatvibesregular": (60, 370, 340, 75, 15.5),
|
|
36
|
+
"hand_note": (67, 370, 338, 85, 2.3),
|
|
37
|
+
"homemadeapple": (45, 370, 310, 60, -1),
|
|
38
|
+
"honeyscript": (83, 370, 338, 80, 9.5),
|
|
39
|
+
"jottflf": (50, 370, 340, 75, 22.5),
|
|
40
|
+
"note_this": (42, 370, 338, 85, 4),
|
|
41
|
+
"pleasewritemeasong": (60, 370, 340, 75, 29.3),
|
|
42
|
+
"s_hand": (55, 370, 323, 100, 41),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_ROUGH = {
|
|
46
|
+
"adinekirnberg": (67, 155, 125, 45, 2),
|
|
47
|
+
"alanishand": (63, 155, 135, 30, 19),
|
|
48
|
+
"architex": (64, 155, 135, 35, 6),
|
|
49
|
+
"caveat": (61, 155, 132, 30, 11),
|
|
50
|
+
"greatvibesregular": (68, 150, 135, 30, 5),
|
|
51
|
+
"hand_note": (80, 155, 135, 30, 4),
|
|
52
|
+
"homemadeapple": (74, 155, 138, 15, 13),
|
|
53
|
+
"honeyscript": (88, 155, 126, 32, 3.3),
|
|
54
|
+
"jottflf": (60, 155, 138, 25, 13),
|
|
55
|
+
"note_this": (75, 155, 143, 20, 16),
|
|
56
|
+
"pleasewritemeasong": (73, 155, 138, 25, 16),
|
|
57
|
+
"s_hand": (58, 155, 132, 40, 15.8),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_SPIRAL = {
|
|
61
|
+
"adinekirnberg": (65, 440, 407, 115, -12),
|
|
62
|
+
"alanishand": (66, 440, 422, 70, 41.5),
|
|
63
|
+
"architex": (78, 440, 432, 70, 18),
|
|
64
|
+
"caveat": (64, 440, 417, 75, 17),
|
|
65
|
+
"greatvibesregular": (71, 440, 417, 75, 1.5),
|
|
66
|
+
"hand_note": (76, 440, 422, 75, -0.2),
|
|
67
|
+
"homemadeapple": (47, 440, 410, 60, -16.8),
|
|
68
|
+
"honeyscript": (98, 440, 422, 70, 5.5),
|
|
69
|
+
"jottflf": (56, 440, 417, 75, 7.9),
|
|
70
|
+
"note_this": (53, 440, 422, 75, 1),
|
|
71
|
+
"pleasewritemeasong": (66, 440, 417, 75, 14.7),
|
|
72
|
+
"s_hand": (67, 440, 407, 90, 32),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_WRINKLED = {
|
|
76
|
+
"adinekirnberg": (55, 140, 62, 71, -8),
|
|
77
|
+
"alanishand": (64, 140, 77, 35, 29.5),
|
|
78
|
+
"architex": (72, 140, 87, 35, 18),
|
|
79
|
+
"caveat": (58, 140, 62, 40, 14),
|
|
80
|
+
"greatvibesregular": (65, 140, 77, 37, 10.5),
|
|
81
|
+
"hand_note": (73, 140, 77, 40, 4.8),
|
|
82
|
+
"homemadeapple": (54, 140, 70, 25, 9.2),
|
|
83
|
+
"honeyscript": (92, 140, 77, 35, 12.5),
|
|
84
|
+
"jottflf": (48, 140, 77, 38, 11.9),
|
|
85
|
+
"note_this": (47, 140, 67, 40, 7),
|
|
86
|
+
"pleasewritemeasong": (58, 140, 77, 38, 15.7),
|
|
87
|
+
"s_hand": (54, 140, 72, 50, 22),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
_COLORED = {
|
|
91
|
+
"adinekirnberg": (62, 250, 60, 50, 10),
|
|
92
|
+
"alanishand": (53, 250, 60, 45, 13),
|
|
93
|
+
"architex": (45, 250, 60, 53, 13),
|
|
94
|
+
"caveat": (42, 250, 60, 50, 10),
|
|
95
|
+
"greatvibesregular": (76, 250, 60, 30, 10),
|
|
96
|
+
"hand_note": (55, 250, 60, 50, 12),
|
|
97
|
+
"homemadeapple": (36, 250, 60, 35, 6),
|
|
98
|
+
"honeyscript": (55, 250, 60, 55, 13),
|
|
99
|
+
"jottflf": (42, 250, 60, 45, 12),
|
|
100
|
+
"note_this": (43, 250, 60, 40, 12),
|
|
101
|
+
"pleasewritemeasong": (51, 250, 60, 45, 13),
|
|
102
|
+
"s_hand": (42, 250, 60, 60, 13),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _layouts(rows: dict, margin_bottom: int) -> dict:
|
|
107
|
+
return {font: Layout(*row, margin_bottom) for font, row in rows.items()}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
LAYOUTS = {
|
|
111
|
+
"a4": _layouts(_A4, 20),
|
|
112
|
+
"lined": _layouts(_LINED, 130),
|
|
113
|
+
"rough": _layouts(_ROUGH, 130),
|
|
114
|
+
"spiral": _layouts(_SPIRAL, 130),
|
|
115
|
+
"wrinkled": _layouts(_WRINKLED, 50),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for _name in COLORED:
|
|
119
|
+
LAYOUTS[_name] = _layouts(_COLORED, 50)
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import logging
|
|
4
|
+
import operator
|
|
5
|
+
import textwrap
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
9
|
+
from .layouts import COLORED, LAYOUTS, Layout
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
RES_DIR = Path(__file__).resolve().parent / "res"
|
|
14
|
+
PAPERS_DIR = RES_DIR / "papers"
|
|
15
|
+
FONTS_DIR = RES_DIR / "fonts"
|
|
16
|
+
|
|
17
|
+
DEFAULT_FILENAME = "assignment.png"
|
|
18
|
+
DEFAULT_PDF_FILENAME = "assignment.pdf"
|
|
19
|
+
|
|
20
|
+
PDF_PAGE_WIDTH_INCHES = 8.27
|
|
21
|
+
|
|
22
|
+
PAPERS = {
|
|
23
|
+
"a4": "a4paper.jpg",
|
|
24
|
+
"lined": "lined.png",
|
|
25
|
+
"rough": "rough.jpg",
|
|
26
|
+
"spiral": "spiral.png",
|
|
27
|
+
"wrinkled": "wrinkled.jpg",
|
|
28
|
+
**{name: f"{name}.jpg" for name in COLORED},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# In order, coz i used it in other projects.
|
|
32
|
+
|
|
33
|
+
FONTS = {
|
|
34
|
+
"adinekirnberg": "AdineKirnberg.ttf",
|
|
35
|
+
"alanishand": "AlanisHand.ttf",
|
|
36
|
+
"architex": "Architex.ttf",
|
|
37
|
+
"caveat": "caveat.ttf",
|
|
38
|
+
"greatvibesregular": "GreatVibes-Regular.ttf",
|
|
39
|
+
"hand_note": "Hand_Note.otf",
|
|
40
|
+
"homemadeapple": "HomemadeApple.ttf",
|
|
41
|
+
"honeyscript": "HoneyScript.ttf",
|
|
42
|
+
"jottflf": "JottFLF.ttf",
|
|
43
|
+
"note_this": "Note_this.ttf",
|
|
44
|
+
"pleasewritemeasong": "pleasewritemeasong.ttf",
|
|
45
|
+
"s_hand": "S_Hand.ttf",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
FONT_ALIASES = {
|
|
49
|
+
"greatvibes": "greatvibesregular",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
INKS = {
|
|
53
|
+
"blue": (19, 19, 148),
|
|
54
|
+
"black": (19, 19, 20),
|
|
55
|
+
"red": (163, 2, 2),
|
|
56
|
+
"green": (0, 122, 0),
|
|
57
|
+
"pencil": (105, 105, 105),
|
|
58
|
+
"brown": (101, 67, 33),
|
|
59
|
+
"purple": (75, 0, 130),
|
|
60
|
+
"orange": (255, 140, 0),
|
|
61
|
+
"teal": (0, 128, 128),
|
|
62
|
+
"magenta": (139, 0, 139),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _normalize(value) -> str:
|
|
67
|
+
"""Lower-case a name and drop everything that is not a letter or digit."""
|
|
68
|
+
return re.sub(r"[^a-z0-9]", "", str(value).lower())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def available_papers() -> list:
|
|
72
|
+
return [name for name, file in PAPERS.items() if (PAPERS_DIR / file).is_file()]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def available_fonts() -> list:
|
|
76
|
+
return [name for name, file in FONTS.items() if (FONTS_DIR / file).is_file()]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_paper(value) -> str:
|
|
80
|
+
"""Return the paper name for `value` (case and spaces are ignored) or raise ValueError."""
|
|
81
|
+
lookup = {_normalize(name): name for name in PAPERS}
|
|
82
|
+
name = lookup.get(_normalize(value))
|
|
83
|
+
if name is None:
|
|
84
|
+
raise ValueError(f"Unknown Paper {value!r}. Choose one of: {', '.join(PAPERS)}")
|
|
85
|
+
if name not in available_papers():
|
|
86
|
+
raise ValueError(f"The image file for Paper {name!r} is missing")
|
|
87
|
+
return name
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve_font(value) -> str:
|
|
91
|
+
"""Return the font name for `value` (spaces, dashes and case are ignored) or raise ValueError."""
|
|
92
|
+
names = list(FONTS)
|
|
93
|
+
text = str(value).strip()
|
|
94
|
+
if re.fullmatch(r"\d+", text):
|
|
95
|
+
number = int(text)
|
|
96
|
+
if not 1 <= number <= len(names):
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"Unknown Font number {value!r}. Choose a number from 1 to {len(names)}"
|
|
99
|
+
)
|
|
100
|
+
name = names[number - 1]
|
|
101
|
+
else:
|
|
102
|
+
lookup = {_normalize(item): item for item in names}
|
|
103
|
+
lookup.update(FONT_ALIASES)
|
|
104
|
+
name = lookup.get(_normalize(text))
|
|
105
|
+
if name is None:
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"Unknown Font {value!r}. Choose one of: {', '.join(names)}"
|
|
108
|
+
)
|
|
109
|
+
if name not in available_fonts():
|
|
110
|
+
raise ValueError(f"The font file for Font {name!r} is missing")
|
|
111
|
+
return name
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def resolve_ink(value) -> str:
|
|
115
|
+
"""Return an ink name (e.g. "blue") or a hex colour like "1A2B3C".
|
|
116
|
+
|
|
117
|
+
Raises ValueError if `value` is neither.
|
|
118
|
+
"""
|
|
119
|
+
key = str(value).strip().lower()
|
|
120
|
+
if key in INKS:
|
|
121
|
+
return key
|
|
122
|
+
code = key.removeprefix("#")
|
|
123
|
+
if re.fullmatch(r"[0-9a-f]{6}", code):
|
|
124
|
+
return code.upper()
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"Unknown Ink {value!r}. Choose one of: {', '.join(INKS)} "
|
|
127
|
+
"or a 6-digit hex colour such as 131394"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def ink_to_rgb(ink: str) -> tuple:
|
|
132
|
+
"""Turn a resolved ink (see `resolve_ink`) into an (R, G, B) tuple."""
|
|
133
|
+
if ink in INKS:
|
|
134
|
+
return INKS[ink]
|
|
135
|
+
return (int(ink[0:2], 16), int(ink[2:4], 16), int(ink[4:6], 16))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _pick(value, default):
|
|
139
|
+
"""Default picker LOL, L Hypergamy NGL."""
|
|
140
|
+
return default if value is None else value
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _resolve_target(path, default_name: str, default_suffix: str) -> Path:
|
|
144
|
+
"""Turn what the user passed to save() into the file to write."""
|
|
145
|
+
raw = os.fsdecode(path) if path is not None else default_name
|
|
146
|
+
target = Path(raw)
|
|
147
|
+
if target.is_dir() or raw.endswith(("/", os.sep)):
|
|
148
|
+
return target / default_name
|
|
149
|
+
if not target.suffix:
|
|
150
|
+
return target.with_suffix(default_suffix)
|
|
151
|
+
return target
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def read_text_file(path) -> str:
|
|
155
|
+
"""Return the text of a UTF-8 text file (a BOM at the start is ignored).
|
|
156
|
+
|
|
157
|
+
Raises ValueError, with a message that names the file, if it cannot be read.
|
|
158
|
+
"""
|
|
159
|
+
if not isinstance(path, (str, os.PathLike)):
|
|
160
|
+
raise ValueError("File must be the path of a text file, as a string or a Path")
|
|
161
|
+
try:
|
|
162
|
+
return Path(path).expanduser().read_text(encoding="utf-8-sig")
|
|
163
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
164
|
+
raise ValueError(f"Could not read {os.fspath(path)}: {error}") from error
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass
|
|
168
|
+
class AssignmentConfigOptions:
|
|
169
|
+
"""Settings for the handwritten pages.
|
|
170
|
+
|
|
171
|
+
Give either Text or File, not both. FontSize to LineSpacing are None by default.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
Text: str | None = None
|
|
175
|
+
Paper: str = "a4"
|
|
176
|
+
Font: str = "caveat"
|
|
177
|
+
Ink: str = "blue"
|
|
178
|
+
KeepLineBreaks: bool = False
|
|
179
|
+
FontSize: int | None = None
|
|
180
|
+
MaxLineLength: int | None = None
|
|
181
|
+
MarginLeft: int | None = None
|
|
182
|
+
MarginTop: int | None = None
|
|
183
|
+
MarginBottom: int | None = None
|
|
184
|
+
LineSpacing: float | None = None
|
|
185
|
+
File: str | os.PathLike | None = None
|
|
186
|
+
|
|
187
|
+
def __post_init__(self):
|
|
188
|
+
if self.File is not None:
|
|
189
|
+
if self.Text is not None:
|
|
190
|
+
raise ValueError("Give Text or File, not both")
|
|
191
|
+
self.Text = read_text_file(self.File)
|
|
192
|
+
|
|
193
|
+
if not isinstance(self.Text, str) or not self.Text.strip():
|
|
194
|
+
raise ValueError("Nothing to write, Text (or File) is missing or empty")
|
|
195
|
+
|
|
196
|
+
self.Ink = resolve_ink(self.Ink)
|
|
197
|
+
self.Font = resolve_font(self.Font)
|
|
198
|
+
self.Paper = resolve_paper(self.Paper)
|
|
199
|
+
|
|
200
|
+
for name in ("FontSize", "MaxLineLength"):
|
|
201
|
+
value = getattr(self, name)
|
|
202
|
+
if value is not None and value < 1:
|
|
203
|
+
raise ValueError(f"{name} must be at least 1")
|
|
204
|
+
|
|
205
|
+
for name in ("MarginLeft", "MarginTop", "MarginBottom"):
|
|
206
|
+
value = getattr(self, name)
|
|
207
|
+
if value is not None and value < 0:
|
|
208
|
+
raise ValueError(f"{name} can't be negative")
|
|
209
|
+
|
|
210
|
+
# LineSpacing isn't checked here, _prepare() already complains if the lines would end up on top of each other
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class AssignmentResult:
|
|
214
|
+
"""What `Assignment.create()` returns.
|
|
215
|
+
|
|
216
|
+
- `bool(result)` is False if the pages could not be created.
|
|
217
|
+
- `len(result)` is the number of pages, `result[0]` is the first page. Each page is a Pillow image that is drawn when you ask for it, so a long text does not keep every page in memory.
|
|
218
|
+
- `result.image` is the first page.
|
|
219
|
+
- `result.paths` are the files written by the last `save()` or `save_pdf()`, and `str(result)` is those paths, one per line (an empty string until something has been saved).
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def __init__(self, render=None, page_count=0, config=None):
|
|
223
|
+
self._render = render
|
|
224
|
+
self.page_count = page_count
|
|
225
|
+
self.config = config
|
|
226
|
+
self.paths = []
|
|
227
|
+
|
|
228
|
+
def __bool__(self) -> bool:
|
|
229
|
+
return self.page_count > 0
|
|
230
|
+
|
|
231
|
+
def __len__(self) -> int:
|
|
232
|
+
return self.page_count
|
|
233
|
+
|
|
234
|
+
def __getitem__(self, index):
|
|
235
|
+
index = operator.index(index)
|
|
236
|
+
if not -self.page_count <= index < self.page_count:
|
|
237
|
+
raise IndexError("page index out of range")
|
|
238
|
+
return self._render(index % self.page_count)
|
|
239
|
+
|
|
240
|
+
def __iter__(self):
|
|
241
|
+
return (self[index] for index in range(self.page_count))
|
|
242
|
+
|
|
243
|
+
def __str__(self) -> str:
|
|
244
|
+
return "\n".join(self.paths)
|
|
245
|
+
|
|
246
|
+
def __repr__(self) -> str:
|
|
247
|
+
if not self:
|
|
248
|
+
return "<AssignmentResult failed>"
|
|
249
|
+
pages = "page" if self.page_count == 1 else "pages"
|
|
250
|
+
saved = f" saved={self.paths!r}" if self.paths else ""
|
|
251
|
+
return f"<AssignmentResult {self.page_count} {pages}{saved}>"
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def image(self):
|
|
255
|
+
"""The first page as a Pillow image, or None if nothing was created."""
|
|
256
|
+
return self[0] if self else None
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def pages(self):
|
|
260
|
+
"""Iterator over every page as a Pillow image."""
|
|
261
|
+
return iter(self)
|
|
262
|
+
|
|
263
|
+
def save(self, path=None, **kwargs):
|
|
264
|
+
"""Save every page and return the list of paths, or None if it failed.
|
|
265
|
+
|
|
266
|
+
- `path` can be a file name or a folder; it defaults to `assignment.png` in the current folder.
|
|
267
|
+
- When the text needs more than one page they are saved as `name_1.png`, `name_2.png` and so on.
|
|
268
|
+
- Extra keyword arguments go to Pillow's `Image.save`.
|
|
269
|
+
"""
|
|
270
|
+
if not self:
|
|
271
|
+
logger.error("Nothing to save: the assignment pages were not created")
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
target = _resolve_target(path, DEFAULT_FILENAME, ".png")
|
|
275
|
+
if target.suffix.lower() in (".jpg", ".jpeg"):
|
|
276
|
+
kwargs.setdefault(
|
|
277
|
+
"quality", 95
|
|
278
|
+
) # Punish the guy for using JPEG when PNG exists. (MAYBE CHANGE IT TO 50 IN FUTURE)
|
|
279
|
+
|
|
280
|
+
if self.page_count == 1:
|
|
281
|
+
targets = [target]
|
|
282
|
+
else:
|
|
283
|
+
digits = len(str(self.page_count))
|
|
284
|
+
targets = [
|
|
285
|
+
target.with_name(f"{target.stem}_{number:0{digits}d}{target.suffix}")
|
|
286
|
+
for number in range(1, self.page_count + 1)
|
|
287
|
+
]
|
|
288
|
+
|
|
289
|
+
saved = []
|
|
290
|
+
try:
|
|
291
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
for page_path, page in zip(targets, self):
|
|
293
|
+
page.save(page_path, **kwargs)
|
|
294
|
+
saved.append(os.fspath(page_path))
|
|
295
|
+
except Exception as error:
|
|
296
|
+
logger.error("Could not save the assignment pages to %s: %s", target, error)
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
self.paths = saved
|
|
300
|
+
return saved
|
|
301
|
+
|
|
302
|
+
def save_pdf(self, path=None):
|
|
303
|
+
"""Save all pages as one PDF and return its path, or None if it failed.
|
|
304
|
+
|
|
305
|
+
- `path` can be a file name or a folder; it defaults to `assignment.pdf` in the current folder
|
|
306
|
+
- The pages are scaled to the width of an A4 sheet. All pages are kept in memory while the PDF is written.
|
|
307
|
+
"""
|
|
308
|
+
if not self:
|
|
309
|
+
logger.error("Nothing to save: the assignment pages were not created")
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
target = _resolve_target(path, DEFAULT_PDF_FILENAME, ".pdf")
|
|
313
|
+
try:
|
|
314
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
pages = list(self)
|
|
316
|
+
pages[0].save(
|
|
317
|
+
target,
|
|
318
|
+
format="PDF",
|
|
319
|
+
save_all=True,
|
|
320
|
+
append_images=pages[1:],
|
|
321
|
+
resolution=pages[0].width / PDF_PAGE_WIDTH_INCHES,
|
|
322
|
+
)
|
|
323
|
+
except Exception as error:
|
|
324
|
+
logger.error("Could not save the assignment PDF to %s: %s", target, error)
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
self.paths = [os.fspath(target)]
|
|
328
|
+
return self.paths[0]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class Assignment:
|
|
332
|
+
"""Writes text on a paper in a handwriting font and ink.
|
|
333
|
+
|
|
334
|
+
- Example:
|
|
335
|
+
config = AssignmentConfigOptions(Text="Hello World", Paper="lined")
|
|
336
|
+
result = Assignment(config).create()
|
|
337
|
+
result.save("page.png")
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
def __init__(self, config: AssignmentConfigOptions):
|
|
341
|
+
if not isinstance(config, AssignmentConfigOptions):
|
|
342
|
+
raise TypeError("config must be an AssignmentConfigOptions instance")
|
|
343
|
+
self.config = config
|
|
344
|
+
|
|
345
|
+
def get_defaults(self) -> dict:
|
|
346
|
+
layout = LAYOUTS[self.config.Paper][self.config.Font]
|
|
347
|
+
return {
|
|
348
|
+
"Paper": self.config.Paper,
|
|
349
|
+
"Font": self.config.Font,
|
|
350
|
+
"FontFile": FONTS[self.config.Font],
|
|
351
|
+
"FontSize": layout.font_size,
|
|
352
|
+
"MaxLineLength": layout.max_line_length,
|
|
353
|
+
"MarginLeft": layout.margin_left,
|
|
354
|
+
"MarginTop": layout.margin_top,
|
|
355
|
+
"MarginBottom": layout.margin_bottom,
|
|
356
|
+
"LineSpacing": layout.line_spacing,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
def create(self) -> AssignmentResult:
|
|
360
|
+
"""Lay out the text. Returns a falsy AssignmentResult if anything fails."""
|
|
361
|
+
try:
|
|
362
|
+
render, page_count = self._prepare()
|
|
363
|
+
except Exception as error:
|
|
364
|
+
logger.error("Could not create the assignment pages: %s", error)
|
|
365
|
+
return AssignmentResult()
|
|
366
|
+
return AssignmentResult(render, page_count, self.config)
|
|
367
|
+
|
|
368
|
+
def _layout(self) -> Layout:
|
|
369
|
+
"""The tuned layout with the config's overrides applied."""
|
|
370
|
+
config = self.config
|
|
371
|
+
layout = LAYOUTS[config.Paper][config.Font]
|
|
372
|
+
return Layout(
|
|
373
|
+
max_line_length=_pick(config.MaxLineLength, layout.max_line_length),
|
|
374
|
+
margin_left=_pick(config.MarginLeft, layout.margin_left),
|
|
375
|
+
margin_top=_pick(config.MarginTop, layout.margin_top),
|
|
376
|
+
font_size=_pick(config.FontSize, layout.font_size),
|
|
377
|
+
line_spacing=_pick(config.LineSpacing, layout.line_spacing),
|
|
378
|
+
margin_bottom=_pick(config.MarginBottom, layout.margin_bottom),
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def _wrap(self, width: int) -> list:
|
|
382
|
+
"""Break the text into lines of at most `width` characters."""
|
|
383
|
+
text = self.config.Text
|
|
384
|
+
if not self.config.KeepLineBreaks:
|
|
385
|
+
return textwrap.wrap(text, width=width)
|
|
386
|
+
|
|
387
|
+
lines = []
|
|
388
|
+
for line in text.splitlines():
|
|
389
|
+
lines.extend(textwrap.wrap(line, width=width) or [""])
|
|
390
|
+
# blank lines at the very end would only add an empty page
|
|
391
|
+
while lines and not lines[-1]:
|
|
392
|
+
lines.pop()
|
|
393
|
+
return lines
|
|
394
|
+
|
|
395
|
+
def _prepare(self):
|
|
396
|
+
"""Work out the pages. Returns (render, page_count).
|
|
397
|
+
|
|
398
|
+
`render(index)` draws page `index` (counting from 0) and returns it btw.
|
|
399
|
+
"""
|
|
400
|
+
config = self.config
|
|
401
|
+
layout = self._layout()
|
|
402
|
+
|
|
403
|
+
with Image.open(PAPERS_DIR / PAPERS[config.Paper]) as paper:
|
|
404
|
+
base = paper.convert("RGB")
|
|
405
|
+
|
|
406
|
+
font = ImageFont.truetype(str(FONTS_DIR / FONTS[config.Font]), layout.font_size)
|
|
407
|
+
color = ink_to_rgb(config.Ink)
|
|
408
|
+
|
|
409
|
+
lines = self._wrap(layout.max_line_length)
|
|
410
|
+
left, top, right, bottom = font.getbbox("Ay")
|
|
411
|
+
line_height = (bottom - top) + layout.line_spacing
|
|
412
|
+
if line_height <= 0:
|
|
413
|
+
raise ValueError("LineSpacing is too small, the lines would overlap")
|
|
414
|
+
available_height = base.height - layout.margin_top - layout.margin_bottom
|
|
415
|
+
lines_per_page = int(available_height // line_height)
|
|
416
|
+
if lines_per_page < 1:
|
|
417
|
+
logger.warning(
|
|
418
|
+
"Not even one line fits on the %s page, use a smaller FontSize, "
|
|
419
|
+
"MarginTop or MarginBottom.",
|
|
420
|
+
config.Paper,
|
|
421
|
+
)
|
|
422
|
+
lines_per_page = 1
|
|
423
|
+
|
|
424
|
+
pages = [
|
|
425
|
+
lines[start : start + lines_per_page]
|
|
426
|
+
for start in range(0, len(lines), lines_per_page)
|
|
427
|
+
]
|
|
428
|
+
|
|
429
|
+
if any(
|
|
430
|
+
layout.margin_left + font.getlength(line) > base.width for line in lines
|
|
431
|
+
):
|
|
432
|
+
logger.warning(
|
|
433
|
+
"Some lines run past the right edge of the %s page. "
|
|
434
|
+
"Use a smaller MaxLineLength or FontSize.",
|
|
435
|
+
config.Paper,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def render(index: int) -> Image.Image:
|
|
439
|
+
page = base.copy()
|
|
440
|
+
draw = ImageDraw.Draw(page)
|
|
441
|
+
y = layout.margin_top
|
|
442
|
+
for line in pages[index]:
|
|
443
|
+
draw.text((layout.margin_left, y), line, fill=color, font=font)
|
|
444
|
+
y += line_height
|
|
445
|
+
return page
|
|
446
|
+
|
|
447
|
+
return render, len(pages)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Font notice
|
|
2
|
+
|
|
3
|
+
The code in this package is MIT licensed (see `LICENSE`). The fonts in this folder are **not** covered by that license. Each one has its own terms.
|
|
4
|
+
|
|
5
|
+
The table below is copied from the info stored inside each font file.
|
|
6
|
+
"Not stated" means the font file itself has no license in it. That doesn't mean the font is free to use, only that the file doesn't say.
|
|
7
|
+
|
|
8
|
+
| File | Font | Copyright line in the file | License in the file |
|
|
9
|
+
| --- | --- | --- | --- |
|
|
10
|
+
| `caveat.ttf` | Caveat | Copyright 2015 Google Inc. (designer: Pablo Impallari) | SIL Open Font License 1.1 |
|
|
11
|
+
| `GreatVibes-Regular.ttf` | Great Vibes | Copyright (c) 2012 TypeSETit, LLC, Reserved Font Name "Great Vibes" | SIL Open Font License 1.1 |
|
|
12
|
+
| `HomemadeApple.ttf` | Homemade Apple | Copyright (c) 2010 by Font Diner, Inc. | Apache License 2.0 |
|
|
13
|
+
| `AdineKirnberg.ttf` | Adine Kirnberg | Copyright (c) Typographer Mediengestaltung, 2000. Adine Kirnberg-Script 1991 by D. Rakowski. All rights reserved. Full character set added by D. Steffmann | Not stated |
|
|
14
|
+
| `AlanisHand.ttf` | Alanis Hand | "2003" | Not stated |
|
|
15
|
+
| `Architex.ttf` | Architext | (c) Grant Marshall, www.marshallarts.co.za | Not stated |
|
|
16
|
+
| `Hand_Note.otf` | Hand Note | Hand Note (c) Azcreative Studio. 2020. All Rights Reserved (designer: Abdul Azis) | Not stated |
|
|
17
|
+
| `HoneyScript.ttf` | Honey Script | Copyright (c) Typographer Mediengestaltung, 2000. All rights reserved. Added to full character set by Dieter Steffmann | Not stated |
|
|
18
|
+
| `JottFLF.ttf` | JottFLF | Jott FLF Casual, 4.1 Copyright Casady & Greene, Inc. 1988-92. All rights reserved | Not stated |
|
|
19
|
+
| `Note_this.ttf` | Note this | Copyright (c) West Wind Fonts, 2001 | Not stated |
|
|
20
|
+
| `pleasewritemeasong.ttf` | Please write me a song | Vanessa Bays - ByTheButterfly.com | Not stated |
|
|
21
|
+
| `S_Hand.ttf` | S Hand | Created with FontForge (no author or license given) | Not stated |
|
|
22
|
+
|
|
23
|
+
Full text of the licenses named above:
|
|
24
|
+
|
|
25
|
+
- SIL Open Font License 1.1: https://openfontlicense.org
|
|
26
|
+
- Apache License 2.0: https://www.apache.org/licenses/LICENSE-2.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,266 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: assignmentwriter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python package and CLI that writes your text on paper in a handwriting font and ink of your choice
|
|
5
|
+
Author: TheHritu
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/TheHritu/AssignmentWriter
|
|
8
|
+
Keywords: assignment,handwriting,handwritten,text-to-handwriting,image,pillow
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: Pillow>=9.0
|
|
16
|
+
Provides-Extra: cli
|
|
17
|
+
Requires-Dist: rich; extra == "cli"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# AssignmentWriter
|
|
21
|
+
|
|
22
|
+
A Python package and command line tool that writes your text on a sheet of paper, in a handwriting font and ink colour of your choice. Pick a paper, a font and an ink, and get back an image of the page(s) (in form of pdf too) - all in a few lines of code.
|
|
23
|
+
|
|
24
|
+
[](https://pypi.org/project/assignmentwriter/) [](https://github.com/TheHritu/AssignmentWriter/blob/main/LICENSE)
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
pip install assignmentwriter -U
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from assignmentwriter import Assignment, AssignmentConfigOptions
|
|
36
|
+
|
|
37
|
+
config = AssignmentConfigOptions(Text="Hello World", Paper="lined", Font="caveat", Ink="blue")
|
|
38
|
+
result = Assignment(config).create()
|
|
39
|
+
|
|
40
|
+
result.save("page.png") # saves the image
|
|
41
|
+
print(result) # prints the saved path (if saved)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or write a whole text file, on as many pages as it needs:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
config = AssignmentConfigOptions(File="essay.txt", Paper="spiral", Ink="teal")
|
|
48
|
+
result = Assignment(config).create()
|
|
49
|
+
|
|
50
|
+
result.save("essay.png") # essay_1.png, essay_2.png, ...
|
|
51
|
+
result.save_pdf("essay.pdf") # or all the pages in one PDF
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Previews
|
|
55
|
+
|
|
56
|
+
See at https://github.com/TheHritu/AssignmentWriter
|
|
57
|
+
|
|
58
|
+
## Papers, fonts and inks
|
|
59
|
+
|
|
60
|
+
| Option | Values |
|
|
61
|
+
|--------|--------|
|
|
62
|
+
| `Paper` | `a4`, `lined`, `rough`, `spiral`, `wrinkled`, `cream`, `cyan`, `lightblue`, `lightgreen`, `lime`, `orange`, `pink` |
|
|
63
|
+
| `Font` | `adinekirnberg`, `alanishand`, `architex`, `caveat`, `greatvibesregular`, `hand_note`, `homemadeapple`, `honeyscript`, `jottflf`, `note_this`, `pleasewritemeasong`, `s_hand` |
|
|
64
|
+
| `Ink` | `blue`, `black`, `red`, `green`, `pencil`, `brown`, `purple`, `orange`, `teal`, `magenta`, or any hex colour such as `"131394"` |
|
|
65
|
+
|
|
66
|
+
A font can also be chosen by its number, 1 to 12, in the order shown above (`Font=4` is `caveat`).
|
|
67
|
+
Names are not case sensitive and spaces, dashes and underscores are ignored (`"Hand Note"` and `"Light Green"` work).
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from assignmentwriter import available_fonts, available_papers, INKS
|
|
71
|
+
|
|
72
|
+
print(available_papers()) # ['a4', 'lined', 'rough', 'spiral', 'wrinkled', 'cream', 'cyan', 'lightblue', 'lightgreen', 'lime', 'orange', 'pink']
|
|
73
|
+
print(available_fonts()) # ['adinekirnberg', 'alanishand', 'architex', 'caveat', 'greatvibesregular', 'hand_note', 'homemadeapple', 'honeyscript', 'jottflf', 'note_this', 'pleasewritemeasong', 's_hand']
|
|
74
|
+
print(list(INKS)) # ['blue', 'black', 'red', 'green', 'pencil', 'brown', 'purple', 'orange', 'teal', 'magenta']
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
> **Note:** `alanishand` draws the digits `2` and `5` very wide, so lines with those numbers can run off the page. Use another font for text with numbers.
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
`AssignmentConfigOptions` accepts these fields:
|
|
82
|
+
|
|
83
|
+
| Field | Type | Default | Description |
|
|
84
|
+
|-------|------|---------|-------------|
|
|
85
|
+
| `Text` | `str` | `None` | Text to write (give `Text` or `File`, not both) |
|
|
86
|
+
| `File` | `str` or `Path` | `None` | A UTF-8 text file to write instead of `Text` |
|
|
87
|
+
| `Paper` | `str` | `"a4"` | Paper to write on |
|
|
88
|
+
| `Font` | `str` | `"caveat"` | Handwriting font (name or number) |
|
|
89
|
+
| `Ink` | `str` | `"blue"` | Ink colour (name or hex) |
|
|
90
|
+
| `KeepLineBreaks` | `bool` | `False` | Start a new line wherever your text has one |
|
|
91
|
+
| `FontSize` | `int` | `None` | Override the font size |
|
|
92
|
+
| `MaxLineLength` | `int` | `None` | Override the number of characters per line |
|
|
93
|
+
| `MarginLeft` | `int` | `None` | Override the left margin (pixels) |
|
|
94
|
+
| `MarginTop` | `int` | `None` | Override the top margin (pixels) |
|
|
95
|
+
| `MarginBottom` | `int` | `None` | Override the space kept free at the bottom of each page (pixels) |
|
|
96
|
+
| `LineSpacing` | `float` | `None` | Override the space between lines (pixels) |
|
|
97
|
+
|
|
98
|
+
The last six are `None` by default, which keeps the layout that is tuned for the chosen paper and font.
|
|
99
|
+
An invalid value raises a `ValueError` as soon as the config is created.
|
|
100
|
+
|
|
101
|
+
By default your text is written as one paragraph, line breaks are treated as spaces.
|
|
102
|
+
Use `KeepLineBreaks=True` to keep headings, lists and paragraphs on their own lines.
|
|
103
|
+
|
|
104
|
+
### Text from a file
|
|
105
|
+
|
|
106
|
+
Pass the path of a UTF-8 text file as `File` instead of `Text`:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from assignmentwriter import Assignment, AssignmentConfigOptions
|
|
110
|
+
|
|
111
|
+
config = AssignmentConfigOptions(File="essay.txt", Paper="lined", Ink="black", KeepLineBreaks=True)
|
|
112
|
+
result = Assignment(config).create()
|
|
113
|
+
result.save("essay.png")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A `ValueError` is raised if the file cannot be read, is empty, or if you give both `Text` and `File`.
|
|
117
|
+
|
|
118
|
+
### Get the tuned layout for a paper and font:
|
|
119
|
+
|
|
120
|
+
Returns a dictionary with the default layout values.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from assignmentwriter import Assignment, AssignmentConfigOptions
|
|
124
|
+
|
|
125
|
+
config = AssignmentConfigOptions(Text="Hi", Paper="lined", Font="caveat")
|
|
126
|
+
print(Assignment(config).get_defaults())
|
|
127
|
+
# {'Paper': 'lined', 'Font': 'caveat', 'FontFile': 'caveat.ttf', 'FontSize': 85,
|
|
128
|
+
# 'MaxLineLength': 52, 'MarginLeft': 370, 'MarginTop': 333, 'MarginBottom': 130, 'LineSpacing': 22}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Several pages
|
|
132
|
+
|
|
133
|
+
When your text is too long for one page, it continues on as many pages as it needs, so none of it is lost.
|
|
134
|
+
A line is never split between two pages.
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
config = AssignmentConfigOptions(File="essay.txt", Paper="spiral")
|
|
138
|
+
result = Assignment(config).create()
|
|
139
|
+
|
|
140
|
+
print(len(result)) # the number of pages
|
|
141
|
+
result.save("essay.png") # essay_1.png, essay_2.png, ...
|
|
142
|
+
result.save_pdf("essay.pdf") # or all the pages in one PDF
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## `AssignmentResult`
|
|
146
|
+
|
|
147
|
+
`Assignment.create()` returns an `AssignmentResult` object:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
result = Assignment(config).create()
|
|
151
|
+
|
|
152
|
+
bool(result) # False if the pages could not be created
|
|
153
|
+
len(result) # the number of pages
|
|
154
|
+
result.image # the first page as a Pillow Image
|
|
155
|
+
result[1] # the second page, `for page in result` goes through all of them
|
|
156
|
+
result.save() # assignment.png, or assignment_1.png, assignment_2.png ... for several pages
|
|
157
|
+
result.save("out.png") # save to a specific path (png, jpg, ...), returns the list of paths
|
|
158
|
+
result.save("pages/") # save in a folder
|
|
159
|
+
result.save_pdf("out.pdf") # all the pages in one PDF (as wide as an A4 sheet), returns the path
|
|
160
|
+
str(result) # the paths of the last save, one per line ("" before saving)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Each page is drawn when you ask for it, so a very long text does not keep every page in memory at once.
|
|
164
|
+
|
|
165
|
+
If anything fails, `create()` returns a falsy `AssignmentResult` and logs the error. `save()` and `save_pdf()` return `None` and log the error if a file cannot be written.
|
|
166
|
+
A warning is logged if some lines run past the right edge of the page. Use a smaller `FontSize` / `MaxLineLength`.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
# CLI
|
|
171
|
+
|
|
172
|
+
First, install the extra dependency for coloured output (optional, the CLI also works without it):
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
pip install "assignmentwriter[cli]"
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Usage
|
|
181
|
+
|
|
182
|
+
**No arguments** — launches an interactive prompt:
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
assignmentwriter
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Quick create** — just pass your text directly (saved as `assignment.png`):
|
|
189
|
+
|
|
190
|
+
```
|
|
191
|
+
assignmentwriter "Hello World"
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**Pick a paper, font and ink:**
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
assignmentwriter "Hello World" --paper lined --font caveat --ink black
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**Save the image to a file:**
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
assignmentwriter "Hello World" --save output.png
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Write the text of a file** (keep its line breaks):
|
|
207
|
+
|
|
208
|
+
```
|
|
209
|
+
assignmentwriter --file notes.txt --keep-line-breaks
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**Long text** continues on more pages, saved as `essay_1.png`, `essay_2.png`, ... :
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
assignmentwriter --file essay.txt --paper spiral --save essay.png
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
**Save all the pages as one PDF:**
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
assignmentwriter --file essay.txt --paper spiral --pdf
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**Plain output** (just the saved paths, no colors — useful for scripting):
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
assignmentwriter "Hello World" --as-text
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## List
|
|
233
|
+
|
|
234
|
+
See every paper, font and ink:
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
assignmentwriter list
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
> **Add `--as-text` to get plain line-by-line output instead of a table.**
|
|
241
|
+
|
|
242
|
+
## On Some OS
|
|
243
|
+
|
|
244
|
+
On some operating systems, the CLI may not be available as `assignmentwriter` immediately after installation. If you encounter a "command not found" error, try running it with Python:
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
python -m assignmentwriter "Hello World"
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Fonts and papers
|
|
253
|
+
|
|
254
|
+
The code is MIT licensed, but the fonts and paper images have their own terms.
|
|
255
|
+
|
|
256
|
+
## Coffee is love, coffee is life.
|
|
257
|
+
|
|
258
|
+
https://www.buymeacoffee.com/hritu
|
|
259
|
+
|
|
260
|
+
## Contributing
|
|
261
|
+
|
|
262
|
+
Contributions are welcome — open an issue or submit a pull request.
|
|
263
|
+
|
|
264
|
+
## License
|
|
265
|
+
|
|
266
|
+
MIT © TheHritu
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
assignmentwriter/__init__.py,sha256=xTrjlQfrqlh7HPAw5YimVFX3raCwjqKu4NjBx2O7peQ,586
|
|
2
|
+
assignmentwriter/__main__.py,sha256=h_jahUCpELtz_fdlRyK7WZtHvizbiOwm2e6QndsXHnk,7710
|
|
3
|
+
assignmentwriter/layouts.py,sha256=qvoYBXDGU_mAjXHfulxKErptCScmmEuhMHvDDSjftEk,3803
|
|
4
|
+
assignmentwriter/modules.py,sha256=XJhvioIuWzbQOk-so0Qm0m8bSkNIVFxwP2M8rkoJYxc,15763
|
|
5
|
+
assignmentwriter/res/fonts/AdineKirnberg.ttf,sha256=bS5LP3jKpVPxDIP81uUjZBPiS3Chhxqphv5WsDjuKnQ,56632
|
|
6
|
+
assignmentwriter/res/fonts/AlanisHand.ttf,sha256=0EUwraVplW7THBHDWoO6kHqNNPgUUi0NR53oh35Exws,36304
|
|
7
|
+
assignmentwriter/res/fonts/Architex.ttf,sha256=prjjPOGQ7pyHFZC5gs_zfhN7MDu3Pcq1XvP_cVS6FDo,30568
|
|
8
|
+
assignmentwriter/res/fonts/GreatVibes-Regular.ttf,sha256=9hhm3pIFM5URLJWfh0Wfa-g14yFKAleEJLPPABwrDTA,105980
|
|
9
|
+
assignmentwriter/res/fonts/Hand_Note.otf,sha256=HXAkV9K0EJS-NHOBWEnVvOGPjvWu-1QySvozZbo3dC4,47280
|
|
10
|
+
assignmentwriter/res/fonts/HomemadeApple.ttf,sha256=sxHwQumubbsyeyOsBHaxay6YoEF1RUGndz-6s7MnmQY,110080
|
|
11
|
+
assignmentwriter/res/fonts/HoneyScript.ttf,sha256=5WTawX0yYmarrQ38lc6EFuAusUW9Ir3NGHOTsxFk734,40952
|
|
12
|
+
assignmentwriter/res/fonts/JottFLF.ttf,sha256=6cQrZRryHdLkUgdE8Uf7h2bqvnyVxMgTg2EVDHo79SQ,43364
|
|
13
|
+
assignmentwriter/res/fonts/NOTICE.md,sha256=L5feAzVoJYndbffFdeuVJ5VyfURwfs2J-NtwRje9qkA,2044
|
|
14
|
+
assignmentwriter/res/fonts/Note_this.ttf,sha256=k3XzcrFqcgjE1RjUXtwCD_SU19Dsji4AScevUZgiC0k,52092
|
|
15
|
+
assignmentwriter/res/fonts/S_Hand.ttf,sha256=5g9I-gNSSenUrKdEqQ1QPjAdSls7ctN6ptF7532vymw,22828
|
|
16
|
+
assignmentwriter/res/fonts/caveat.ttf,sha256=VLiF0Do-0lLFsxJ1riNBhYVFer0w3xUNfV5KM5J6WZE,196984
|
|
17
|
+
assignmentwriter/res/fonts/pleasewritemeasong.ttf,sha256=qL6JlJu0zwesCYIEb5q-5HPVYFemr5fUw9TQsE2BW0Y,38356
|
|
18
|
+
assignmentwriter/res/papers/a4paper.jpg,sha256=6h93xOjYCyx_fTwPdpLU7RS21Vv5WrDsq91-auS0dJ0,140601
|
|
19
|
+
assignmentwriter/res/papers/cream.jpg,sha256=EOWFBjmqDYnbbtbS8DQYT48j4AS1HZO4R56QxPnS79Q,77624
|
|
20
|
+
assignmentwriter/res/papers/cyan.jpg,sha256=FKzgtWC8oh2ikW14Guw63LJZf5oHG10ctlPj4OuYPlA,103933
|
|
21
|
+
assignmentwriter/res/papers/lightblue.jpg,sha256=1sVdyEsV6aj6okrIci5L8zlZGxHjU8EvQfhfIlodRi8,77880
|
|
22
|
+
assignmentwriter/res/papers/lightgreen.jpg,sha256=I91qSWXyILIftPgS9eTXEriQu414N7VPw47YaS4KaUE,91836
|
|
23
|
+
assignmentwriter/res/papers/lime.jpg,sha256=CDRlslSiPLZ1yxzVg8L0ye_EcRPfUeRnwwpPIisHOh8,112593
|
|
24
|
+
assignmentwriter/res/papers/lined.png,sha256=RUf5ivb3sw46b9i6M4kKWSUE-RBylb2XYjQmBE0ATgs,2305592
|
|
25
|
+
assignmentwriter/res/papers/orange.jpg,sha256=-TSeh4nH2-EhqcM-NwfZL8g9YpVL-0Rqe9hHfgkSPmM,100508
|
|
26
|
+
assignmentwriter/res/papers/pink.jpg,sha256=9dclq7jkdl-yHbVeRa8H_sQ_eThBx7r_aTBmE0JTT8U,78830
|
|
27
|
+
assignmentwriter/res/papers/rough.jpg,sha256=m2GFLoxXo0ZDgzqFI83aNwtYCUPNwBtuasqZAszUy60,317604
|
|
28
|
+
assignmentwriter/res/papers/spiral.png,sha256=P0Tr21UXCouvWHUtrJdg7hb3KxLcDd_6aJdYRasPdeM,337063
|
|
29
|
+
assignmentwriter/res/papers/wrinkled.jpg,sha256=fcbTFn-qI50vAY9Oe4if_XfEwTXESVDYnFddVTq4L6Q,595520
|
|
30
|
+
assignmentwriter-0.1.0.dist-info/licenses/LICENSE,sha256=nrgi-zHEF-8cxgWJLEJeqXvxXTjoQVhyvHvzY6YX8fs,1064
|
|
31
|
+
assignmentwriter-0.1.0.dist-info/METADATA,sha256=v9y1O0h7GVkbGkHHjeX0nVZ1SpxJqe489FhQehy6zb0,9215
|
|
32
|
+
assignmentwriter-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
33
|
+
assignmentwriter-0.1.0.dist-info/entry_points.txt,sha256=TnOxv3Toq5M8O7pVbBWBAfZDaJ_sOGPhBUPXHRMa_Fk,68
|
|
34
|
+
assignmentwriter-0.1.0.dist-info/top_level.txt,sha256=co7qlkBoJDS00g7hBmeVthVA_vTzbo3xQitl3gRbd8c,17
|
|
35
|
+
assignmentwriter-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TheHritu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
assignmentwriter
|