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 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
@@ -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
+ [![CI](https://github.com/DrEssam97/khatt/actions/workflows/ci.yml/badge.svg)](https://github.com/DrEssam97/khatt/actions/workflows/ci.yml)
39
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
40
+ [![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue.svg)](pyproject.toml)
41
+ [![Fonts: OFL](https://img.shields.io/badge/fonts-SIL%20OFL%201.1-lightgrey.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ khatt = khatt.cli:app
@@ -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/.