khatt 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.
- khatt/__init__.py +67 -0
- khatt/cli.py +114 -0
- khatt/fonts/Amiri-Regular.ttf +0 -0
- khatt/fonts/Cairo-Regular.ttf +0 -0
- khatt/fonts/LICENSES/OFL-Amiri.txt +94 -0
- khatt/fonts/LICENSES/OFL-Cairo.txt +93 -0
- khatt/fonts/LICENSES/OFL-NotoNaskhArabic.txt +93 -0
- khatt/fonts/LICENSES/OFL-Rubik.txt +93 -0
- khatt/fonts/NotoNaskhArabic-Regular.ttf +0 -0
- khatt/fonts/Rubik-Regular.ttf +0 -0
- khatt/fonts/__init__.py +35 -0
- khatt/mapper.py +113 -0
- khatt/py.typed +0 -0
- khatt/raster.py +123 -0
- khatt/shaping.py +190 -0
- khatt-0.1.0.dist-info/METADATA +211 -0
- khatt-0.1.0.dist-info/RECORD +20 -0
- khatt-0.1.0.dist-info/WHEEL +4 -0
- khatt-0.1.0.dist-info/entry_points.txt +2 -0
- khatt-0.1.0.dist-info/licenses/LICENSE +27 -0
khatt/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Khatt (خَطّ) — render Arabic text as ASCII/Unicode-block art.
|
|
2
|
+
|
|
3
|
+
FIGlet-style per-character fonts cannot express Arabic contextual shaping,
|
|
4
|
+
mandatory ligatures or right-to-left layout, so khatt shapes text with
|
|
5
|
+
HarfBuzz, rasterizes it with a real Arabic font via FreeType, and maps pixel
|
|
6
|
+
coverage onto character ramps.
|
|
7
|
+
|
|
8
|
+
>>> import khatt
|
|
9
|
+
>>> print(khatt.render("مرحبا"))
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from khatt.fonts import available_fonts as list_fonts
|
|
15
|
+
from khatt.fonts import font_path
|
|
16
|
+
from khatt.mapper import available_styles as list_styles
|
|
17
|
+
from khatt.mapper import get_style, map_image
|
|
18
|
+
from khatt.raster import rasterize, to_grid
|
|
19
|
+
from khatt.shaping import UnsupportedCharacterWarning, shape
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"UnsupportedCharacterWarning",
|
|
25
|
+
"__version__",
|
|
26
|
+
"list_fonts",
|
|
27
|
+
"list_styles",
|
|
28
|
+
"render",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render(
|
|
33
|
+
text: str,
|
|
34
|
+
font: str = "amiri",
|
|
35
|
+
style: str = "blocks",
|
|
36
|
+
width: int = 80,
|
|
37
|
+
threshold: float | None = None,
|
|
38
|
+
*,
|
|
39
|
+
keep_tashkeel: bool = False,
|
|
40
|
+
) -> str:
|
|
41
|
+
"""Render Arabic (or mixed-direction) text as text art.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
text: The text to render; Arabic is shaped with correct positional
|
|
45
|
+
forms and ligatures, and mixed Arabic/Latin/digit strings come
|
|
46
|
+
out in visual order.
|
|
47
|
+
font: A bundled font name (see :func:`list_fonts`).
|
|
48
|
+
style: Output character set (see :func:`list_styles`).
|
|
49
|
+
width: Output width in terminal columns.
|
|
50
|
+
threshold: Optional coverage cutoff in [0, 1]: coverage below it
|
|
51
|
+
renders blank; for ``braille`` it is the per-dot cutoff.
|
|
52
|
+
keep_tashkeel: Keep Arabic diacritics instead of stripping them
|
|
53
|
+
(they are usually illegible at character-grid resolution).
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The rendered art, ``\\n``-separated, or an empty string when the
|
|
57
|
+
input contains nothing renderable. Characters the font does not
|
|
58
|
+
cover are skipped with an :class:`UnsupportedCharacterWarning`.
|
|
59
|
+
"""
|
|
60
|
+
path = font_path(font)
|
|
61
|
+
resolved_style = get_style(style)
|
|
62
|
+
glyphs = shape(text, path, keep_tashkeel=keep_tashkeel)
|
|
63
|
+
image = rasterize(glyphs, path)
|
|
64
|
+
if image is None:
|
|
65
|
+
return ""
|
|
66
|
+
grid = to_grid(image, width, cell=resolved_style.cell)
|
|
67
|
+
return map_image(grid, resolved_style.name, threshold)
|
khatt/cli.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""The ``khatt`` command-line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import warnings
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from PIL import ImageOps
|
|
11
|
+
|
|
12
|
+
import khatt
|
|
13
|
+
from khatt.fonts import available_fonts, font_path
|
|
14
|
+
from khatt.raster import rasterize
|
|
15
|
+
from khatt.shaping import shape
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(add_completion=False)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _configure_utf8_output() -> None:
|
|
21
|
+
"""Force UTF-8 on stdout/stderr; Windows consoles often default to a
|
|
22
|
+
legacy code page that cannot encode Arabic or block characters."""
|
|
23
|
+
for stream in (sys.stdout, sys.stderr):
|
|
24
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
25
|
+
if reconfigure is not None:
|
|
26
|
+
reconfigure(encoding="utf-8")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _save_png(text: str, font: str, keep_tashkeel: bool, target: Path) -> None:
|
|
30
|
+
"""PNG output saves the high-resolution rendered text (black on white),
|
|
31
|
+
not the character art — image viewers can't display text art anyway."""
|
|
32
|
+
path = font_path(font)
|
|
33
|
+
image = rasterize(shape(text, path, keep_tashkeel=keep_tashkeel), path)
|
|
34
|
+
if image is None: # unreachable normally: empty art exits earlier
|
|
35
|
+
raise typer.Exit(code=1)
|
|
36
|
+
ImageOps.invert(image).save(target)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command()
|
|
40
|
+
def main(
|
|
41
|
+
text: str | None = typer.Argument(
|
|
42
|
+
None,
|
|
43
|
+
help="Text to render. Omit (or pass '-') to read from stdin.",
|
|
44
|
+
show_default=False,
|
|
45
|
+
),
|
|
46
|
+
font: str = typer.Option("amiri", "--font", "-f", help="Bundled font name."),
|
|
47
|
+
style: str = typer.Option("blocks", "--style", "-s", help="Output style."),
|
|
48
|
+
width: int = typer.Option(80, "--width", "-w", min=1, help="Output width in columns."),
|
|
49
|
+
threshold: float | None = typer.Option(
|
|
50
|
+
None,
|
|
51
|
+
"--threshold",
|
|
52
|
+
"-t",
|
|
53
|
+
min=0.0,
|
|
54
|
+
max=1.0,
|
|
55
|
+
help="Coverage cutoff in [0, 1]; below it cells render blank.",
|
|
56
|
+
),
|
|
57
|
+
keep_tashkeel: bool = typer.Option(
|
|
58
|
+
False, "--keep-tashkeel", help="Keep Arabic diacritics instead of stripping them."
|
|
59
|
+
),
|
|
60
|
+
output: Path | None = typer.Option(
|
|
61
|
+
None,
|
|
62
|
+
"--output",
|
|
63
|
+
"-o",
|
|
64
|
+
help="Write to a file: .png saves the rendered text as an image, "
|
|
65
|
+
"any other extension saves the text art.",
|
|
66
|
+
),
|
|
67
|
+
list_fonts: bool = typer.Option(False, "--list-fonts", help="List bundled fonts and exit."),
|
|
68
|
+
list_styles: bool = typer.Option(False, "--list-styles", help="List styles and exit."),
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Render Arabic text as ASCII/Unicode-block art."""
|
|
71
|
+
_configure_utf8_output()
|
|
72
|
+
|
|
73
|
+
if list_fonts:
|
|
74
|
+
for name in available_fonts():
|
|
75
|
+
typer.echo(name)
|
|
76
|
+
raise typer.Exit()
|
|
77
|
+
if list_styles:
|
|
78
|
+
for name in khatt.list_styles():
|
|
79
|
+
typer.echo(name)
|
|
80
|
+
raise typer.Exit()
|
|
81
|
+
|
|
82
|
+
if text is None or text == "-":
|
|
83
|
+
text = sys.stdin.read()
|
|
84
|
+
text = text.rstrip("\n")
|
|
85
|
+
|
|
86
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
87
|
+
warnings.simplefilter("always")
|
|
88
|
+
try:
|
|
89
|
+
art = khatt.render(
|
|
90
|
+
text,
|
|
91
|
+
font=font,
|
|
92
|
+
style=style,
|
|
93
|
+
width=width,
|
|
94
|
+
threshold=threshold,
|
|
95
|
+
keep_tashkeel=keep_tashkeel,
|
|
96
|
+
)
|
|
97
|
+
except KeyError as exc:
|
|
98
|
+
typer.secho(f"error: {exc.args[0]}", err=True, fg=typer.colors.RED)
|
|
99
|
+
raise typer.Exit(code=2) from exc
|
|
100
|
+
for warning in caught:
|
|
101
|
+
typer.secho(f"warning: {warning.message}", err=True, fg=typer.colors.YELLOW)
|
|
102
|
+
|
|
103
|
+
if not art and text.strip():
|
|
104
|
+
typer.secho("error: no renderable characters in input", err=True, fg=typer.colors.RED)
|
|
105
|
+
raise typer.Exit(code=1)
|
|
106
|
+
|
|
107
|
+
if output is None:
|
|
108
|
+
typer.echo(art)
|
|
109
|
+
elif output.suffix.lower() == ".png":
|
|
110
|
+
_save_png(text, font, keep_tashkeel, output)
|
|
111
|
+
typer.secho(f"wrote {output}", err=True)
|
|
112
|
+
else:
|
|
113
|
+
output.write_text(art + "\n", encoding="utf-8")
|
|
114
|
+
typer.secho(f"wrote {output}", err=True)
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Copyright 2010-2022 The Amiri Project Authors (https://github.com/aliftype/amiri).
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
This license is copied below, and is also available with a FAQ at:
|
|
5
|
+
https://openfontlicense.org/
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
-----------------------------------------------------------
|
|
9
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
PREAMBLE
|
|
13
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
14
|
+
development of collaborative font projects, to support the font creation
|
|
15
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
16
|
+
open framework in which fonts may be shared and improved in partnership
|
|
17
|
+
with others.
|
|
18
|
+
|
|
19
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
20
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
21
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
22
|
+
redistributed and/or sold with any software provided that any reserved
|
|
23
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
24
|
+
however, cannot be released under any other type of license. The
|
|
25
|
+
requirement for fonts to remain under this license does not apply
|
|
26
|
+
to any document created using the fonts or their derivatives.
|
|
27
|
+
|
|
28
|
+
DEFINITIONS
|
|
29
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
30
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
31
|
+
include source files, build scripts and documentation.
|
|
32
|
+
|
|
33
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
34
|
+
copyright statement(s).
|
|
35
|
+
|
|
36
|
+
"Original Version" refers to the collection of Font Software components as
|
|
37
|
+
distributed by the Copyright Holder(s).
|
|
38
|
+
|
|
39
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
40
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
41
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
42
|
+
new environment.
|
|
43
|
+
|
|
44
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
45
|
+
writer or other person who contributed to the Font Software.
|
|
46
|
+
|
|
47
|
+
PERMISSION & CONDITIONS
|
|
48
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
49
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
50
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
51
|
+
Software, subject to the following conditions:
|
|
52
|
+
|
|
53
|
+
1) Neither the Font Software nor any of its individual components,
|
|
54
|
+
in Original or Modified Versions, may be sold by itself.
|
|
55
|
+
|
|
56
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
57
|
+
redistributed and/or sold with any software, provided that each copy
|
|
58
|
+
contains the above copyright notice and this license. These can be
|
|
59
|
+
included either as stand-alone text files, human-readable headers or
|
|
60
|
+
in the appropriate machine-readable metadata fields within text or
|
|
61
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
62
|
+
|
|
63
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
64
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
65
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
66
|
+
presented to the users.
|
|
67
|
+
|
|
68
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
69
|
+
Software shall not be used to promote, endorse or advertise any
|
|
70
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
71
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
72
|
+
permission.
|
|
73
|
+
|
|
74
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
75
|
+
must be distributed entirely under this license, and must not be
|
|
76
|
+
distributed under any other license. The requirement for fonts to
|
|
77
|
+
remain under this license does not apply to any document created
|
|
78
|
+
using the Font Software.
|
|
79
|
+
|
|
80
|
+
TERMINATION
|
|
81
|
+
This license becomes null and void if any of the above conditions are
|
|
82
|
+
not met.
|
|
83
|
+
|
|
84
|
+
DISCLAIMER
|
|
85
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
86
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
87
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
88
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
89
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
90
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
91
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
92
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
93
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
94
|
+
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Copyright 2009 The Cairo Project Authors (https://github.com/Gue3bara/Cairo)
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
This license is copied below, and is also available with a FAQ at:
|
|
5
|
+
http://scripts.sil.org/OFL
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
-----------------------------------------------------------
|
|
9
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
PREAMBLE
|
|
13
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
14
|
+
development of collaborative font projects, to support the font creation
|
|
15
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
16
|
+
open framework in which fonts may be shared and improved in partnership
|
|
17
|
+
with others.
|
|
18
|
+
|
|
19
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
20
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
21
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
22
|
+
redistributed and/or sold with any software provided that any reserved
|
|
23
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
24
|
+
however, cannot be released under any other type of license. The
|
|
25
|
+
requirement for fonts to remain under this license does not apply
|
|
26
|
+
to any document created using the fonts or their derivatives.
|
|
27
|
+
|
|
28
|
+
DEFINITIONS
|
|
29
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
30
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
31
|
+
include source files, build scripts and documentation.
|
|
32
|
+
|
|
33
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
34
|
+
copyright statement(s).
|
|
35
|
+
|
|
36
|
+
"Original Version" refers to the collection of Font Software components as
|
|
37
|
+
distributed by the Copyright Holder(s).
|
|
38
|
+
|
|
39
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
40
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
41
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
42
|
+
new environment.
|
|
43
|
+
|
|
44
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
45
|
+
writer or other person who contributed to the Font Software.
|
|
46
|
+
|
|
47
|
+
PERMISSION & CONDITIONS
|
|
48
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
49
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
50
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
51
|
+
Software, subject to the following conditions:
|
|
52
|
+
|
|
53
|
+
1) Neither the Font Software nor any of its individual components,
|
|
54
|
+
in Original or Modified Versions, may be sold by itself.
|
|
55
|
+
|
|
56
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
57
|
+
redistributed and/or sold with any software, provided that each copy
|
|
58
|
+
contains the above copyright notice and this license. These can be
|
|
59
|
+
included either as stand-alone text files, human-readable headers or
|
|
60
|
+
in the appropriate machine-readable metadata fields within text or
|
|
61
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
62
|
+
|
|
63
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
64
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
65
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
66
|
+
presented to the users.
|
|
67
|
+
|
|
68
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
69
|
+
Software shall not be used to promote, endorse or advertise any
|
|
70
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
71
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
72
|
+
permission.
|
|
73
|
+
|
|
74
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
75
|
+
must be distributed entirely under this license, and must not be
|
|
76
|
+
distributed under any other license. The requirement for fonts to
|
|
77
|
+
remain under this license does not apply to any document created
|
|
78
|
+
using the Font Software.
|
|
79
|
+
|
|
80
|
+
TERMINATION
|
|
81
|
+
This license becomes null and void if any of the above conditions are
|
|
82
|
+
not met.
|
|
83
|
+
|
|
84
|
+
DISCLAIMER
|
|
85
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
86
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
87
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
88
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
89
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
90
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
91
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
92
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
93
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/arabic)
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
This license is copied below, and is also available with a FAQ at:
|
|
5
|
+
https://openfontlicense.org
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
-----------------------------------------------------------
|
|
9
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
PREAMBLE
|
|
13
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
14
|
+
development of collaborative font projects, to support the font creation
|
|
15
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
16
|
+
open framework in which fonts may be shared and improved in partnership
|
|
17
|
+
with others.
|
|
18
|
+
|
|
19
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
20
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
21
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
22
|
+
redistributed and/or sold with any software provided that any reserved
|
|
23
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
24
|
+
however, cannot be released under any other type of license. The
|
|
25
|
+
requirement for fonts to remain under this license does not apply
|
|
26
|
+
to any document created using the fonts or their derivatives.
|
|
27
|
+
|
|
28
|
+
DEFINITIONS
|
|
29
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
30
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
31
|
+
include source files, build scripts and documentation.
|
|
32
|
+
|
|
33
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
34
|
+
copyright statement(s).
|
|
35
|
+
|
|
36
|
+
"Original Version" refers to the collection of Font Software components as
|
|
37
|
+
distributed by the Copyright Holder(s).
|
|
38
|
+
|
|
39
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
40
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
41
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
42
|
+
new environment.
|
|
43
|
+
|
|
44
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
45
|
+
writer or other person who contributed to the Font Software.
|
|
46
|
+
|
|
47
|
+
PERMISSION & CONDITIONS
|
|
48
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
49
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
50
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
51
|
+
Software, subject to the following conditions:
|
|
52
|
+
|
|
53
|
+
1) Neither the Font Software nor any of its individual components,
|
|
54
|
+
in Original or Modified Versions, may be sold by itself.
|
|
55
|
+
|
|
56
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
57
|
+
redistributed and/or sold with any software, provided that each copy
|
|
58
|
+
contains the above copyright notice and this license. These can be
|
|
59
|
+
included either as stand-alone text files, human-readable headers or
|
|
60
|
+
in the appropriate machine-readable metadata fields within text or
|
|
61
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
62
|
+
|
|
63
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
64
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
65
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
66
|
+
presented to the users.
|
|
67
|
+
|
|
68
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
69
|
+
Software shall not be used to promote, endorse or advertise any
|
|
70
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
71
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
72
|
+
permission.
|
|
73
|
+
|
|
74
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
75
|
+
must be distributed entirely under this license, and must not be
|
|
76
|
+
distributed under any other license. The requirement for fonts to
|
|
77
|
+
remain under this license does not apply to any document created
|
|
78
|
+
using the Font Software.
|
|
79
|
+
|
|
80
|
+
TERMINATION
|
|
81
|
+
This license becomes null and void if any of the above conditions are
|
|
82
|
+
not met.
|
|
83
|
+
|
|
84
|
+
DISCLAIMER
|
|
85
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
86
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
87
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
88
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
89
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
90
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
91
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
92
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
93
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Copyright 2015 The Rubik Project Authors (https://github.com/googlefonts/rubik)
|
|
2
|
+
|
|
3
|
+
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
|
4
|
+
This license is copied below, and is also available with a FAQ at:
|
|
5
|
+
https://scripts.sil.org/OFL
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
-----------------------------------------------------------
|
|
9
|
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
|
10
|
+
-----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
PREAMBLE
|
|
13
|
+
The goals of the Open Font License (OFL) are to stimulate worldwide
|
|
14
|
+
development of collaborative font projects, to support the font creation
|
|
15
|
+
efforts of academic and linguistic communities, and to provide a free and
|
|
16
|
+
open framework in which fonts may be shared and improved in partnership
|
|
17
|
+
with others.
|
|
18
|
+
|
|
19
|
+
The OFL allows the licensed fonts to be used, studied, modified and
|
|
20
|
+
redistributed freely as long as they are not sold by themselves. The
|
|
21
|
+
fonts, including any derivative works, can be bundled, embedded,
|
|
22
|
+
redistributed and/or sold with any software provided that any reserved
|
|
23
|
+
names are not used by derivative works. The fonts and derivatives,
|
|
24
|
+
however, cannot be released under any other type of license. The
|
|
25
|
+
requirement for fonts to remain under this license does not apply
|
|
26
|
+
to any document created using the fonts or their derivatives.
|
|
27
|
+
|
|
28
|
+
DEFINITIONS
|
|
29
|
+
"Font Software" refers to the set of files released by the Copyright
|
|
30
|
+
Holder(s) under this license and clearly marked as such. This may
|
|
31
|
+
include source files, build scripts and documentation.
|
|
32
|
+
|
|
33
|
+
"Reserved Font Name" refers to any names specified as such after the
|
|
34
|
+
copyright statement(s).
|
|
35
|
+
|
|
36
|
+
"Original Version" refers to the collection of Font Software components as
|
|
37
|
+
distributed by the Copyright Holder(s).
|
|
38
|
+
|
|
39
|
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
|
40
|
+
or substituting -- in part or in whole -- any of the components of the
|
|
41
|
+
Original Version, by changing formats or by porting the Font Software to a
|
|
42
|
+
new environment.
|
|
43
|
+
|
|
44
|
+
"Author" refers to any designer, engineer, programmer, technical
|
|
45
|
+
writer or other person who contributed to the Font Software.
|
|
46
|
+
|
|
47
|
+
PERMISSION & CONDITIONS
|
|
48
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
49
|
+
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
|
50
|
+
redistribute, and sell modified and unmodified copies of the Font
|
|
51
|
+
Software, subject to the following conditions:
|
|
52
|
+
|
|
53
|
+
1) Neither the Font Software nor any of its individual components,
|
|
54
|
+
in Original or Modified Versions, may be sold by itself.
|
|
55
|
+
|
|
56
|
+
2) Original or Modified Versions of the Font Software may be bundled,
|
|
57
|
+
redistributed and/or sold with any software, provided that each copy
|
|
58
|
+
contains the above copyright notice and this license. These can be
|
|
59
|
+
included either as stand-alone text files, human-readable headers or
|
|
60
|
+
in the appropriate machine-readable metadata fields within text or
|
|
61
|
+
binary files as long as those fields can be easily viewed by the user.
|
|
62
|
+
|
|
63
|
+
3) No Modified Version of the Font Software may use the Reserved Font
|
|
64
|
+
Name(s) unless explicit written permission is granted by the corresponding
|
|
65
|
+
Copyright Holder. This restriction only applies to the primary font name as
|
|
66
|
+
presented to the users.
|
|
67
|
+
|
|
68
|
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
|
69
|
+
Software shall not be used to promote, endorse or advertise any
|
|
70
|
+
Modified Version, except to acknowledge the contribution(s) of the
|
|
71
|
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
|
72
|
+
permission.
|
|
73
|
+
|
|
74
|
+
5) The Font Software, modified or unmodified, in part or in whole,
|
|
75
|
+
must be distributed entirely under this license, and must not be
|
|
76
|
+
distributed under any other license. The requirement for fonts to
|
|
77
|
+
remain under this license does not apply to any document created
|
|
78
|
+
using the Font Software.
|
|
79
|
+
|
|
80
|
+
TERMINATION
|
|
81
|
+
This license becomes null and void if any of the above conditions are
|
|
82
|
+
not met.
|
|
83
|
+
|
|
84
|
+
DISCLAIMER
|
|
85
|
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
86
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
|
87
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
|
88
|
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
|
89
|
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
90
|
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
|
91
|
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
92
|
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
|
93
|
+
OTHER DEALINGS IN THE FONT SOFTWARE.
|
|
Binary file
|
|
Binary file
|
khatt/fonts/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Bundled open-license Arabic fonts.
|
|
2
|
+
|
|
3
|
+
Font binaries live in this package directory; their SIL Open Font License
|
|
4
|
+
texts are preserved under ``LICENSES/``. The MIT license of the khatt code
|
|
5
|
+
does not apply to these files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
_FONT_DIR = Path(__file__).resolve().parent
|
|
13
|
+
|
|
14
|
+
# Cairo, Noto Naskh Arabic and Rubik are variable-font builds used at their
|
|
15
|
+
# default (Regular) instances; Amiri is a static build.
|
|
16
|
+
_REGISTRY: dict[str, str] = {
|
|
17
|
+
"amiri": "Amiri-Regular.ttf",
|
|
18
|
+
"cairo": "Cairo-Regular.ttf",
|
|
19
|
+
"noto-naskh": "NotoNaskhArabic-Regular.ttf",
|
|
20
|
+
"rubik": "Rubik-Regular.ttf",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def font_path(name: str) -> Path:
|
|
25
|
+
"""Return the path to a bundled font by registry name (case-insensitive)."""
|
|
26
|
+
key = name.strip().lower()
|
|
27
|
+
if key not in _REGISTRY:
|
|
28
|
+
available = ", ".join(sorted(_REGISTRY))
|
|
29
|
+
raise KeyError(f"unknown font {name!r}; available fonts: {available}")
|
|
30
|
+
return _FONT_DIR / _REGISTRY[key]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def available_fonts() -> list[str]:
|
|
34
|
+
"""Return the sorted names of all bundled fonts."""
|
|
35
|
+
return sorted(_REGISTRY)
|
khatt/mapper.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Map grayscale coverage grids to text-art characters.
|
|
2
|
+
|
|
3
|
+
Input images use ink-coverage convention: pixel value 0 is empty background,
|
|
4
|
+
255 is fully inked. Higher coverage maps to a denser character, monotonically.
|
|
5
|
+
|
|
6
|
+
``threshold`` (in [0, 1]) forces coverage below it to blank and rescales the
|
|
7
|
+
remaining range across the ramp; for ``braille`` it is the per-dot on/off
|
|
8
|
+
cutoff (default 0.5).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from PIL import Image
|
|
16
|
+
|
|
17
|
+
_BLANKS = " ⠀" # characters trimmed from line ends
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Style:
|
|
22
|
+
"""A named output style: a character ramp or a dot-matrix mapping."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
cell: tuple[int, int]
|
|
26
|
+
"""Pixels consumed per output character, (x, y)."""
|
|
27
|
+
ramp: str | None
|
|
28
|
+
"""Blank-to-dense character ramp; ``None`` means braille dot mapping."""
|
|
29
|
+
description: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
STYLES: dict[str, Style] = {
|
|
33
|
+
"blocks": Style("blocks", (1, 1), " ░▒▓█", "Unicode shade blocks"),
|
|
34
|
+
"ascii": Style("ascii", (1, 1), " .:-=+*#%@", "classic ASCII density ramp"),
|
|
35
|
+
"braille": Style("braille", (2, 4), None, "braille patterns, 2x4 dots per cell"),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Braille bit assignment per (dot column, dot row) within a 2x4 cell.
|
|
39
|
+
_BRAILLE_BITS = {
|
|
40
|
+
(0, 0): 0x01,
|
|
41
|
+
(0, 1): 0x02,
|
|
42
|
+
(0, 2): 0x04,
|
|
43
|
+
(0, 3): 0x40,
|
|
44
|
+
(1, 0): 0x08,
|
|
45
|
+
(1, 1): 0x10,
|
|
46
|
+
(1, 2): 0x20,
|
|
47
|
+
(1, 3): 0x80,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def available_styles() -> list[str]:
|
|
52
|
+
"""Return the sorted names of all output styles."""
|
|
53
|
+
return sorted(STYLES)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_style(name: str) -> Style:
|
|
57
|
+
"""Look up a style by name (case-insensitive)."""
|
|
58
|
+
key = name.strip().lower()
|
|
59
|
+
if key not in STYLES:
|
|
60
|
+
available = ", ".join(sorted(STYLES))
|
|
61
|
+
raise KeyError(f"unknown style {name!r}; available styles: {available}")
|
|
62
|
+
return STYLES[key]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _ramp_char(ramp: str, coverage: float, threshold: float | None) -> str:
|
|
66
|
+
if threshold is not None:
|
|
67
|
+
if coverage < threshold or threshold >= 1.0:
|
|
68
|
+
coverage = 0.0
|
|
69
|
+
else:
|
|
70
|
+
coverage = (coverage - threshold) / (1.0 - threshold)
|
|
71
|
+
index = min(int(coverage * len(ramp)), len(ramp) - 1)
|
|
72
|
+
return ramp[index]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def map_image(image: Image.Image, style: str, threshold: float | None = None) -> str:
|
|
76
|
+
"""Map a grayscale grid image to lines of text-art characters.
|
|
77
|
+
|
|
78
|
+
The image size must be an exact multiple of the style's cell size; use
|
|
79
|
+
:func:`khatt.raster.to_grid` with the style's ``cell`` to produce it.
|
|
80
|
+
"""
|
|
81
|
+
resolved = get_style(style)
|
|
82
|
+
if threshold is not None and not 0.0 <= threshold <= 1.0:
|
|
83
|
+
raise ValueError(f"threshold must be within [0, 1], got {threshold}")
|
|
84
|
+
|
|
85
|
+
dx, dy = resolved.cell
|
|
86
|
+
width, height = image.size
|
|
87
|
+
if width % dx or height % dy:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"image size {width}x{height} is not a multiple of the "
|
|
90
|
+
f"{resolved.name} cell size {dx}x{dy}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
gray = image if image.mode == "L" else image.convert("L")
|
|
94
|
+
data = gray.tobytes() # row-major coverage bytes
|
|
95
|
+
|
|
96
|
+
def coverage_at(x: int, y: int) -> float:
|
|
97
|
+
return data[y * width + x] / 255.0
|
|
98
|
+
|
|
99
|
+
dot_cutoff = 0.5 if threshold is None else threshold
|
|
100
|
+
lines: list[str] = []
|
|
101
|
+
for row in range(height // dy):
|
|
102
|
+
chars: list[str] = []
|
|
103
|
+
for col in range(width // dx):
|
|
104
|
+
if resolved.ramp is not None:
|
|
105
|
+
chars.append(_ramp_char(resolved.ramp, coverage_at(col, row), threshold))
|
|
106
|
+
else:
|
|
107
|
+
code = 0x2800
|
|
108
|
+
for (dot_x, dot_y), bit in _BRAILLE_BITS.items():
|
|
109
|
+
if coverage_at(col * dx + dot_x, row * dy + dot_y) >= dot_cutoff:
|
|
110
|
+
code |= bit
|
|
111
|
+
chars.append(chr(code))
|
|
112
|
+
lines.append("".join(chars).rstrip(_BLANKS))
|
|
113
|
+
return "\n".join(lines)
|
khatt/py.typed
ADDED
|
File without changes
|
khatt/raster.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Rasterize shaped glyphs to grayscale bitmaps and character grids.
|
|
2
|
+
|
|
3
|
+
Glyphs are rendered individually by FreeType at the pen positions computed by
|
|
4
|
+
HarfBuzz, max-blended onto a high-resolution canvas (Arabic joins overlap, so
|
|
5
|
+
plain pasting would clip strokes), cropped to the ink extents, and finally
|
|
6
|
+
downsampled to the requested character grid.
|
|
7
|
+
|
|
8
|
+
Determinism: hinting is disabled, pen positions are rounded identically
|
|
9
|
+
everywhere, the freetype-py wheel bundles its own libfreetype, and font files
|
|
10
|
+
are bundled — so output is byte-identical across platforms for a locked
|
|
11
|
+
dependency set (this is what makes the golden-file tests portable).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import io
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import freetype
|
|
21
|
+
from PIL import Image, ImageChops
|
|
22
|
+
|
|
23
|
+
from khatt.shaping import ShapedGlyph, units_per_em
|
|
24
|
+
|
|
25
|
+
RENDER_PPEM = 256
|
|
26
|
+
"""Pixels per em of the high-resolution render stage."""
|
|
27
|
+
|
|
28
|
+
CELL_ASPECT = 0.5
|
|
29
|
+
"""Assumed terminal cell width:height ratio (cells are twice as tall as wide)."""
|
|
30
|
+
|
|
31
|
+
_LOAD_FLAGS = freetype.FT_LOAD_RENDER | freetype.FT_LOAD_NO_HINTING
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@lru_cache(maxsize=8)
|
|
35
|
+
def _face(path: str) -> freetype.Face:
|
|
36
|
+
# Load via a byte stream: freetype's own file handling can trip over
|
|
37
|
+
# non-ASCII paths on Windows.
|
|
38
|
+
face = freetype.Face(io.BytesIO(Path(path).read_bytes()))
|
|
39
|
+
face.set_pixel_sizes(0, RENDER_PPEM)
|
|
40
|
+
return face
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _bitmap_to_image(bitmap: freetype.Bitmap) -> Image.Image:
|
|
44
|
+
pitch, width, rows = bitmap.pitch, bitmap.width, bitmap.rows
|
|
45
|
+
data = bytes(bitmap.buffer)
|
|
46
|
+
if pitch != width:
|
|
47
|
+
data = b"".join(data[r * pitch : r * pitch + width] for r in range(rows))
|
|
48
|
+
return Image.frombytes("L", (width, rows), data)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _blend_max(canvas: Image.Image, glyph_image: Image.Image, x: int, y: int) -> None:
|
|
52
|
+
"""Blend a glyph bitmap onto the canvas keeping the maximum coverage."""
|
|
53
|
+
canvas_w, canvas_h = canvas.size
|
|
54
|
+
width, height = glyph_image.size
|
|
55
|
+
x0, y0 = max(x, 0), max(y, 0)
|
|
56
|
+
x1, y1 = min(x + width, canvas_w), min(y + height, canvas_h)
|
|
57
|
+
if x0 >= x1 or y0 >= y1:
|
|
58
|
+
return
|
|
59
|
+
source = glyph_image.crop((x0 - x, y0 - y, x1 - x, y1 - y))
|
|
60
|
+
region = canvas.crop((x0, y0, x1, y1))
|
|
61
|
+
canvas.paste(ImageChops.lighter(region, source), (x0, y0))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def rasterize(glyphs: list[ShapedGlyph], font_path: str | Path) -> Image.Image | None:
|
|
65
|
+
"""Render shaped glyphs to a grayscale image cropped to the ink extents.
|
|
66
|
+
|
|
67
|
+
Returns ``None`` when there is nothing to draw (no glyphs, or only
|
|
68
|
+
ink-less glyphs such as spaces).
|
|
69
|
+
"""
|
|
70
|
+
if not glyphs:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
path = str(font_path)
|
|
74
|
+
face = _face(path)
|
|
75
|
+
scale = RENDER_PPEM / units_per_em(path)
|
|
76
|
+
|
|
77
|
+
ascender = face.size.ascender >> 6
|
|
78
|
+
descender = abs(face.size.descender >> 6)
|
|
79
|
+
total_advance = sum(g.x_advance for g in glyphs) * scale
|
|
80
|
+
# Generous margin: swashes and mark stacks can extend well past both the
|
|
81
|
+
# advance width and the nominal ascender/descender.
|
|
82
|
+
margin = RENDER_PPEM
|
|
83
|
+
canvas = Image.new(
|
|
84
|
+
"L",
|
|
85
|
+
(int(total_advance) + 2 * margin, ascender + descender + 2 * margin),
|
|
86
|
+
0,
|
|
87
|
+
)
|
|
88
|
+
baseline = margin + ascender
|
|
89
|
+
|
|
90
|
+
pen_x = float(margin)
|
|
91
|
+
for glyph in glyphs:
|
|
92
|
+
face.load_glyph(glyph.gid, _LOAD_FLAGS)
|
|
93
|
+
slot = face.glyph
|
|
94
|
+
if slot.bitmap.width and slot.bitmap.rows:
|
|
95
|
+
x = round(pen_x + glyph.x_offset * scale) + slot.bitmap_left
|
|
96
|
+
y = baseline - slot.bitmap_top - round(glyph.y_offset * scale)
|
|
97
|
+
_blend_max(canvas, _bitmap_to_image(slot.bitmap), x, y)
|
|
98
|
+
pen_x += glyph.x_advance * scale
|
|
99
|
+
|
|
100
|
+
bbox = canvas.getbbox()
|
|
101
|
+
if bbox is None:
|
|
102
|
+
return None
|
|
103
|
+
return canvas.crop(bbox)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def to_grid(
|
|
107
|
+
image: Image.Image,
|
|
108
|
+
columns: int,
|
|
109
|
+
cell: tuple[int, int] = (1, 1),
|
|
110
|
+
cell_aspect: float = CELL_ASPECT,
|
|
111
|
+
) -> Image.Image:
|
|
112
|
+
"""Downsample an ink image to a character grid of ``columns`` cells.
|
|
113
|
+
|
|
114
|
+
The row count preserves the ink aspect ratio corrected by ``cell_aspect``;
|
|
115
|
+
the returned image measures ``columns * cell[0]`` by ``rows * cell[1]``
|
|
116
|
+
pixels, one pixel per ramp character or braille dot.
|
|
117
|
+
"""
|
|
118
|
+
if columns < 1:
|
|
119
|
+
raise ValueError(f"columns must be at least 1, got {columns}")
|
|
120
|
+
width, height = image.size
|
|
121
|
+
rows = max(1, round((height / width) * columns * cell_aspect))
|
|
122
|
+
dx, dy = cell
|
|
123
|
+
return image.resize((columns * dx, rows * dy), Image.Resampling.LANCZOS)
|
khatt/shaping.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Arabic text shaping via HarfBuzz.
|
|
2
|
+
|
|
3
|
+
Pipeline: ``python-bidi`` computes the visual (display) order of the whole
|
|
4
|
+
string, the visual string is split into directional segments, RTL segments are
|
|
5
|
+
un-reversed back to logical order, and each segment is shaped by HarfBuzz with
|
|
6
|
+
the appropriate direction. The resulting glyphs are returned in visual order,
|
|
7
|
+
left to right, with positions in font units (scaled to units-per-em).
|
|
8
|
+
|
|
9
|
+
Bidi handling relies on python-bidi's implementation of the Unicode
|
|
10
|
+
Bidirectional Algorithm for reordering; the segment classification here only
|
|
11
|
+
distinguishes right-to-left letters from everything else, so explicit bidi
|
|
12
|
+
control characters (LRE/RLE/…) are not given special treatment.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import unicodedata
|
|
18
|
+
import warnings
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from functools import lru_cache
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import uharfbuzz as hb
|
|
24
|
+
from bidi import get_display
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UnsupportedCharacterWarning(UserWarning):
|
|
28
|
+
"""A character has no glyph in the requested font and was skipped."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Arabic diacritical marks (tashkeel). Tatweel (U+0640) is deliberately not
|
|
32
|
+
# included: it is an elongation character, not a diacritic.
|
|
33
|
+
_TASHKEEL = frozenset(
|
|
34
|
+
chr(cp)
|
|
35
|
+
for start, end in (
|
|
36
|
+
(0x0610, 0x061A),
|
|
37
|
+
(0x064B, 0x065F),
|
|
38
|
+
(0x0670, 0x0670),
|
|
39
|
+
(0x06D6, 0x06DC),
|
|
40
|
+
(0x06DF, 0x06E4),
|
|
41
|
+
(0x06E7, 0x06E8),
|
|
42
|
+
(0x06EA, 0x06ED),
|
|
43
|
+
)
|
|
44
|
+
for cp in range(start, end + 1)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_RTL_BIDI_CLASSES = frozenset({"AL", "R"})
|
|
48
|
+
# Strong LTR and the digit classes: these must keep left-to-right order.
|
|
49
|
+
_LTR_BIDI_CLASSES = frozenset({"L", "EN", "AN"})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ShapedGlyph:
|
|
54
|
+
"""One positioned glyph, in visual order. Distances are in font units."""
|
|
55
|
+
|
|
56
|
+
gid: int
|
|
57
|
+
cluster: int
|
|
58
|
+
x_advance: int
|
|
59
|
+
y_advance: int
|
|
60
|
+
x_offset: int
|
|
61
|
+
y_offset: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def strip_tashkeel(text: str) -> str:
|
|
65
|
+
"""Remove Arabic diacritical marks (harakat, tanween, shadda, sukun…)."""
|
|
66
|
+
return "".join(ch for ch in text if ch not in _TASHKEEL)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@lru_cache(maxsize=8)
|
|
70
|
+
def _load_font(path: str) -> hb.Font:
|
|
71
|
+
blob = hb.Blob.from_file_path(path)
|
|
72
|
+
face = hb.Face(blob)
|
|
73
|
+
return hb.Font(face)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _classify(visual: str) -> list[str]:
|
|
77
|
+
"""Tag each character of the visual string as ``rtl``, ``ltr`` or ``neutral``.
|
|
78
|
+
|
|
79
|
+
Combining marks (NSM) and neutrals are resolved afterwards: marks join the
|
|
80
|
+
directional segment of their neighbours, neutral runs flanked by RTL on
|
|
81
|
+
both sides join the RTL segment, everything else defaults to LTR.
|
|
82
|
+
"""
|
|
83
|
+
kinds: list[str] = []
|
|
84
|
+
for ch in visual:
|
|
85
|
+
bc = unicodedata.bidirectional(ch)
|
|
86
|
+
if bc in _RTL_BIDI_CLASSES:
|
|
87
|
+
kinds.append("rtl")
|
|
88
|
+
elif bc == "NSM":
|
|
89
|
+
kinds.append("mark")
|
|
90
|
+
elif bc in _LTR_BIDI_CLASSES:
|
|
91
|
+
kinds.append("ltr")
|
|
92
|
+
else:
|
|
93
|
+
kinds.append("neutral")
|
|
94
|
+
|
|
95
|
+
resolved: list[str] = []
|
|
96
|
+
n = len(kinds)
|
|
97
|
+
for i, kind in enumerate(kinds):
|
|
98
|
+
if kind in ("mark", "neutral"):
|
|
99
|
+
prev = resolved[i - 1] if i > 0 else None
|
|
100
|
+
nxt = next((k for k in kinds[i + 1 : n] if k not in ("mark", "neutral")), None)
|
|
101
|
+
if kind == "mark":
|
|
102
|
+
resolved.append("rtl" if "rtl" in (prev, nxt) else "ltr")
|
|
103
|
+
else:
|
|
104
|
+
resolved.append("rtl" if prev == "rtl" and nxt == "rtl" else "ltr")
|
|
105
|
+
else:
|
|
106
|
+
resolved.append(kind)
|
|
107
|
+
return resolved
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _visual_segments(text: str) -> list[tuple[str, str]]:
|
|
111
|
+
"""Split text into ``(logical_segment, direction)`` pairs in visual order."""
|
|
112
|
+
visual = get_display(text)
|
|
113
|
+
kinds = _classify(visual)
|
|
114
|
+
|
|
115
|
+
segments: list[tuple[str, str]] = []
|
|
116
|
+
start = 0
|
|
117
|
+
for i in range(1, len(visual) + 1):
|
|
118
|
+
if i == len(visual) or kinds[i] != kinds[start]:
|
|
119
|
+
chunk = visual[start:i]
|
|
120
|
+
if kinds[start] == "rtl":
|
|
121
|
+
# RTL segments arrive reversed in the visual string; restore
|
|
122
|
+
# logical order for HarfBuzz, which expects logical input.
|
|
123
|
+
segments.append((chunk[::-1], "rtl"))
|
|
124
|
+
else:
|
|
125
|
+
segments.append((chunk, "ltr"))
|
|
126
|
+
start = i
|
|
127
|
+
return segments
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _shape_segment(font: hb.Font, segment: str, direction: str) -> list[ShapedGlyph]:
|
|
131
|
+
buf = hb.Buffer()
|
|
132
|
+
buf.add_str(segment)
|
|
133
|
+
buf.guess_segment_properties()
|
|
134
|
+
buf.direction = direction
|
|
135
|
+
if direction == "rtl":
|
|
136
|
+
buf.script = "Arab"
|
|
137
|
+
buf.language = "ar"
|
|
138
|
+
hb.shape(font, buf, None)
|
|
139
|
+
|
|
140
|
+
glyphs: list[ShapedGlyph] = []
|
|
141
|
+
for info, pos in zip(buf.glyph_infos, buf.glyph_positions, strict=True):
|
|
142
|
+
if info.codepoint == 0: # .notdef — the font has no glyph for this
|
|
143
|
+
offender = segment[info.cluster] if info.cluster < len(segment) else "?"
|
|
144
|
+
warnings.warn(
|
|
145
|
+
f"character {offender!r} (U+{ord(offender):04X}) is not supported "
|
|
146
|
+
"by the selected font and was skipped",
|
|
147
|
+
UnsupportedCharacterWarning,
|
|
148
|
+
stacklevel=3,
|
|
149
|
+
)
|
|
150
|
+
continue
|
|
151
|
+
glyphs.append(
|
|
152
|
+
ShapedGlyph(
|
|
153
|
+
gid=info.codepoint,
|
|
154
|
+
cluster=info.cluster,
|
|
155
|
+
x_advance=pos.x_advance,
|
|
156
|
+
y_advance=pos.y_advance,
|
|
157
|
+
x_offset=pos.x_offset,
|
|
158
|
+
y_offset=pos.y_offset,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
return glyphs
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def shape(text: str, font_path: str | Path, *, keep_tashkeel: bool = False) -> list[ShapedGlyph]:
|
|
165
|
+
"""Shape ``text`` with the font at ``font_path``.
|
|
166
|
+
|
|
167
|
+
Returns glyphs in visual order (left to right) with positions in font
|
|
168
|
+
units. Arabic contextual forms, mandatory ligatures and mixed-direction
|
|
169
|
+
text are handled; characters missing from the font are skipped with an
|
|
170
|
+
:class:`UnsupportedCharacterWarning`.
|
|
171
|
+
|
|
172
|
+
``keep_tashkeel`` keeps Arabic diacritics; by default they are stripped,
|
|
173
|
+
because diacritics are illegible at character-grid resolution.
|
|
174
|
+
"""
|
|
175
|
+
if not keep_tashkeel:
|
|
176
|
+
text = strip_tashkeel(text)
|
|
177
|
+
if not text:
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
font = _load_font(str(font_path))
|
|
181
|
+
glyphs: list[ShapedGlyph] = []
|
|
182
|
+
for segment, direction in _visual_segments(text):
|
|
183
|
+
glyphs.extend(_shape_segment(font, segment, direction))
|
|
184
|
+
return glyphs
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def units_per_em(font_path: str | Path) -> int:
|
|
188
|
+
"""Units-per-em of the font; ShapedGlyph distances are in these units."""
|
|
189
|
+
upem: int = _load_font(str(font_path)).face.upem
|
|
190
|
+
return upem
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: khatt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Render Arabic text as ASCII/Unicode-block art — the Arabic FIGlet
|
|
5
|
+
Project-URL: Homepage, https://github.com/DrEssam97/khatt
|
|
6
|
+
Project-URL: Repository, https://github.com/DrEssam97/khatt
|
|
7
|
+
Project-URL: Issues, https://github.com/DrEssam97/khatt/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/DrEssam97/khatt/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Mohammed Essam <mohammedessam613@gmail.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: arabic,ascii-art,figlet,harfbuzz,rtl,text-art,unicode
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Natural Language :: Arabic
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Artistic Software
|
|
25
|
+
Classifier: Topic :: Text Processing :: Fonts
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Requires-Dist: freetype-py>=2.5.1
|
|
28
|
+
Requires-Dist: pillow>=12.3.0
|
|
29
|
+
Requires-Dist: python-bidi>=0.6.11
|
|
30
|
+
Requires-Dist: typer>=0.26.8
|
|
31
|
+
Requires-Dist: uharfbuzz>=0.55.0
|
|
32
|
+
Provides-Extra: app
|
|
33
|
+
Requires-Dist: gradio>=6.20.0; extra == 'app'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# خَطّ — Khatt
|
|
37
|
+
|
|
38
|
+
[](https://github.com/DrEssam97/khatt/actions/workflows/ci.yml)
|
|
39
|
+
[](LICENSE)
|
|
40
|
+
[](pyproject.toml)
|
|
41
|
+
[](khatt/fonts/LICENSES/)
|
|
42
|
+
|
|
43
|
+
**Render Arabic text as ASCII/Unicode-block art — the Arabic FIGlet that FIGlet can't be.**
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣾⡄
|
|
47
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢺⣿⣿⣄
|
|
48
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⠟
|
|
49
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣿⣿
|
|
50
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿
|
|
51
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣿⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣤⣄⡀
|
|
52
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣾⣿⣿⣿⡗
|
|
53
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠛⢿⣿⠟
|
|
54
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⣇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈
|
|
55
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⣄⣀
|
|
56
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⡏⠀⠀⠀⠀⣀⣤⣶⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠀⣠⣶⣿⣿⣷⣶⣤⣄⣀
|
|
57
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠇⠀⣀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠀⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣤⣀⣀
|
|
58
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣤⣾⣿⠟⠋⠉⠀⠀⠀⠀⠙⢿⣿⣿⡟⠀⠀⠘⠉⠉⠉⠉⠉⠉⠙⠛⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶⣶⣶⣶⡶
|
|
59
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⠟⠋⠀⠀⠀⠀⠀⠀⣀⣠⣤⣶⣿⡿⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣤⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁
|
|
60
|
+
⣀⣀⣀⣀⡀⠀⣀⣀⣀⣴⣿⣿⣥⣤⣤⣤⣴⣶⣶⣿⣿⣿⣿⣿⣿⣋⣀⡀⠀⠀⣀⣀⣀⣀⣤⣤⣶⣾⣿⣿⣿⡿⠿⠛⠋⠉⠁⠀⠀⠀⠀⠀⠀⠈
|
|
61
|
+
⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠋⠉
|
|
62
|
+
⠀⠀⠙⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠿⠿⠛⠛⠉⠛⠛⠻⠿⠿⠿⠿⠿⠿⠿⠿⠟⠛⠉⠁
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
*(that's «خط» — "khatt", meaning line/script/calligraphy — Amiri font, `braille` style)*
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
-%+.
|
|
69
|
+
:@. =: *+-- =@ *# =@
|
|
70
|
+
-@. +: += =@ #% +@
|
|
71
|
+
%+ -@. =% =**+***: @# =@ *% -*****%+ -%. +@
|
|
72
|
+
@* -@. +@ +@. %* =@ *% -@- @* -@. +@
|
|
73
|
+
@+ -@. +@ .@- %* =@ *% +@ @* -@. +@
|
|
74
|
+
@+ -@. +@ :@- %# =@ #% :@- @* -@. +@
|
|
75
|
+
@* :%****##**********%: +**%#***#% +# -*****%#**+*% =%
|
|
76
|
+
.@= :- :- -.
|
|
77
|
+
+*%+ -* =+ *:
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
*(«ايه الأخبار» — "What's the news?" — Cairo font, `ascii` style)*
|
|
81
|
+
|
|
82
|
+
## Why FIGlet can't do this
|
|
83
|
+
|
|
84
|
+
FIGlet fonts map **one character to one fixed glyph**. Arabic doesn't work that way:
|
|
85
|
+
|
|
86
|
+
- Every letter has up to four **positional forms** (isolated / initial / medial / final).
|
|
87
|
+
- Some letter pairs form **mandatory ligatures** — ل + ا must become لا.
|
|
88
|
+
- The script is **cursive**: letters connect, and the connections carry the word's shape.
|
|
89
|
+
- Text runs **right-to-left**, and real text mixes RTL words with LTR numbers and names.
|
|
90
|
+
|
|
91
|
+
Khatt sidesteps glyph tables entirely with a **rasterize-then-map** pipeline:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
text ──► HarfBuzz shaping ──► FreeType rasterization ──► density mapping ──► art
|
|
95
|
+
(positional forms, (real Arabic fonts, (blocks / ascii /
|
|
96
|
+
ligatures, bidi) hi-res grayscale) braille ramps)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The result is genuine calligraphic letterforms — from real fonts — approximated in
|
|
100
|
+
terminal characters.
|
|
101
|
+
|
|
102
|
+
## Install
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
pip install khatt # or: uv add khatt
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
From source:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
git clone https://github.com/DrEssam97/khatt.git && cd khatt
|
|
112
|
+
uv sync
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Quickstart
|
|
116
|
+
|
|
117
|
+
### Python API
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
import khatt
|
|
121
|
+
|
|
122
|
+
print(khatt.render("مرحبا")) # blocks style, 80 columns
|
|
123
|
+
print(khatt.render("خط", style="braille", width=60)) # high effective resolution
|
|
124
|
+
print(khatt.render("القاهرة", font="cairo", style="ascii", threshold=0.4))
|
|
125
|
+
|
|
126
|
+
khatt.list_fonts() # ['amiri', 'cairo', 'noto-naskh', 'rubik']
|
|
127
|
+
khatt.list_styles() # ['ascii', 'blocks', 'braille']
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### CLI
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
khatt "مرحبا" # render to the terminal
|
|
134
|
+
khatt "خط" --style braille --width 60
|
|
135
|
+
khatt "القاهرة" -f cairo -s ascii -t 0.4
|
|
136
|
+
echo "سلام" | khatt # stdin
|
|
137
|
+
khatt "خط" --output art.txt # save the art
|
|
138
|
+
khatt "خط" --output khatt.png # save the rendered text as an image
|
|
139
|
+
khatt --list-fonts
|
|
140
|
+
khatt --list-styles
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Arabic diacritics (tashkeel) are stripped by default — they're illegible at
|
|
144
|
+
character-grid resolution. Pass `--keep-tashkeel` (API: `keep_tashkeel=True`) to
|
|
145
|
+
render them.
|
|
146
|
+
|
|
147
|
+
### Gradio app
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
uv sync --extra app
|
|
151
|
+
uv run python app.py
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Bundled fonts and styles
|
|
155
|
+
|
|
156
|
+
| Font | Family | Character |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `amiri` (default) | Amiri | Classical naskh, rich calligraphic detail |
|
|
159
|
+
| `cairo` | Cairo | Geometric, modern, bold strokes |
|
|
160
|
+
| `noto-naskh` | Noto Naskh Arabic | Clean, highly legible naskh |
|
|
161
|
+
| `rubik` | Rubik | Strong Latin + Arabic pairing for mixed text |
|
|
162
|
+
|
|
163
|
+
All fonts are licensed under the SIL Open Font License 1.1, preserved verbatim in
|
|
164
|
+
[`khatt/fonts/LICENSES/`](khatt/fonts/LICENSES/).
|
|
165
|
+
|
|
166
|
+
| Style | Ramp | Notes |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| `blocks` | `░ ▒ ▓ █` | Default; reads well at any size |
|
|
169
|
+
| `ascii` | `. : - = + * # % @` | Pure ASCII, maximum compatibility |
|
|
170
|
+
| `braille` | `⠁ … ⣿` | 2×4 dots per cell — 8× the effective resolution |
|
|
171
|
+
|
|
172
|
+
## Deploy the app to a Hugging Face Space
|
|
173
|
+
|
|
174
|
+
`app.py` follows the Spaces convention (single file at the repo root), so the whole
|
|
175
|
+
repository deploys unchanged with one command:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
uv run --extra app gradio deploy
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
This prompts for a Space name on first run (it uses your `huggingface-cli login`
|
|
182
|
+
credentials) and pushes the repo as a Gradio-SDK Space. Alternatively, create a Gradio
|
|
183
|
+
Space on huggingface.co and `git push` this repository to it — `app.py` is picked up
|
|
184
|
+
automatically and dependencies install via `requirements.txt` (which just points at
|
|
185
|
+
`pyproject.toml`).
|
|
186
|
+
|
|
187
|
+
## Development
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
uv sync --all-extras
|
|
191
|
+
uv run pre-commit install
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The gate that must be green before every merge:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
uv run ruff check . && uv run ruff format --check . && uv run mypy khatt && uv run pytest
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Rendering changed intentionally? Regenerate the golden snapshots and commit the diff:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
uv run pytest --update-golden
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md), [PLAN.md](PLAN.md) and [ROADMAP.md](ROADMAP.md).
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT for the code — see [LICENSE](LICENSE). Bundled fonts are under the SIL Open Font
|
|
211
|
+
License 1.1 — see [`khatt/fonts/LICENSES/`](khatt/fonts/LICENSES/).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
khatt/__init__.py,sha256=iAa8HQRmuWmY2IDtBMQBPSaebi56Oxr6Trf4mV-JTuc,2327
|
|
2
|
+
khatt/cli.py,sha256=z4N6oHW5XVjlOgTncTAazMAQ2jLOZcjOKbA0d-B4LuI,3901
|
|
3
|
+
khatt/mapper.py,sha256=dviJyINhEkZmgj_lN0m2ISMR3BjUauDTqIqasG1WfKA,3771
|
|
4
|
+
khatt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
khatt/raster.py,sha256=uMUB8R0jemJUncGkwmO8OGRb6dQKZZ2ZcgG5ZePJDg8,4405
|
|
6
|
+
khatt/shaping.py,sha256=nSgCTBBhGGOFWal8XXdBmI744aIBL1in8n3YJqQFpN8,6458
|
|
7
|
+
khatt/fonts/Amiri-Regular.ttf,sha256=qzkcQUfQVMSJdumDIq0O7-FCeqDgUCoSpMddgKcM_Nc,431116
|
|
8
|
+
khatt/fonts/Cairo-Regular.ttf,sha256=ZnyYcYI5HJH05XovRVsXlPtePubKTvM4Poa7aQ-pyWQ,599548
|
|
9
|
+
khatt/fonts/NotoNaskhArabic-Regular.ttf,sha256=Z7WlJaZhtgeXH70_lqgbidOnaOdFNPyoTxisl-b6ty8,307592
|
|
10
|
+
khatt/fonts/Rubik-Regular.ttf,sha256=Gzp0N7oq-A5GXnc-1gxQNtG6as5JLYkEbbzxj7MeTog,359804
|
|
11
|
+
khatt/fonts/__init__.py,sha256=vCO1rdKOA4trFsh1doYKVgYXNT8ZAJMjbFJO7tb68ZQ,1112
|
|
12
|
+
khatt/fonts/LICENSES/OFL-Amiri.txt,sha256=ct5o5ZVPT90kcCKS71oy8APKlg7JMw3Ibl7vtd_7myI,4389
|
|
13
|
+
khatt/fonts/LICENSES/OFL-Cairo.txt,sha256=zjntsEtpDxRM1A3FmhvibcsInlOcgRgonRCBzeREnW8,4380
|
|
14
|
+
khatt/fonts/LICENSES/OFL-NotoNaskhArabic.txt,sha256=p6WiXrGIvxzZaYIDDVPiPDNIXGmxBEpWIlQiaFfuE68,4382
|
|
15
|
+
khatt/fonts/LICENSES/OFL-Rubik.txt,sha256=Ryy-fCVEHfY-nHhktD6zwPSz35UMZqdiJObP4eroQ_s,4384
|
|
16
|
+
khatt-0.1.0.dist-info/METADATA,sha256=hFj00fHX5s5vr8RdYOyd_LxbOj32d2kPprgoLpttSoM,8763
|
|
17
|
+
khatt-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
18
|
+
khatt-0.1.0.dist-info/entry_points.txt,sha256=BJ0GRvI9JyFwalOueGuXiytmF6KIYpAbUbF15_tDt1s,40
|
|
19
|
+
khatt-0.1.0.dist-info/licenses/LICENSE,sha256=sf6_MUORTsraS0RGWxx7zXHqCxvwDxl19HtvkuAL-cE,1297
|
|
20
|
+
khatt-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohammed Essam
|
|
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.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
The Arabic fonts bundled under khatt/fonts/ are NOT covered by this license.
|
|
26
|
+
They are licensed under the SIL Open Font License (OFL) 1.1; the full license
|
|
27
|
+
text for each font family is preserved in khatt/fonts/LICENSES/.
|