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.
Files changed (35) hide show
  1. assignmentwriter/__init__.py +33 -0
  2. assignmentwriter/__main__.py +263 -0
  3. assignmentwriter/layouts.py +119 -0
  4. assignmentwriter/modules.py +447 -0
  5. assignmentwriter/res/fonts/AdineKirnberg.ttf +0 -0
  6. assignmentwriter/res/fonts/AlanisHand.ttf +0 -0
  7. assignmentwriter/res/fonts/Architex.ttf +0 -0
  8. assignmentwriter/res/fonts/GreatVibes-Regular.ttf +0 -0
  9. assignmentwriter/res/fonts/Hand_Note.otf +0 -0
  10. assignmentwriter/res/fonts/HomemadeApple.ttf +0 -0
  11. assignmentwriter/res/fonts/HoneyScript.ttf +0 -0
  12. assignmentwriter/res/fonts/JottFLF.ttf +0 -0
  13. assignmentwriter/res/fonts/NOTICE.md +26 -0
  14. assignmentwriter/res/fonts/Note_this.ttf +0 -0
  15. assignmentwriter/res/fonts/S_Hand.ttf +0 -0
  16. assignmentwriter/res/fonts/caveat.ttf +0 -0
  17. assignmentwriter/res/fonts/pleasewritemeasong.ttf +0 -0
  18. assignmentwriter/res/papers/a4paper.jpg +0 -0
  19. assignmentwriter/res/papers/cream.jpg +0 -0
  20. assignmentwriter/res/papers/cyan.jpg +0 -0
  21. assignmentwriter/res/papers/lightblue.jpg +0 -0
  22. assignmentwriter/res/papers/lightgreen.jpg +0 -0
  23. assignmentwriter/res/papers/lime.jpg +0 -0
  24. assignmentwriter/res/papers/lined.png +0 -0
  25. assignmentwriter/res/papers/orange.jpg +0 -0
  26. assignmentwriter/res/papers/pink.jpg +0 -0
  27. assignmentwriter/res/papers/rough.jpg +0 -0
  28. assignmentwriter/res/papers/spiral.png +0 -0
  29. assignmentwriter/res/papers/wrinkled.jpg +0 -0
  30. assignmentwriter-0.1.0.dist-info/METADATA +266 -0
  31. assignmentwriter-0.1.0.dist-info/RECORD +35 -0
  32. assignmentwriter-0.1.0.dist-info/WHEEL +5 -0
  33. assignmentwriter-0.1.0.dist-info/entry_points.txt +2 -0
  34. assignmentwriter-0.1.0.dist-info/licenses/LICENSE +21 -0
  35. 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
@@ -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
@@ -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
+ [![Version](https://img.shields.io/badge/Version-0.1.0-red)](https://pypi.org/project/assignmentwriter/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ assignmentwriter = assignmentwriter.__main__:main
@@ -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