Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: CI

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}

- name: Install dependencies
run: uv sync --group dev

- name: Run tests
run: uv run pytest --cov=arabic_reshaper --cov-report=term-missing
33 changes: 33 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Release

on:
release:
types: [published]

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- run: uv python install ${{ matrix.python-version }}
- run: uv sync --group dev
- run: uv run pytest

publish:
needs: test
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
- run: uv build
- uses: pypa/gh-action-pypi-publish@release/v1
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,6 @@ venv/
# ignore
.ignore/
.DS_Store

# local test fonts — not committed
fonts/
37 changes: 0 additions & 37 deletions .travis.yml

This file was deleted.

7 changes: 0 additions & 7 deletions MANIFEST.in

This file was deleted.

10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Python Arabic Reshaper

[![Build Status](https://app.travis-ci.com/mpcabd/python-arabic-reshaper.svg?branch=master)](https://app.travis-ci.com/mpcabd/python-arabic-reshaper)
[![CI](https://github.com/mpcabd/python-arabic-reshaper/actions/workflows/ci.yml/badge.svg)](https://github.com/mpcabd/python-arabic-reshaper/actions/workflows/ci.yml)

Reconstruct Arabic sentences to be used in applications that don't support
Arabic script.
Expand Down Expand Up @@ -248,6 +248,14 @@ https://github.com/mpcabd/python-arabic-reshaper/tarball/master

## Version History

### 3.0.1

* Modernised packaging: migrated from `setuptools` to `hatchling` via `pyproject.toml`
* Dropped support for Python < 3.10
* Switched CI from Travis CI to GitHub Actions
* Added type hints and improved test coverage for font configuration
* Removed obsolete Anaconda publishing scripts

### 3.0.0
* Stop supporting Python 2.7
* Remove dependency on `future`. See #88.
Expand Down
3 changes: 1 addition & 2 deletions arabic_reshaper/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import os

from .arabic_reshaper import reshape, default_reshaper, ArabicReshaper
from .reshaper_config import (config_for_true_type_font,
ArabicReshaperConfigurationError,
ENABLE_NO_LIGATURES,
ENABLE_SENTENCES_LIGATURES,
ENABLE_WORDS_LIGATURES,
Expand Down
43 changes: 19 additions & 24 deletions arabic_reshaper/arabic_reshaper.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import re

from functools import cached_property
from itertools import repeat

from .ligatures import LIGATURES
Expand All @@ -35,7 +36,7 @@
)


class ArabicReshaper(object):
class ArabicReshaper:
"""
A class for Arabic reshaper, it allows for fine-tune configuration over the
API.
Expand All @@ -56,7 +57,7 @@ class ArabicReshaper(object):
"""

def __init__(self, configuration=None, configuration_file=None):
super(ArabicReshaper, self).__init__()
super().__init__()

self.configuration = auto_config(configuration, configuration_file)
self.language = self.configuration.get('language')
Expand All @@ -68,33 +69,25 @@ def __init__(self, configuration=None, configuration_file=None):
else:
self.letters = LETTERS_ARABIC

@property
@cached_property
def _ligatures_re(self):
if not hasattr(self, '__ligatures_re'):
patterns = []
re_group_index_to_ligature_forms = {}
index = 0
FORMS = 1
MATCH = 0
for ligature_record in LIGATURES:
ligature, replacement = ligature_record
if not self.configuration.getboolean(ligature):
continue
re_group_index_to_ligature_forms[index] = replacement[FORMS]
patterns.append('({})'.format(replacement[MATCH]))
index += 1
self._re_group_index_to_ligature_forms = (
re_group_index_to_ligature_forms
)
self.__ligatures_re = re.compile('|'.join(patterns), re.UNICODE)
return self.__ligatures_re
patterns = []
self._re_group_index_to_ligature_forms = {}
index = 0
FORMS = 1
MATCH = 0
for ligature, replacement in LIGATURES:
if not self.configuration.getboolean(ligature):
continue
self._re_group_index_to_ligature_forms[index] = replacement[FORMS]
patterns.append(f'({replacement[MATCH]})')
index += 1
return re.compile('|'.join(patterns), re.UNICODE)

def _get_ligature_forms_from_re_group_index(self, group_index):
if not hasattr(self, '_re_group_index_to_ligature_forms'):
return self._ligatures_re
return self._re_group_index_to_ligature_forms[group_index]

def reshape(self, text):
def reshape(self, text: str) -> str:
if not text:
return ''

Expand Down Expand Up @@ -217,6 +210,8 @@ def reshape(self, text):
if not forms[ligature_form]:
continue
output[a] = (forms[ligature_form], NOT_SUPPORTED)
# Pad the replaced positions with empty sentinels so that
# Harakat position indices remain aligned with the output list.
output[a+1:b] = repeat(('', NOT_SUPPORTED), b - 1 - a)

result = []
Expand Down
18 changes: 9 additions & 9 deletions arabic_reshaper/letters.py
Original file line number Diff line number Diff line change
Expand Up @@ -506,22 +506,22 @@
ZWJ: (ZWJ, ZWJ, ZWJ, ZWJ),
}

def connects_with_letter_before(letter,LETTERS):
if letter not in LETTERS:
def connects_with_letter_before(letter, letters):
if letter not in letters:
return False
forms = LETTERS[letter]
forms = letters[letter]
return forms[FINAL] or forms[MEDIAL]


def connects_with_letter_after(letter,LETTERS):
if letter not in LETTERS:
def connects_with_letter_after(letter, letters):
if letter not in letters:
return False
forms = LETTERS[letter]
forms = letters[letter]
return forms[INITIAL] or forms[MEDIAL]


def connects_with_letters_before_and_after(letter,LETTERS):
if letter not in LETTERS:
def connects_with_letters_before_and_after(letter, letters):
if letter not in letters:
return False
forms = LETTERS[letter]
forms = letters[letter]
return forms[MEDIAL]
1 change: 1 addition & 0 deletions arabic_reshaper/ligatures.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
'\u0648\u0633\u0644\u0645', ('\uFDF8', '', '', ''),
)),
('RIAL SIGN', (
# Regex (not a literal string): matches both Farsi YEH (U+06CC) and Arabic YEH (U+064A).
'\u0631[\u06CC\u064A]\u0627\u0644', ('\uFDFC', '', '', ''),
)),
)
Expand Down
55 changes: 39 additions & 16 deletions arabic_reshaper/reshaper_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
except ImportError:
with_font_config = False

class ArabicReshaperConfigurationError(ValueError):
"""Raised when the reshaper configuration is invalid or missing."""


ENABLE_NO_LIGATURES = 0b000
ENABLE_SENTENCES_LIGATURES = 0b001
ENABLE_WORDS_LIGATURES = 0b010
Expand Down Expand Up @@ -361,7 +365,10 @@
}


def auto_config(configuration=None, configuration_file=None):
def auto_config(
configuration: dict | None = None,
configuration_file: str | None = None,
):
loaded_from_envvar = False

configuration_parser = ConfigParser()
Expand All @@ -378,14 +385,12 @@ def auto_config(configuration=None, configuration_file=None):

if configuration_file:
if not os.path.exists(configuration_file):
raise Exception(
'Configuration file {} not found{}.'.format(
configuration_file,
loaded_from_envvar and (
' it is set in your environment variable ' +
'PYTHON_ARABIC_RESHAPER_CONFIGURATION_FILE'
) or ''
)
env_note = (
' (set via PYTHON_ARABIC_RESHAPER_CONFIGURATION_FILE)'
if loaded_from_envvar else ''
)
raise ArabicReshaperConfigurationError(
f'Configuration file not found: {configuration_file}{env_note}'
)
configuration_parser.read((configuration_file,))

Expand All @@ -395,22 +400,40 @@ def auto_config(configuration=None, configuration_file=None):
})

if 'ArabicReshaper' not in configuration_parser:
raise ValueError(
raise ArabicReshaperConfigurationError(
'Invalid configuration: '
'A section with the name ArabicReshaper was not found'
)

return configuration_parser['ArabicReshaper']


def config_for_true_type_font(font_file_path,
ligatures_config=ENABLE_ALL_LIGATURES):
def config_for_true_type_font(font_file_path: str, ligatures_config: int = ENABLE_ALL_LIGATURES) -> dict:
"""Return a reshaper configuration dict tuned to the capabilities of a TrueType font.

Inspects the font's cmap table to determine which positional Arabic letter
forms are present, and checks each ligature glyph so that only ligatures
the font actually supports are enabled.

Args:
font_file_path: Path to the .ttf/.otf font file.
ligatures_config: Bitmask of ENABLE_*_LIGATURES flags controlling which
ligature categories to probe. Defaults to ENABLE_ALL_LIGATURES.

Returns:
A configuration dict suitable for passing to ArabicReshaper().

Raises:
ImportError: If fonttools is not installed.
ArabicReshaperConfigurationError: If the font path is invalid.
"""
if not with_font_config:
raise Exception('fonttools not installed, ' +
'install it then rerun this.\n' +
'$ pip install arabic-teshaper[with-fonttools]')
raise ImportError(
'fonttools is not installed. '
'Install it with: pip install arabic-reshaper[with-fonttools]'
)
if not font_file_path or not os.path.exists(font_file_path):
raise Exception('Invalid path to font file')
raise ArabicReshaperConfigurationError(f'Invalid path to font file: {font_file_path}')
ttfont = TTFont(font_file_path)
has_isolated = True
for k, v in LETTERS_ARABIC.items():
Expand Down
2 changes: 0 additions & 2 deletions arabic_reshaper/tests/test_003_reshaping.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ def setUp(self):


)
print(self.cases[0][0])

def test_reshaping(self):
_reshaping_test(self)

Expand Down
Loading
Loading