tafqit 0.1.0__tar.gz
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.
- tafqit-0.1.0/CITATION.cff +13 -0
- tafqit-0.1.0/Cargo.lock +177 -0
- tafqit-0.1.0/Cargo.toml +22 -0
- tafqit-0.1.0/LICENSE +21 -0
- tafqit-0.1.0/PKG-INFO +10 -0
- tafqit-0.1.0/README.md +112 -0
- tafqit-0.1.0/pyproject.toml +20 -0
- tafqit-0.1.0/python/Cargo.toml +16 -0
- tafqit-0.1.0/python/src/lib.rs +87 -0
- tafqit-0.1.0/python/tests/test_smoke.py +43 -0
- tafqit-0.1.0/src/ar.rs +329 -0
- tafqit-0.1.0/src/fr.rs +188 -0
- tafqit-0.1.0/src/lib.rs +150 -0
- tafqit-0.1.0/src/main.rs +100 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: "If you use this software, please cite it using the metadata from this file."
|
|
3
|
+
type: software
|
|
4
|
+
title: "tafqit"
|
|
5
|
+
abstract: "Amounts in words (tafqit / تفقيط) in Arabic and French, with dinar/centime currency forms for invoices and cheques"
|
|
6
|
+
authors:
|
|
7
|
+
- family-names: Chougui
|
|
8
|
+
given-names: Abdeldjalil
|
|
9
|
+
version: 0.1.0
|
|
10
|
+
license: MIT
|
|
11
|
+
repository-code: "https://github.com/AbdeldjalilChougui/tafqit"
|
|
12
|
+
url: "https://github.com/AbdeldjalilChougui/tafqit"
|
|
13
|
+
keywords: [arabic, french, numbers, words, invoice]
|
tafqit-0.1.0/Cargo.lock
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# This file is automatically @generated by Cargo.
|
|
2
|
+
# It is not intended for manual editing.
|
|
3
|
+
version = 4
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "autocfg"
|
|
7
|
+
version = "1.5.1"
|
|
8
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
9
|
+
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
|
10
|
+
|
|
11
|
+
[[package]]
|
|
12
|
+
name = "heck"
|
|
13
|
+
version = "0.5.0"
|
|
14
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
15
|
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
|
16
|
+
|
|
17
|
+
[[package]]
|
|
18
|
+
name = "indoc"
|
|
19
|
+
version = "2.0.8"
|
|
20
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
21
|
+
checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
|
|
22
|
+
dependencies = [
|
|
23
|
+
"rustversion",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[[package]]
|
|
27
|
+
name = "libc"
|
|
28
|
+
version = "0.2.191"
|
|
29
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
30
|
+
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
|
31
|
+
|
|
32
|
+
[[package]]
|
|
33
|
+
name = "memoffset"
|
|
34
|
+
version = "0.9.1"
|
|
35
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
36
|
+
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
|
37
|
+
dependencies = [
|
|
38
|
+
"autocfg",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[[package]]
|
|
42
|
+
name = "once_cell"
|
|
43
|
+
version = "1.21.4"
|
|
44
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
45
|
+
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
46
|
+
|
|
47
|
+
[[package]]
|
|
48
|
+
name = "portable-atomic"
|
|
49
|
+
version = "1.15.0"
|
|
50
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
51
|
+
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
|
|
52
|
+
|
|
53
|
+
[[package]]
|
|
54
|
+
name = "proc-macro2"
|
|
55
|
+
version = "1.0.107"
|
|
56
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
57
|
+
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
|
58
|
+
dependencies = [
|
|
59
|
+
"unicode-ident",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
[[package]]
|
|
63
|
+
name = "pyo3"
|
|
64
|
+
version = "0.27.2"
|
|
65
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
66
|
+
checksum = "ab53c047fcd1a1d2a8820fe84f05d6be69e9526be40cb03b73f86b6b03e6d87d"
|
|
67
|
+
dependencies = [
|
|
68
|
+
"indoc",
|
|
69
|
+
"libc",
|
|
70
|
+
"memoffset",
|
|
71
|
+
"once_cell",
|
|
72
|
+
"portable-atomic",
|
|
73
|
+
"pyo3-build-config",
|
|
74
|
+
"pyo3-ffi",
|
|
75
|
+
"pyo3-macros",
|
|
76
|
+
"unindent",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[[package]]
|
|
80
|
+
name = "pyo3-build-config"
|
|
81
|
+
version = "0.27.2"
|
|
82
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
83
|
+
checksum = "b455933107de8642b4487ed26d912c2d899dec6114884214a0b3bb3be9261ea6"
|
|
84
|
+
dependencies = [
|
|
85
|
+
"target-lexicon",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
[[package]]
|
|
89
|
+
name = "pyo3-ffi"
|
|
90
|
+
version = "0.27.2"
|
|
91
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
92
|
+
checksum = "1c85c9cbfaddf651b1221594209aed57e9e5cff63c4d11d1feead529b872a089"
|
|
93
|
+
dependencies = [
|
|
94
|
+
"libc",
|
|
95
|
+
"pyo3-build-config",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
[[package]]
|
|
99
|
+
name = "pyo3-macros"
|
|
100
|
+
version = "0.27.2"
|
|
101
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
102
|
+
checksum = "0a5b10c9bf9888125d917fb4d2ca2d25c8df94c7ab5a52e13313a07e050a3b02"
|
|
103
|
+
dependencies = [
|
|
104
|
+
"proc-macro2",
|
|
105
|
+
"pyo3-macros-backend",
|
|
106
|
+
"quote",
|
|
107
|
+
"syn",
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
[[package]]
|
|
111
|
+
name = "pyo3-macros-backend"
|
|
112
|
+
version = "0.27.2"
|
|
113
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
114
|
+
checksum = "03b51720d314836e53327f5871d4c0cfb4fb37cc2c4a11cc71907a86342c40f9"
|
|
115
|
+
dependencies = [
|
|
116
|
+
"heck",
|
|
117
|
+
"proc-macro2",
|
|
118
|
+
"pyo3-build-config",
|
|
119
|
+
"quote",
|
|
120
|
+
"syn",
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
[[package]]
|
|
124
|
+
name = "quote"
|
|
125
|
+
version = "1.0.47"
|
|
126
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
127
|
+
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
|
128
|
+
dependencies = [
|
|
129
|
+
"proc-macro2",
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
[[package]]
|
|
133
|
+
name = "rustversion"
|
|
134
|
+
version = "1.0.23"
|
|
135
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
136
|
+
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|
137
|
+
|
|
138
|
+
[[package]]
|
|
139
|
+
name = "syn"
|
|
140
|
+
version = "2.0.119"
|
|
141
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
142
|
+
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
|
143
|
+
dependencies = [
|
|
144
|
+
"proc-macro2",
|
|
145
|
+
"quote",
|
|
146
|
+
"unicode-ident",
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
[[package]]
|
|
150
|
+
name = "tafqit"
|
|
151
|
+
version = "0.1.0"
|
|
152
|
+
|
|
153
|
+
[[package]]
|
|
154
|
+
name = "tafqit-py"
|
|
155
|
+
version = "0.1.0"
|
|
156
|
+
dependencies = [
|
|
157
|
+
"pyo3",
|
|
158
|
+
"tafqit",
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
[[package]]
|
|
162
|
+
name = "target-lexicon"
|
|
163
|
+
version = "0.13.5"
|
|
164
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
165
|
+
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
|
166
|
+
|
|
167
|
+
[[package]]
|
|
168
|
+
name = "unicode-ident"
|
|
169
|
+
version = "1.0.27"
|
|
170
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
171
|
+
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
|
172
|
+
|
|
173
|
+
[[package]]
|
|
174
|
+
name = "unindent"
|
|
175
|
+
version = "0.2.4"
|
|
176
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
177
|
+
checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
|
tafqit-0.1.0/Cargo.toml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "tafqit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
authors = ["Abdeldjalil Chougui"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
description = "Amounts in words (tafqit / تفقيط) in Arabic and French, with dinar/centime currency forms for invoices and cheques"
|
|
8
|
+
keywords = ["arabic", "french", "numbers", "words", "invoice"]
|
|
9
|
+
categories = ["text-processing", "internationalization"]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
repository = "https://github.com/AbdeldjalilChougui/tafqit"
|
|
12
|
+
homepage = "https://github.com/AbdeldjalilChougui/tafqit"
|
|
13
|
+
include = ["src/**/*", "Cargo.toml", "Cargo.lock", "README.md", "LICENSE", "CITATION.cff"]
|
|
14
|
+
|
|
15
|
+
[workspace]
|
|
16
|
+
members = [".", "python"]
|
|
17
|
+
|
|
18
|
+
[[bin]]
|
|
19
|
+
name = "tafqit"
|
|
20
|
+
path = "src/main.rs"
|
|
21
|
+
|
|
22
|
+
[dependencies]
|
tafqit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abdeldjalil Chougui
|
|
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.
|
tafqit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tafqit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Amounts in words (tafqit) in Arabic and French, with dinar/centime forms (Rust core)
|
|
5
|
+
Author: Abdeldjalil Chougui
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Project-URL: Homepage, https://github.com/AbdeldjalilChougui/tafqit
|
|
9
|
+
Project-URL: Issues, https://github.com/AbdeldjalilChougui/tafqit/issues
|
|
10
|
+
Project-URL: Repository, https://github.com/AbdeldjalilChougui/tafqit
|
tafqit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# tafqit (تفقيط)
|
|
2
|
+
|
|
3
|
+
Write numbers and money amounts in words, in **Arabic** and **French**, for invoices,
|
|
4
|
+
receipts and cheques. Algerian dinar/centime forms are built in. Rust library + CLI +
|
|
5
|
+
Python bindings.
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
$ tafqit 1234.50
|
|
9
|
+
ألف ومائتان وأربعة وثلاثون دينارًا وخمسون سنتيمًا
|
|
10
|
+
mille deux cent trente-quatre dinars et cinquante centimes
|
|
11
|
+
|
|
12
|
+
$ tafqit --lang ar --cheque 2000000
|
|
13
|
+
فقط مليونا دينار لا غير
|
|
14
|
+
|
|
15
|
+
$ tafqit --lang fr 1000000
|
|
16
|
+
un million de dinars
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Numbers up to 999 999 999 999 (billions).
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
cargo install tafqit --locked # CLI
|
|
25
|
+
cargo add tafqit # Rust library
|
|
26
|
+
pip install tafqit # Python bindings (prebuilt wheels: Linux, macOS, Windows)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```rust
|
|
32
|
+
use tafqit::{ar, fr, Amount};
|
|
33
|
+
|
|
34
|
+
let a: Amount = "1 234,50".parse()?; // also 1234.5, 1,234.50, 1.234,50
|
|
35
|
+
a.to_arabic(&ar::DZD, &ar::Options::default())?; // ألف ومائتان وأربعة وثلاثون دينارًا وخمسون سنتيمًا
|
|
36
|
+
a.to_french(&fr::DINAR, &fr::Options::default())?; // mille deux cent trente-quatre dinars et cinquante centimes
|
|
37
|
+
|
|
38
|
+
// Bare numbers, gender and case
|
|
39
|
+
ar::number(2000, &Default::default())?; // ألفان
|
|
40
|
+
let gen = ar::Options { case: ar::Case::Genitive, ..Default::default() };
|
|
41
|
+
ar::with_noun(2000, &ar::DINAR, &gen)?; // ألفي دينار
|
|
42
|
+
ar::with_noun(13, &ar::LIRA, &Default::default())?; // ثلاث عشرة ليرةً (feminine noun)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
CLI options: `--lang ar|fr|both`, `--number` (no currency), `--case nom|acc|gen`,
|
|
46
|
+
`--feminine`, `--cheque` (`فقط … لا غير`), `--rectified` (French 1990 spelling),
|
|
47
|
+
`--mia` (`مئة` instead of `مائة`).
|
|
48
|
+
|
|
49
|
+
Python (`pip install tafqit`, or build from source):
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
cd python && maturin build --release -o dist && uv venv && uv pip install dist/*.whl
|
|
53
|
+
```
|
|
54
|
+
```python
|
|
55
|
+
import tafqit
|
|
56
|
+
tafqit.arabic("1234.50") # amounts as str/int/Decimal (floats are refused)
|
|
57
|
+
tafqit.arabic(2000, case="genitive") # 'ألفي دينار'
|
|
58
|
+
tafqit.french(1_000_000) # 'un million de dinars'
|
|
59
|
+
tafqit.number_ar(21, feminine=True) # 'إحدى وعشرون'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Rules implemented
|
|
63
|
+
|
|
64
|
+
**Arabic** (Modern Standard Arabic):
|
|
65
|
+
- 1 and 2 come after the noun or use the dual form: `دينار واحد`, `ديناران`.
|
|
66
|
+
- 3–10 take the opposite gender of the noun, and the noun takes the genitive plural:
|
|
67
|
+
`ثلاثة دنانير`, `ثلاث ليرات`, `ثماني ليرات`.
|
|
68
|
+
- 11–99 take an accusative singular noun (tamyiz): `خمسة عشر دينارًا`. 11 and 12
|
|
69
|
+
agree with the noun (`أحد عشر` / `إحدى عشرة`). In 13–19 the unit is reversed and
|
|
70
|
+
`عشر/عشرة` agrees: `ثلاث عشرة ليرةً`.
|
|
71
|
+
- Round hundreds, thousands, millions and billions are followed by a genitive
|
|
72
|
+
singular: `مائة دينار`, `ثلاثة آلاف دينار`, `مليون دينار`. Before a noun a dual
|
|
73
|
+
loses its nun (construct state): `مائتا دينار`, `ألفا دينار`, `مليونا دينار`.
|
|
74
|
+
- Scale words count like nouns: `ألف`, `ألفان`, `ثلاثة آلاف`, `أحد عشر ألفًا`,
|
|
75
|
+
`مائة ألف`.
|
|
76
|
+
- Case: duals, `اثنان/اثنين`, `اثنا عشر/اثني عشر` and the tens `عشرون/عشرين` change
|
|
77
|
+
between the nominative and the accusative/genitive.
|
|
78
|
+
- Amounts ending in 1 or 2 after a larger part keep a noun on each part:
|
|
79
|
+
`مائة دينار ودينار واحد`, `ألف دينار وديناران`. Some invoice software writes
|
|
80
|
+
`مائة وواحد دينار` instead. The form used here is the one grammar references give.
|
|
81
|
+
|
|
82
|
+
**French:**
|
|
83
|
+
- Traditional spelling: `vingt et un`, `soixante et onze`, `quatre-vingts` /
|
|
84
|
+
`quatre-vingt-un`, `deux cents` / `deux cent un`.
|
|
85
|
+
- `mille` is invariable and has no `un`. `cent` and `vingt` keep their plural `s`
|
|
86
|
+
before `millions`/`milliards`, because those are nouns: `deux cents millions`,
|
|
87
|
+
`quatre-vingt mille`.
|
|
88
|
+
- Optional 1990 rectified spelling (`deux-cent-vingt-et-un`).
|
|
89
|
+
- Currency: `un dinar`, `deux dinars`, `zéro dinar`, `un million de dinars`,
|
|
90
|
+
`… dinars et cinquante centimes`.
|
|
91
|
+
|
|
92
|
+
## Tests
|
|
93
|
+
|
|
94
|
+
`cargo test`. `tests/tables.rs` has about 170 known-correct spellings: Arabic dinars in
|
|
95
|
+
nominative and accusative/genitive, a feminine noun, bare numbers, French traditional
|
|
96
|
+
and rectified spelling, amounts, and the CLI. Every number from 0 to 20 000 is also
|
|
97
|
+
spelled in both languages to catch panics. Python: `cd python && maturin build -o dist && uv venv && uv pip install
|
|
98
|
+
dist/*.whl && .venv/bin/python tests/test_smoke.py`.
|
|
99
|
+
|
|
100
|
+
## Status / limitations
|
|
101
|
+
|
|
102
|
+
- The Arabic output has no short vowels except the tanween on the tamyiz (`دينارًا`).
|
|
103
|
+
The adjective `جزائري` is not added (`دينار جزائري` needs its own agreement rules).
|
|
104
|
+
- Only dinar/centime is built in for French. In Arabic, any noun can be supplied
|
|
105
|
+
through `ar::Noun` (`ar::LIRA` shows a feminine one).
|
|
106
|
+
- No ordinals (`الأول`, `premier`), no negative numbers, no decimals other than
|
|
107
|
+
hundredths.
|
|
108
|
+
- Belgian/Swiss French (`septante`, `nonante`) is not supported.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["maturin>=1.5,<2"]
|
|
3
|
+
build-backend = "maturin"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tafqit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Amounts in words (tafqit) in Arabic and French, with dinar/centime forms (Rust core)"
|
|
9
|
+
license = { text = "MIT" }
|
|
10
|
+
authors = [{ name = "Abdeldjalil Chougui" }]
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
|
|
13
|
+
[project.urls]
|
|
14
|
+
Homepage = "https://github.com/AbdeldjalilChougui/tafqit"
|
|
15
|
+
Repository = "https://github.com/AbdeldjalilChougui/tafqit"
|
|
16
|
+
Issues = "https://github.com/AbdeldjalilChougui/tafqit/issues"
|
|
17
|
+
|
|
18
|
+
[tool.maturin]
|
|
19
|
+
features = ["pyo3/extension-module"]
|
|
20
|
+
manifest-path = "python/Cargo.toml"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "tafqit-py"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
authors = ["Abdeldjalil Chougui"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
description = "Python bindings for tafqit"
|
|
8
|
+
publish = false
|
|
9
|
+
|
|
10
|
+
[lib]
|
|
11
|
+
name = "tafqit"
|
|
12
|
+
crate-type = ["cdylib"]
|
|
13
|
+
|
|
14
|
+
[dependencies]
|
|
15
|
+
tafqit = { path = "..", version = "0.1.0" }
|
|
16
|
+
pyo3 = { version = "0.27", features = ["extension-module", "abi3-py39"] }
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
//! Python bindings for `tafqit`.
|
|
2
|
+
|
|
3
|
+
use ::tafqit as core;
|
|
4
|
+
use core::{ar, fr, Amount, Gender};
|
|
5
|
+
use pyo3::exceptions::PyValueError;
|
|
6
|
+
use pyo3::prelude::*;
|
|
7
|
+
|
|
8
|
+
fn err(e: core::Error) -> PyErr {
|
|
9
|
+
PyValueError::new_err(e.to_string())
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
fn parse_amount(amount: &Bound<'_, PyAny>) -> PyResult<Amount> {
|
|
13
|
+
// Accept int, str ("1234.50"), or anything whose str() parses (e.g. Decimal).
|
|
14
|
+
if let Ok(n) = amount.extract::<u64>() {
|
|
15
|
+
return Amount::new(n, 0).map_err(err);
|
|
16
|
+
}
|
|
17
|
+
if amount.is_instance_of::<pyo3::types::PyFloat>() {
|
|
18
|
+
return Err(PyValueError::new_err("pass amounts as str, int or Decimal, not float (rounding errors)"));
|
|
19
|
+
}
|
|
20
|
+
let s: String = amount.str()?.extract()?;
|
|
21
|
+
s.parse().map_err(err)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
fn gender(feminine: bool) -> Gender {
|
|
25
|
+
if feminine {
|
|
26
|
+
Gender::Feminine
|
|
27
|
+
} else {
|
|
28
|
+
Gender::Masculine
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fn parse_case(case: &str) -> PyResult<ar::Case> {
|
|
33
|
+
match case {
|
|
34
|
+
"nominative" | "nom" => Ok(ar::Case::Nominative),
|
|
35
|
+
"accusative" | "acc" => Ok(ar::Case::Accusative),
|
|
36
|
+
"genitive" | "gen" => Ok(ar::Case::Genitive),
|
|
37
|
+
_ => Err(PyValueError::new_err("case must be 'nominative', 'accusative' or 'genitive'")),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Amount in Arabic words with dinar/centime forms.
|
|
42
|
+
#[pyfunction]
|
|
43
|
+
#[pyo3(signature = (amount, *, case="nominative", cheque=false, mia=false))]
|
|
44
|
+
fn arabic(amount: &Bound<'_, PyAny>, case: &str, cheque: bool, mia: bool) -> PyResult<String> {
|
|
45
|
+
let a = parse_amount(amount)?;
|
|
46
|
+
let o = ar::Options { case: parse_case(case)?, mia_spelling: mia, ..Default::default() };
|
|
47
|
+
if cheque {
|
|
48
|
+
a.to_arabic_cheque(&ar::DZD, &o).map_err(err)
|
|
49
|
+
} else {
|
|
50
|
+
a.to_arabic(&ar::DZD, &o).map_err(err)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Amount in French words with dinar/centime forms.
|
|
55
|
+
#[pyfunction]
|
|
56
|
+
#[pyo3(signature = (amount, *, rectified=false))]
|
|
57
|
+
fn french(amount: &Bound<'_, PyAny>, rectified: bool) -> PyResult<String> {
|
|
58
|
+
let a = parse_amount(amount)?;
|
|
59
|
+
let spelling = if rectified { fr::Spelling::Rectified1990 } else { fr::Spelling::Traditional };
|
|
60
|
+
a.to_french(&fr::DINAR, &fr::Options { spelling, ..Default::default() }).map_err(err)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// A bare number in Arabic words.
|
|
64
|
+
#[pyfunction]
|
|
65
|
+
#[pyo3(signature = (n, *, feminine=false, case="nominative"))]
|
|
66
|
+
fn number_ar(n: u64, feminine: bool, case: &str) -> PyResult<String> {
|
|
67
|
+
ar::number(n, &ar::Options { gender: gender(feminine), case: parse_case(case)?, ..Default::default() }).map_err(err)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// A bare number in French words.
|
|
71
|
+
#[pyfunction]
|
|
72
|
+
#[pyo3(signature = (n, *, feminine=false, rectified=false))]
|
|
73
|
+
fn number_fr(n: u64, feminine: bool, rectified: bool) -> PyResult<String> {
|
|
74
|
+
let spelling = if rectified { fr::Spelling::Rectified1990 } else { fr::Spelling::Traditional };
|
|
75
|
+
fr::number(n, &fr::Options { gender: gender(feminine), spelling }).map_err(err)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#[pymodule]
|
|
79
|
+
fn tafqit(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
80
|
+
m.add_function(wrap_pyfunction!(arabic, m)?)?;
|
|
81
|
+
m.add_function(wrap_pyfunction!(french, m)?)?;
|
|
82
|
+
m.add_function(wrap_pyfunction!(number_ar, m)?)?;
|
|
83
|
+
m.add_function(wrap_pyfunction!(number_fr, m)?)?;
|
|
84
|
+
m.add("MAX", core::MAX)?;
|
|
85
|
+
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
|
|
86
|
+
Ok(())
|
|
87
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Smoke test for the tafqit Python bindings. Runs with plain `python` or pytest."""
|
|
2
|
+
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
|
|
5
|
+
import tafqit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_amounts():
|
|
9
|
+
assert tafqit.arabic("1234.50") == "ألف ومائتان وأربعة وثلاثون دينارًا وخمسون سنتيمًا"
|
|
10
|
+
assert tafqit.arabic(2000) == "ألفا دينار"
|
|
11
|
+
assert tafqit.arabic(2000, case="genitive") == "ألفي دينار"
|
|
12
|
+
assert tafqit.arabic(Decimal("1000"), cheque=True) == "فقط ألف دينار لا غير"
|
|
13
|
+
assert tafqit.french("1234.50") == "mille deux cent trente-quatre dinars et cinquante centimes"
|
|
14
|
+
assert tafqit.french(1_000_000) == "un million de dinars"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_numbers():
|
|
18
|
+
assert tafqit.number_ar(21, feminine=True) == "إحدى وعشرون"
|
|
19
|
+
assert tafqit.number_fr(80) == "quatre-vingts"
|
|
20
|
+
assert tafqit.number_fr(221, rectified=True) == "deux-cent-vingt-et-un"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_errors():
|
|
24
|
+
for bad in ["abc", 12.5]:
|
|
25
|
+
try:
|
|
26
|
+
tafqit.arabic(bad)
|
|
27
|
+
except ValueError:
|
|
28
|
+
pass
|
|
29
|
+
else:
|
|
30
|
+
raise AssertionError(f"expected ValueError for {bad!r}")
|
|
31
|
+
try:
|
|
32
|
+
tafqit.number_ar(tafqit.MAX + 1)
|
|
33
|
+
except ValueError:
|
|
34
|
+
pass
|
|
35
|
+
else:
|
|
36
|
+
raise AssertionError("expected ValueError")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
test_amounts()
|
|
41
|
+
test_numbers()
|
|
42
|
+
test_errors()
|
|
43
|
+
print("python smoke test: OK", tafqit.__version__)
|
tafqit-0.1.0/src/ar.rs
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
//! Arabic number words (تفقيط) with counted-noun agreement.
|
|
2
|
+
//!
|
|
3
|
+
//! Rules implemented (Modern Standard Arabic):
|
|
4
|
+
//! - 1 and 2 follow the noun: `دينار واحد`, or use the dual alone: `ديناران`.
|
|
5
|
+
//! - 3–10 take the opposite gender of the noun's singular, and the noun goes to
|
|
6
|
+
//! the genitive plural: `ثلاثة دنانير`, `ثلاث ليرات`.
|
|
7
|
+
//! - 11–99: the noun is an accusative singular (tamyiz): `خمسة عشر دينارًا`;
|
|
8
|
+
//! 11 and 12 agree with the noun (`أحد عشر` / `إحدى عشرة`), 13–19 have a
|
|
9
|
+
//! reversed unit and an agreeing `عشر/عشرة`.
|
|
10
|
+
//! - Round hundreds, thousands, millions and billions are followed by a
|
|
11
|
+
//! genitive singular: `مائة دينار`, `ثلاثة آلاف دينار`, `مليون دينار`.
|
|
12
|
+
//! A dual before a noun loses its nun (construct state): `ألفا دينار`, `مائتا دينار`.
|
|
13
|
+
//! - Case: dual and sound plural endings change between the nominative
|
|
14
|
+
//! (`اثنان`, `عشرون`, `ألفان`) and the accusative/genitive (`اثنين`, `عشرين`, `ألفين`).
|
|
15
|
+
//! - The counted noun follows the last numeral. When the amount ends in 1 or 2 after a
|
|
16
|
+
//! larger part, each part keeps its own noun: `مائة دينار ودينار واحد`, `ألف دينار وديناران`.
|
|
17
|
+
//! (Some invoice tools write `مائة وواحد دينار`. The form used here is the one
|
|
18
|
+
//! grammar references recommend.)
|
|
19
|
+
|
|
20
|
+
use crate::{Error, Gender, MAX};
|
|
21
|
+
|
|
22
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
23
|
+
pub enum Case {
|
|
24
|
+
/// مرفوع: `اثنان`, `عشرون`, `ديناران`
|
|
25
|
+
#[default]
|
|
26
|
+
Nominative,
|
|
27
|
+
/// منصوب: `اثنين`, `عشرين`, `دينارين`
|
|
28
|
+
Accusative,
|
|
29
|
+
/// مجرور: same written forms as the accusative
|
|
30
|
+
Genitive,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
impl Case {
|
|
34
|
+
fn oblique(self) -> bool {
|
|
35
|
+
self != Case::Nominative
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// A countable noun with the forms Arabic numerals need.
|
|
40
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
41
|
+
pub struct Noun {
|
|
42
|
+
/// Singular (also the genitive form after round numbers): `دينار`
|
|
43
|
+
pub singular: &'static str,
|
|
44
|
+
/// Indefinite accusative singular, used after 11–99: `دينارًا`
|
|
45
|
+
pub accusative: &'static str,
|
|
46
|
+
/// Dual, nominative: `ديناران`
|
|
47
|
+
pub dual_nom: &'static str,
|
|
48
|
+
/// Dual, accusative/genitive: `دينارين`
|
|
49
|
+
pub dual_obl: &'static str,
|
|
50
|
+
/// Plural used after 3–10: `دنانير`
|
|
51
|
+
pub plural: &'static str,
|
|
52
|
+
pub gender: Gender,
|
|
53
|
+
/// Scale words say `ألف`, not `ألف واحد`.
|
|
54
|
+
pub omit_one: bool,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
pub const DINAR: Noun = Noun {
|
|
58
|
+
singular: "دينار",
|
|
59
|
+
accusative: "دينارًا",
|
|
60
|
+
dual_nom: "ديناران",
|
|
61
|
+
dual_obl: "دينارين",
|
|
62
|
+
plural: "دنانير",
|
|
63
|
+
gender: Gender::Masculine,
|
|
64
|
+
omit_one: false,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
pub const CENTIME: Noun = Noun {
|
|
68
|
+
singular: "سنتيم",
|
|
69
|
+
accusative: "سنتيمًا",
|
|
70
|
+
dual_nom: "سنتيمان",
|
|
71
|
+
dual_obl: "سنتيمين",
|
|
72
|
+
plural: "سنتيمات",
|
|
73
|
+
gender: Gender::Masculine,
|
|
74
|
+
omit_one: false,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/// A feminine noun, as an example of feminine agreement (Lebanese/Syrian pound).
|
|
78
|
+
pub const LIRA: Noun = Noun {
|
|
79
|
+
singular: "ليرة",
|
|
80
|
+
accusative: "ليرةً",
|
|
81
|
+
dual_nom: "ليرتان",
|
|
82
|
+
dual_obl: "ليرتين",
|
|
83
|
+
plural: "ليرات",
|
|
84
|
+
gender: Gender::Feminine,
|
|
85
|
+
omit_one: false,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const THOUSAND: Noun = Noun {
|
|
89
|
+
singular: "ألف",
|
|
90
|
+
accusative: "ألفًا",
|
|
91
|
+
dual_nom: "ألفان",
|
|
92
|
+
dual_obl: "ألفين",
|
|
93
|
+
plural: "آلاف",
|
|
94
|
+
gender: Gender::Masculine,
|
|
95
|
+
omit_one: true,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const MILLION: Noun = Noun {
|
|
99
|
+
singular: "مليون",
|
|
100
|
+
accusative: "مليونًا",
|
|
101
|
+
dual_nom: "مليونان",
|
|
102
|
+
dual_obl: "مليونين",
|
|
103
|
+
plural: "ملايين",
|
|
104
|
+
gender: Gender::Masculine,
|
|
105
|
+
omit_one: true,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const BILLION: Noun = Noun {
|
|
109
|
+
singular: "مليار",
|
|
110
|
+
accusative: "مليارًا",
|
|
111
|
+
dual_nom: "ملياران",
|
|
112
|
+
dual_obl: "مليارين",
|
|
113
|
+
plural: "مليارات",
|
|
114
|
+
gender: Gender::Masculine,
|
|
115
|
+
omit_one: true,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/// Currency: a main unit and a hundredth.
|
|
119
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
120
|
+
pub struct Currency {
|
|
121
|
+
pub unit: Noun,
|
|
122
|
+
pub sub: Noun,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
pub const DZD: Currency = Currency { unit: DINAR, sub: CENTIME };
|
|
126
|
+
|
|
127
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
128
|
+
pub struct Options {
|
|
129
|
+
pub case: Case,
|
|
130
|
+
/// Gender for bare numbers (no counted noun).
|
|
131
|
+
pub gender: Gender,
|
|
132
|
+
/// Spell hundreds `مئة` instead of `مائة`.
|
|
133
|
+
pub mia_spelling: bool,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Units 1..=10 in the form used with a noun of gender `g`
|
|
137
|
+
/// (3–10 take the opposite gender: masculine noun → `ثلاثة`).
|
|
138
|
+
fn unit(n: u64, g: Gender, case: Case) -> &'static str {
|
|
139
|
+
let masc_noun = g == Gender::Masculine;
|
|
140
|
+
match (n, masc_noun, case.oblique()) {
|
|
141
|
+
(1, true, _) => "واحد",
|
|
142
|
+
(1, false, _) => "واحدة",
|
|
143
|
+
(2, true, false) => "اثنان",
|
|
144
|
+
(2, true, true) => "اثنين",
|
|
145
|
+
(2, false, false) => "اثنتان",
|
|
146
|
+
(2, false, true) => "اثنتين",
|
|
147
|
+
(n, true, _) => ["", "", "", "ثلاثة", "أربعة", "خمسة", "ستة", "سبعة", "ثمانية", "تسعة", "عشرة"][n as usize],
|
|
148
|
+
(n, false, _) => ["", "", "", "ثلاث", "أربع", "خمس", "ست", "سبع", "ثماني", "تسع", "عشر"][n as usize],
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
fn tens(t: u64, case: Case) -> &'static str {
|
|
153
|
+
let nom = ["", "", "عشرون", "ثلاثون", "أربعون", "خمسون", "ستون", "سبعون", "ثمانون", "تسعون"];
|
|
154
|
+
let obl = ["", "", "عشرين", "ثلاثين", "أربعين", "خمسين", "ستين", "سبعين", "ثمانين", "تسعين"];
|
|
155
|
+
if case.oblique() {
|
|
156
|
+
obl[t as usize]
|
|
157
|
+
} else {
|
|
158
|
+
nom[t as usize]
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/// 1..=99 as a bare numeral agreeing with a noun of gender `g`.
|
|
163
|
+
fn below_100(n: u64, g: Gender, case: Case) -> String {
|
|
164
|
+
let masc = g == Gender::Masculine;
|
|
165
|
+
match n {
|
|
166
|
+
1..=10 => unit(n, g, case).to_string(),
|
|
167
|
+
11 => (if masc { "أحد عشر" } else { "إحدى عشرة" }).to_string(),
|
|
168
|
+
12 => match (masc, case.oblique()) {
|
|
169
|
+
(true, false) => "اثنا عشر",
|
|
170
|
+
(true, true) => "اثني عشر",
|
|
171
|
+
(false, false) => "اثنتا عشرة",
|
|
172
|
+
(false, true) => "اثنتي عشرة",
|
|
173
|
+
}
|
|
174
|
+
.to_string(),
|
|
175
|
+
13..=19 => format!("{} {}", unit(n - 10, g, case), if masc { "عشر" } else { "عشرة" }),
|
|
176
|
+
_ => {
|
|
177
|
+
let (t, u) = (n / 10, n % 10);
|
|
178
|
+
match u {
|
|
179
|
+
0 => tens(t, case).to_string(),
|
|
180
|
+
1 => format!("{} و{}", if masc { "واحد" } else { "إحدى" }, tens(t, case)),
|
|
181
|
+
_ => format!("{} و{}", unit(u, g, case), tens(t, case)),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// Hundreds word for h (1..=9). `construct`: a noun follows directly (`مائتا دينار`).
|
|
188
|
+
fn hundreds(h: u64, case: Case, construct: bool) -> &'static str {
|
|
189
|
+
match (h, case.oblique(), construct) {
|
|
190
|
+
(1, ..) => "مائة",
|
|
191
|
+
(2, false, false) => "مائتان",
|
|
192
|
+
(2, true, false) => "مائتين",
|
|
193
|
+
(2, false, true) => "مائتا",
|
|
194
|
+
(2, true, true) => "مائتي",
|
|
195
|
+
(h, ..) => ["", "", "", "ثلاثمائة", "أربعمائة", "خمسمائة", "ستمائة", "سبعمائة", "ثمانمائة", "تسعمائة"][h as usize],
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
fn dual(noun: &Noun, case: Case, construct: bool) -> String {
|
|
200
|
+
let d = if case.oblique() { noun.dual_obl } else { noun.dual_nom };
|
|
201
|
+
if construct {
|
|
202
|
+
d.strip_suffix('ن').unwrap_or(d).to_string()
|
|
203
|
+
} else {
|
|
204
|
+
d.to_string()
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// 1..=99 of `noun`. `construct`: another noun follows directly (`ألف دينار`).
|
|
209
|
+
fn count_small(r: u64, noun: &Noun, case: Case, construct: bool) -> String {
|
|
210
|
+
match r {
|
|
211
|
+
1 if noun.omit_one => noun.singular.to_string(),
|
|
212
|
+
1 => format!("{} {}", noun.singular, unit(1, noun.gender, case)),
|
|
213
|
+
2 => dual(noun, case, construct),
|
|
214
|
+
3..=10 => format!("{} {}", below_100(r, noun.gender, case), noun.plural),
|
|
215
|
+
_ => {
|
|
216
|
+
let n = if construct { noun.singular } else { noun.accusative };
|
|
217
|
+
format!("{} {n}", below_100(r, noun.gender, case))
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// 1..=999 of `noun`.
|
|
223
|
+
fn count(g: u64, noun: &Noun, case: Case, construct: bool) -> String {
|
|
224
|
+
let (h, r) = (g / 100, g % 100);
|
|
225
|
+
match (h, r) {
|
|
226
|
+
(0, r) => count_small(r, noun, case, construct),
|
|
227
|
+
(h, 0) => format!("{} {}", hundreds(h, case, true), noun.singular),
|
|
228
|
+
// `مائة ألف وألف`: the hundreds keep their own noun when 1 or 2 follows.
|
|
229
|
+
(h, 1 | 2) => format!("{} {} و{}", hundreds(h, case, true), noun.singular, count_small(r, noun, case, construct)),
|
|
230
|
+
(h, r) => format!("{} و{}", hundreds(h, case, false), count_small(r, noun, case, construct)),
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/// 1..=999 as a bare numeral.
|
|
235
|
+
fn bare_below_1000(n: u64, g: Gender, case: Case) -> String {
|
|
236
|
+
let (h, r) = (n / 100, n % 100);
|
|
237
|
+
match (h, r) {
|
|
238
|
+
(0, r) => below_100(r, g, case),
|
|
239
|
+
(h, 0) => hundreds(h, case, false).to_string(),
|
|
240
|
+
(h, r) => format!("{} و{}", hundreds(h, case, false), below_100(r, g, case)),
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
fn groups(n: u64) -> [(u64, Option<&'static Noun>); 4] {
|
|
245
|
+
[
|
|
246
|
+
(n / 1_000_000_000, Some(&BILLION)),
|
|
247
|
+
((n / 1_000_000) % 1000, Some(&MILLION)),
|
|
248
|
+
((n / 1000) % 1000, Some(&THOUSAND)),
|
|
249
|
+
(n % 1000, None),
|
|
250
|
+
]
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
fn finish(s: String, opts: &Options) -> String {
|
|
254
|
+
if opts.mia_spelling {
|
|
255
|
+
s.replace("مائ", "مئ")
|
|
256
|
+
} else {
|
|
257
|
+
s
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/// Spell `n` as a bare Arabic numeral (no counted noun).
|
|
262
|
+
pub fn number(n: u64, opts: &Options) -> Result<String, Error> {
|
|
263
|
+
if n > MAX {
|
|
264
|
+
return Err(Error::TooLarge(n));
|
|
265
|
+
}
|
|
266
|
+
if n == 0 {
|
|
267
|
+
return Ok("صفر".into());
|
|
268
|
+
}
|
|
269
|
+
let parts: Vec<String> = groups(n)
|
|
270
|
+
.iter()
|
|
271
|
+
.filter(|(g, _)| *g > 0)
|
|
272
|
+
.map(|&(g, scale)| match scale {
|
|
273
|
+
Some(noun) => count(g, noun, opts.case, false),
|
|
274
|
+
None => bare_below_1000(g, opts.gender, opts.case),
|
|
275
|
+
})
|
|
276
|
+
.collect();
|
|
277
|
+
Ok(finish(parts.join(" و"), opts))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/// Spell `n` followed by its counted noun, with full agreement.
|
|
281
|
+
pub fn with_noun(n: u64, noun: &Noun, opts: &Options) -> Result<String, Error> {
|
|
282
|
+
if n > MAX {
|
|
283
|
+
return Err(Error::TooLarge(n));
|
|
284
|
+
}
|
|
285
|
+
Ok(finish(with_noun_raw(n, noun, opts.case), opts))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
fn with_noun_raw(n: u64, noun: &Noun, case: Case) -> String {
|
|
289
|
+
if n == 0 {
|
|
290
|
+
return format!("صفر {}", noun.singular);
|
|
291
|
+
}
|
|
292
|
+
let r = n % 100;
|
|
293
|
+
if n > 100 && (r == 1 || r == 2) {
|
|
294
|
+
// "ألف دينار ودينار واحد": the round part carries its own noun.
|
|
295
|
+
return format!("{} و{}", with_noun_raw(n - r, noun, case), count_small(r, noun, case, false));
|
|
296
|
+
}
|
|
297
|
+
let gs = groups(n);
|
|
298
|
+
let units = gs[3].0;
|
|
299
|
+
let last_scale = gs.iter().rposition(|&(g, s)| g > 0 && s.is_some());
|
|
300
|
+
let mut parts = Vec::new();
|
|
301
|
+
for (i, &(g, scale)) in gs.iter().enumerate() {
|
|
302
|
+
if g == 0 {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
parts.push(match scale {
|
|
306
|
+
Some(s) => count(g, s, case, units == 0 && Some(i) == last_scale),
|
|
307
|
+
None => count(g, noun, case, false),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
let mut s = parts.join(" و");
|
|
311
|
+
if units == 0 {
|
|
312
|
+
s.push(' ');
|
|
313
|
+
s.push_str(noun.singular);
|
|
314
|
+
}
|
|
315
|
+
s
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/// `1234.50 DZD` → `ألف ومائتان وأربعة وثلاثون دينارًا وخمسون سنتيمًا`.
|
|
319
|
+
pub fn amount(units: u64, cents: u32, cur: &Currency, opts: &Options) -> Result<String, Error> {
|
|
320
|
+
if cents > 99 {
|
|
321
|
+
return Err(Error::BadCents(cents));
|
|
322
|
+
}
|
|
323
|
+
let main = with_noun(units, &cur.unit, opts)?;
|
|
324
|
+
if cents == 0 {
|
|
325
|
+
return Ok(main);
|
|
326
|
+
}
|
|
327
|
+
let sub = with_noun(cents as u64, &cur.sub, opts)?;
|
|
328
|
+
Ok(if units == 0 { sub } else { format!("{main} و{sub}") })
|
|
329
|
+
}
|
tafqit-0.1.0/src/fr.rs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
//! French number words (traditional spelling by default, or the 1990
|
|
2
|
+
//! "rectified" spelling with hyphens everywhere).
|
|
3
|
+
|
|
4
|
+
use crate::{Error, Gender, MAX};
|
|
5
|
+
|
|
6
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
7
|
+
pub enum Spelling {
|
|
8
|
+
/// Hyphens only between tens and units below 100 (`deux cent vingt et un`).
|
|
9
|
+
#[default]
|
|
10
|
+
Traditional,
|
|
11
|
+
/// 1990 rectifications: hyphens between all numerals (`deux-cent-vingt-et-un`);
|
|
12
|
+
/// `million`/`milliard` are nouns and stay separate words.
|
|
13
|
+
Rectified1990,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
17
|
+
pub struct Options {
|
|
18
|
+
pub spelling: Spelling,
|
|
19
|
+
/// Gender of the counted noun: `un`/`une` (also in `vingt et une`).
|
|
20
|
+
pub gender: Gender,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const UNITS: [&str; 17] = [
|
|
24
|
+
"zéro", "un", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", "dix", "onze", "douze", "treize",
|
|
25
|
+
"quatorze", "quinze", "seize",
|
|
26
|
+
];
|
|
27
|
+
const TENS: [&str; 7] = ["", "", "vingt", "trente", "quarante", "cinquante", "soixante"];
|
|
28
|
+
|
|
29
|
+
/// 1..=99 as a list of words; `last` means nothing follows in the whole number
|
|
30
|
+
/// (needed for the plural of `quatre-vingts`). `fem` turns a final `un` into `une`.
|
|
31
|
+
fn below_100(n: u64, last: bool, fem: bool, sp: Spelling) -> Vec<String> {
|
|
32
|
+
debug_assert!((1..100).contains(&n));
|
|
33
|
+
let join = |a: &str, b: &str| format!("{a}-{b}");
|
|
34
|
+
let one = if fem { "une" } else { "un" };
|
|
35
|
+
let w = match n {
|
|
36
|
+
1 => one.to_string(),
|
|
37
|
+
2..=16 => UNITS[n as usize].to_string(),
|
|
38
|
+
17..=19 => join("dix", UNITS[n as usize - 10]),
|
|
39
|
+
20..=69 => {
|
|
40
|
+
let (t, u) = (n / 10, n % 10);
|
|
41
|
+
match u {
|
|
42
|
+
0 => TENS[t as usize].to_string(),
|
|
43
|
+
1 => match sp {
|
|
44
|
+
Spelling::Traditional => return vec![TENS[t as usize].into(), "et".into(), one.into()],
|
|
45
|
+
Spelling::Rectified1990 => format!("{}-et-{one}", TENS[t as usize]),
|
|
46
|
+
},
|
|
47
|
+
_ => join(TENS[t as usize], UNITS[u as usize]),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
70..=79 => {
|
|
51
|
+
let r = n - 60;
|
|
52
|
+
if r == 11 {
|
|
53
|
+
match sp {
|
|
54
|
+
Spelling::Traditional => return vec!["soixante".into(), "et".into(), "onze".into()],
|
|
55
|
+
Spelling::Rectified1990 => "soixante-et-onze".into(),
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
join("soixante", &below_100(r, last, fem, sp).join("-"))
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
80 => (if last { "quatre-vingts" } else { "quatre-vingt" }).to_string(),
|
|
62
|
+
81..=99 => join("quatre-vingt", &below_100(n - 80, last, fem, sp).join("-")),
|
|
63
|
+
_ => unreachable!(),
|
|
64
|
+
};
|
|
65
|
+
vec![w]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// 1..=999 as words. `last`: nothing follows (affects `cents`/`vingts`).
|
|
69
|
+
/// `before_noun`: followed by `million(s)`/`milliard(s)` (a noun, so `cents` keeps its s).
|
|
70
|
+
fn below_1000(n: u64, last: bool, before_noun: bool, fem: bool, sp: Spelling) -> Vec<String> {
|
|
71
|
+
let (h, r) = (n / 100, n % 100);
|
|
72
|
+
let mut out = Vec::new();
|
|
73
|
+
if h > 0 {
|
|
74
|
+
if h > 1 {
|
|
75
|
+
out.push(UNITS[h as usize].to_string());
|
|
76
|
+
}
|
|
77
|
+
let plural = h > 1 && r == 0 && (last || before_noun);
|
|
78
|
+
out.push(if plural { "cents" } else { "cent" }.to_string());
|
|
79
|
+
}
|
|
80
|
+
if r > 0 {
|
|
81
|
+
out.extend(below_100(r, last || before_noun, fem, sp));
|
|
82
|
+
}
|
|
83
|
+
out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Spell `n` (0..=999_999_999_999) in French.
|
|
87
|
+
pub fn number(n: u64, opts: &Options) -> Result<String, Error> {
|
|
88
|
+
if n > MAX {
|
|
89
|
+
return Err(Error::TooLarge(n));
|
|
90
|
+
}
|
|
91
|
+
if n == 0 {
|
|
92
|
+
return Ok("zéro".into());
|
|
93
|
+
}
|
|
94
|
+
let sp = opts.spelling;
|
|
95
|
+
let groups = [(n / 1_000_000_000, "milliard"), ((n / 1_000_000) % 1000, "million"), ((n / 1000) % 1000, "mille"), (n % 1000, "")];
|
|
96
|
+
// Each scale part is a word list; parts are joined with spaces, and with
|
|
97
|
+
// hyphens around `mille` in rectified spelling.
|
|
98
|
+
let mut parts: Vec<String> = Vec::new();
|
|
99
|
+
for &(g, scale) in &groups {
|
|
100
|
+
if g == 0 {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
let fem = opts.gender == Gender::Feminine && scale.is_empty();
|
|
104
|
+
match scale {
|
|
105
|
+
"milliard" | "million" => {
|
|
106
|
+
let w = below_1000(g, false, true, false, sp);
|
|
107
|
+
let noun = if g > 1 { format!("{scale}s") } else { scale.to_string() };
|
|
108
|
+
parts.push(format!("{} {noun}", join_words(&w, sp)));
|
|
109
|
+
}
|
|
110
|
+
"mille" => {
|
|
111
|
+
// "mille", never "un mille"; invariable.
|
|
112
|
+
let mut w = if g == 1 { vec![] } else { below_1000(g, false, false, false, sp) };
|
|
113
|
+
w.push("mille".into());
|
|
114
|
+
parts.push(join_words(&w, sp));
|
|
115
|
+
}
|
|
116
|
+
_ => parts.push(join_words(&below_1000(g, true, false, fem, sp), sp)),
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
Ok(match sp {
|
|
120
|
+
Spelling::Traditional => parts.join(" "),
|
|
121
|
+
Spelling::Rectified1990 => {
|
|
122
|
+
// Hyphenate between numerals, but keep spaces around million(s)/milliard(s).
|
|
123
|
+
let mut s = String::new();
|
|
124
|
+
for (i, p) in parts.iter().enumerate() {
|
|
125
|
+
if i > 0 {
|
|
126
|
+
let prev_is_noun = parts[i - 1].ends_with("million")
|
|
127
|
+
|| parts[i - 1].ends_with("millions")
|
|
128
|
+
|| parts[i - 1].ends_with("milliard")
|
|
129
|
+
|| parts[i - 1].ends_with("milliards");
|
|
130
|
+
s.push(if prev_is_noun { ' ' } else { '-' });
|
|
131
|
+
}
|
|
132
|
+
s.push_str(p);
|
|
133
|
+
}
|
|
134
|
+
s
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
fn join_words(w: &[String], sp: Spelling) -> String {
|
|
140
|
+
match sp {
|
|
141
|
+
Spelling::Traditional => w.join(" "),
|
|
142
|
+
Spelling::Rectified1990 => w.join("-"),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// French currency names.
|
|
147
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
148
|
+
pub struct Currency {
|
|
149
|
+
pub unit: (&'static str, &'static str),
|
|
150
|
+
pub unit_gender: Gender,
|
|
151
|
+
pub sub: (&'static str, &'static str),
|
|
152
|
+
pub sub_gender: Gender,
|
|
153
|
+
/// Elided form of "de" before the unit (`d'euros`), if the unit starts with a vowel.
|
|
154
|
+
pub elide: bool,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
pub const DINAR: Currency = Currency {
|
|
158
|
+
unit: ("dinar", "dinars"),
|
|
159
|
+
unit_gender: Gender::Masculine,
|
|
160
|
+
sub: ("centime", "centimes"),
|
|
161
|
+
sub_gender: Gender::Masculine,
|
|
162
|
+
elide: false,
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
fn with_noun(n: u64, noun: (&str, &str), gender: Gender, elide: bool, opts: &Options) -> Result<String, Error> {
|
|
166
|
+
let words = number(n, &Options { gender, ..*opts })?;
|
|
167
|
+
let name = if n >= 2 { noun.1 } else { noun.0 };
|
|
168
|
+
// "un million de dinars": a round number of millions/billions takes "de".
|
|
169
|
+
let de = n >= 1_000_000 && n.is_multiple_of(1_000_000);
|
|
170
|
+
Ok(match (de, elide) {
|
|
171
|
+
(true, true) => format!("{words} d'{name}"),
|
|
172
|
+
(true, false) => format!("{words} de {name}"),
|
|
173
|
+
_ => format!("{words} {name}"),
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// `1234.50 DZD` → `mille deux cent trente-quatre dinars et cinquante centimes`.
|
|
178
|
+
pub fn amount(units: u64, cents: u32, cur: &Currency, opts: &Options) -> Result<String, Error> {
|
|
179
|
+
if cents > 99 {
|
|
180
|
+
return Err(Error::BadCents(cents));
|
|
181
|
+
}
|
|
182
|
+
let main = with_noun(units, cur.unit, cur.unit_gender, cur.elide, opts)?;
|
|
183
|
+
if cents == 0 {
|
|
184
|
+
return Ok(main);
|
|
185
|
+
}
|
|
186
|
+
let sub = with_noun(cents as u64, cur.sub, cur.sub_gender, false, opts)?;
|
|
187
|
+
Ok(if units == 0 { sub } else { format!("{main} et {sub}") })
|
|
188
|
+
}
|
tafqit-0.1.0/src/lib.rs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
//! Tafqit (تفقيط): write numbers and money amounts in words, in Arabic and
|
|
2
|
+
//! French, for invoices and cheques. Dinar/centime forms are built in.
|
|
3
|
+
//!
|
|
4
|
+
//! ```
|
|
5
|
+
//! use tafqit::{ar, fr, Amount};
|
|
6
|
+
//!
|
|
7
|
+
//! let a: Amount = "1234.50".parse().unwrap();
|
|
8
|
+
//! assert_eq!(
|
|
9
|
+
//! a.to_arabic(&ar::DZD, &ar::Options::default()).unwrap(),
|
|
10
|
+
//! "ألف ومائتان وأربعة وثلاثون دينارًا وخمسون سنتيمًا"
|
|
11
|
+
//! );
|
|
12
|
+
//! assert_eq!(
|
|
13
|
+
//! a.to_french(&fr::DINAR, &fr::Options::default()).unwrap(),
|
|
14
|
+
//! "mille deux cent trente-quatre dinars et cinquante centimes"
|
|
15
|
+
//! );
|
|
16
|
+
//! assert_eq!(ar::number(2000, &Default::default()).unwrap(), "ألفان");
|
|
17
|
+
//! ```
|
|
18
|
+
|
|
19
|
+
pub mod ar;
|
|
20
|
+
pub mod fr;
|
|
21
|
+
|
|
22
|
+
use std::fmt;
|
|
23
|
+
use std::str::FromStr;
|
|
24
|
+
|
|
25
|
+
/// Largest supported number: 999 999 999 999 (up to the billions).
|
|
26
|
+
pub const MAX: u64 = 999_999_999_999;
|
|
27
|
+
|
|
28
|
+
/// Grammatical gender of the counted noun.
|
|
29
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
30
|
+
pub enum Gender {
|
|
31
|
+
#[default]
|
|
32
|
+
Masculine,
|
|
33
|
+
Feminine,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
37
|
+
pub enum Error {
|
|
38
|
+
TooLarge(u64),
|
|
39
|
+
BadCents(u32),
|
|
40
|
+
Parse(String),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
impl fmt::Display for Error {
|
|
44
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
45
|
+
match self {
|
|
46
|
+
Error::TooLarge(n) => write!(f, "{n} is too large (max {MAX})"),
|
|
47
|
+
Error::BadCents(c) => write!(f, "invalid hundredths: {c} (must be 0..=99)"),
|
|
48
|
+
Error::Parse(s) => write!(f, "cannot parse amount: {s}"),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
impl std::error::Error for Error {}
|
|
54
|
+
|
|
55
|
+
/// A money amount: whole units plus hundredths.
|
|
56
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
57
|
+
pub struct Amount {
|
|
58
|
+
pub units: u64,
|
|
59
|
+
pub cents: u32,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
impl Amount {
|
|
63
|
+
pub fn new(units: u64, cents: u32) -> Result<Amount, Error> {
|
|
64
|
+
if cents > 99 {
|
|
65
|
+
return Err(Error::BadCents(cents));
|
|
66
|
+
}
|
|
67
|
+
if units > MAX {
|
|
68
|
+
return Err(Error::TooLarge(units));
|
|
69
|
+
}
|
|
70
|
+
Ok(Amount { units, cents })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
pub fn to_arabic(&self, cur: &ar::Currency, opts: &ar::Options) -> Result<String, Error> {
|
|
74
|
+
ar::amount(self.units, self.cents, cur, opts)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pub fn to_french(&self, cur: &fr::Currency, opts: &fr::Options) -> Result<String, Error> {
|
|
78
|
+
fr::amount(self.units, self.cents, cur, opts)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Cheque wording: `فقط ... لا غير`.
|
|
82
|
+
pub fn to_arabic_cheque(&self, cur: &ar::Currency, opts: &ar::Options) -> Result<String, Error> {
|
|
83
|
+
Ok(format!("فقط {} لا غير", self.to_arabic(cur, opts)?))
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
impl FromStr for Amount {
|
|
88
|
+
type Err = Error;
|
|
89
|
+
|
|
90
|
+
/// Accepts `1234`, `1234.5`, `1234,50`, `1 234,50`, `1,234.50`, `1_234.50`.
|
|
91
|
+
/// With both `,` and `.`, the last one is the decimal separator.
|
|
92
|
+
/// At most two decimals; no rounding is done.
|
|
93
|
+
fn from_str(s: &str) -> Result<Amount, Error> {
|
|
94
|
+
let err = || Error::Parse(s.to_string());
|
|
95
|
+
let cleaned: String = s.trim().chars().filter(|c| !matches!(c, ' ' | '_' | '\'' | '\u{a0}' | '\u{202f}')).collect();
|
|
96
|
+
if cleaned.is_empty() {
|
|
97
|
+
return Err(err());
|
|
98
|
+
}
|
|
99
|
+
let dec_pos = cleaned.rfind(['.', ',']);
|
|
100
|
+
let (int_part, frac) = match dec_pos {
|
|
101
|
+
Some(p) => {
|
|
102
|
+
let frac = &cleaned[p + 1..];
|
|
103
|
+
// "1,234" (three digits after a lone comma/dot, no other separator) is a thousands separator.
|
|
104
|
+
let other_sep = cleaned[..p].contains(['.', ',']);
|
|
105
|
+
if frac.len() == 3 && !other_sep && frac.chars().all(|c| c.is_ascii_digit()) {
|
|
106
|
+
(cleaned.replace([',', '.'], ""), String::new())
|
|
107
|
+
} else {
|
|
108
|
+
(cleaned[..p].replace([',', '.'], ""), frac.to_string())
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
None => (cleaned.clone(), String::new()),
|
|
112
|
+
};
|
|
113
|
+
if int_part.is_empty() && frac.is_empty() {
|
|
114
|
+
return Err(err());
|
|
115
|
+
}
|
|
116
|
+
if !int_part.chars().all(|c| c.is_ascii_digit()) || !frac.chars().all(|c| c.is_ascii_digit()) || frac.len() > 2 {
|
|
117
|
+
return Err(err());
|
|
118
|
+
}
|
|
119
|
+
let units: u64 = if int_part.is_empty() { 0 } else { int_part.parse().map_err(|_| err())? };
|
|
120
|
+
let cents: u32 = match frac.len() {
|
|
121
|
+
0 => 0,
|
|
122
|
+
1 => frac.parse::<u32>().unwrap() * 10,
|
|
123
|
+
_ => frac.parse().unwrap(),
|
|
124
|
+
};
|
|
125
|
+
Amount::new(units, cents)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#[cfg(test)]
|
|
130
|
+
mod tests {
|
|
131
|
+
use super::*;
|
|
132
|
+
|
|
133
|
+
#[test]
|
|
134
|
+
fn parse_amounts() {
|
|
135
|
+
let ok = |s: &str, u: u64, c: u32| assert_eq!(s.parse::<Amount>(), Ok(Amount { units: u, cents: c }), "{s}");
|
|
136
|
+
ok("1234", 1234, 0);
|
|
137
|
+
ok("1234.5", 1234, 50);
|
|
138
|
+
ok("1234,05", 1234, 5);
|
|
139
|
+
ok("1 234,50", 1234, 50);
|
|
140
|
+
ok("1,234.50", 1234, 50);
|
|
141
|
+
ok("1.234,50", 1234, 50);
|
|
142
|
+
ok("1,234", 1234, 0);
|
|
143
|
+
ok("12.345", 12345, 0); // lone separator + 3 digits = thousands
|
|
144
|
+
ok("1_000_000", 1_000_000, 0);
|
|
145
|
+
ok(".75", 0, 75);
|
|
146
|
+
for bad in ["", "abc", "1.234.5x", "12.3456", "-5", "1e3", "1000000000000"] {
|
|
147
|
+
assert!(bad.parse::<Amount>().is_err(), "{bad} should fail");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
tafqit-0.1.0/src/main.rs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
use std::process::ExitCode;
|
|
2
|
+
use tafqit::{ar, fr, Amount, Gender};
|
|
3
|
+
|
|
4
|
+
const USAGE: &str = "usage: tafqit [options] <amount>
|
|
5
|
+
|
|
6
|
+
options:
|
|
7
|
+
--lang ar|fr|both output language(s) (default: both)
|
|
8
|
+
--number spell a bare number, no currency
|
|
9
|
+
--case nom|acc|gen Arabic grammatical case (default: nom)
|
|
10
|
+
--feminine feminine counted noun (bare numbers; French une)
|
|
11
|
+
--cheque Arabic cheque wording: فقط ... لا غير
|
|
12
|
+
--rectified French 1990 spelling (hyphens everywhere)
|
|
13
|
+
--mia Arabic spelling مئة instead of مائة
|
|
14
|
+
|
|
15
|
+
examples:
|
|
16
|
+
tafqit 1234.50
|
|
17
|
+
tafqit --lang ar --cheque 2000000
|
|
18
|
+
tafqit --number --feminine 21";
|
|
19
|
+
|
|
20
|
+
fn main() -> ExitCode {
|
|
21
|
+
let mut lang = "both".to_string();
|
|
22
|
+
let mut bare = false;
|
|
23
|
+
let mut cheque = false;
|
|
24
|
+
let mut aro = ar::Options::default();
|
|
25
|
+
let mut fro = fr::Options::default();
|
|
26
|
+
let mut amount: Option<String> = None;
|
|
27
|
+
let mut args = std::env::args().skip(1);
|
|
28
|
+
while let Some(a) = args.next() {
|
|
29
|
+
match a.as_str() {
|
|
30
|
+
"--lang" => lang = args.next().unwrap_or_default(),
|
|
31
|
+
"--number" => bare = true,
|
|
32
|
+
"--cheque" => cheque = true,
|
|
33
|
+
"--feminine" => {
|
|
34
|
+
aro.gender = Gender::Feminine;
|
|
35
|
+
fro.gender = Gender::Feminine;
|
|
36
|
+
}
|
|
37
|
+
"--rectified" => fro.spelling = fr::Spelling::Rectified1990,
|
|
38
|
+
"--mia" => aro.mia_spelling = true,
|
|
39
|
+
"--case" => {
|
|
40
|
+
aro.case = match args.next().as_deref() {
|
|
41
|
+
Some("nom") => ar::Case::Nominative,
|
|
42
|
+
Some("acc") => ar::Case::Accusative,
|
|
43
|
+
Some("gen") => ar::Case::Genitive,
|
|
44
|
+
_ => return usage(),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
"-h" | "--help" => {
|
|
48
|
+
println!("{USAGE}");
|
|
49
|
+
return ExitCode::SUCCESS;
|
|
50
|
+
}
|
|
51
|
+
s if amount.is_none() && !s.starts_with("--") => amount = Some(s.to_string()),
|
|
52
|
+
_ => return usage(),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
let Some(raw) = amount else { return usage() };
|
|
56
|
+
let a: Amount = match raw.parse() {
|
|
57
|
+
Ok(a) => a,
|
|
58
|
+
Err(e) => {
|
|
59
|
+
eprintln!("tafqit: {e}");
|
|
60
|
+
return ExitCode::from(1);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
if bare && a.cents != 0 {
|
|
64
|
+
eprintln!("tafqit: --number takes a whole number");
|
|
65
|
+
return ExitCode::from(1);
|
|
66
|
+
}
|
|
67
|
+
let arabic = || -> Result<String, tafqit::Error> {
|
|
68
|
+
match (bare, cheque) {
|
|
69
|
+
(true, _) => ar::number(a.units, &aro),
|
|
70
|
+
(false, true) => a.to_arabic_cheque(&ar::DZD, &aro),
|
|
71
|
+
(false, false) => a.to_arabic(&ar::DZD, &aro),
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
let french = || if bare { fr::number(a.units, &fro) } else { a.to_french(&fr::DINAR, &fro) };
|
|
75
|
+
let mut outputs = Vec::new();
|
|
76
|
+
if lang == "ar" || lang == "both" {
|
|
77
|
+
outputs.push(arabic());
|
|
78
|
+
}
|
|
79
|
+
if lang == "fr" || lang == "both" {
|
|
80
|
+
outputs.push(french());
|
|
81
|
+
}
|
|
82
|
+
if outputs.is_empty() {
|
|
83
|
+
return usage();
|
|
84
|
+
}
|
|
85
|
+
for o in outputs {
|
|
86
|
+
match o {
|
|
87
|
+
Ok(s) => println!("{s}"),
|
|
88
|
+
Err(e) => {
|
|
89
|
+
eprintln!("tafqit: {e}");
|
|
90
|
+
return ExitCode::from(1);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
ExitCode::SUCCESS
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fn usage() -> ExitCode {
|
|
98
|
+
eprintln!("{USAGE}");
|
|
99
|
+
ExitCode::from(2)
|
|
100
|
+
}
|