pagesequence 2.0.0rc1__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.
- pagesequence-2.0.0rc1/LICENSE +21 -0
- pagesequence-2.0.0rc1/PKG-INFO +51 -0
- pagesequence-2.0.0rc1/README.md +38 -0
- pagesequence-2.0.0rc1/pyproject.toml +21 -0
- pagesequence-2.0.0rc1/pyproject.toml.orig +18 -0
- pagesequence-2.0.0rc1/src/pagesequence/__init__.py +30 -0
- pagesequence-2.0.0rc1/src/pagesequence/_core.py +898 -0
- pagesequence-2.0.0rc1/src/pagesequence/plugins/__init__.py +2 -0
- pagesequence-2.0.0rc1/src/pagesequence/plugins/greek.py +6 -0
- pagesequence-2.0.0rc1/src/pagesequence/plugins/hebrew.py +7 -0
- pagesequence-2.0.0rc1/src/pagesequence/plugins/latin_sv.py +6 -0
- pagesequence-2.0.0rc1/src/pagesequence/plugins/roman_additive.py +33 -0
- pagesequence-2.0.0rc1/src/pagesequence/roman.py +55 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 Lasse Hellman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pagesequence
|
|
3
|
+
Version: 2.0.0rc1
|
|
4
|
+
Summary: Map the pages of a document (like a PDF) to the labels printed on them.
|
|
5
|
+
Author: Lasse Hellman
|
|
6
|
+
Author-email: Lasse Hellman <github@tvartom.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Project-URL: Homepage, https://github.com/publication-md/pagesequence
|
|
11
|
+
Project-URL: Specification, https://github.com/publication-md/pagesequence/blob/main/spec/pagesequence.md
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# PageSequence
|
|
15
|
+
|
|
16
|
+
A **page sequence** is a short string that says which labels are printed on each page of a
|
|
17
|
+
document, such as a PDF. With it, a reference written in the labels printed in the publication,
|
|
18
|
+
such as `"20-21"`, is turned into the right pages of the document.
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pip install pagesequence
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from pagesequence import PageSequence
|
|
26
|
+
|
|
27
|
+
# An unnumbered cover, roman front matter, then pages 1 to 48: 53 pages in all.
|
|
28
|
+
seq = PageSequence('"Omslag", i-iv, 1-48', 53)
|
|
29
|
+
seq.get_index_list("20-21") # [24, 25]: the document pages, counted from 0
|
|
30
|
+
seq.format_at(1) # "i"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Invalid input raises a `PageSequenceError` with `kind` and `position`; `PageSequence.create`
|
|
34
|
+
returns `None` instead. `normalize`, `clean` and `equals` work on sequence strings.
|
|
35
|
+
|
|
36
|
+
The plugin systems are imported from their own modules and passed in each call:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from pagesequence import normalize
|
|
40
|
+
from pagesequence.plugins.roman_additive import roman_additive
|
|
41
|
+
|
|
42
|
+
normalize("I, II, III, IIII, V", plugins=[roman_additive]) # "I-V/roman_additive"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The plugins are `roman_additive`, `latin_sv`, `greek` and `hebrew`. The package has no
|
|
46
|
+
dependencies and needs Python 3.11 or later.
|
|
47
|
+
|
|
48
|
+
The notation and the API are in the
|
|
49
|
+
[specification](https://github.com/publication-md/pagesequence/blob/main/spec/pagesequence.md).
|
|
50
|
+
PageSequence is part of [publication-md](https://github.com/publication-md). The code is under the
|
|
51
|
+
MIT licence.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# PageSequence
|
|
2
|
+
|
|
3
|
+
A **page sequence** is a short string that says which labels are printed on each page of a
|
|
4
|
+
document, such as a PDF. With it, a reference written in the labels printed in the publication,
|
|
5
|
+
such as `"20-21"`, is turned into the right pages of the document.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install pagesequence
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from pagesequence import PageSequence
|
|
13
|
+
|
|
14
|
+
# An unnumbered cover, roman front matter, then pages 1 to 48: 53 pages in all.
|
|
15
|
+
seq = PageSequence('"Omslag", i-iv, 1-48', 53)
|
|
16
|
+
seq.get_index_list("20-21") # [24, 25]: the document pages, counted from 0
|
|
17
|
+
seq.format_at(1) # "i"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Invalid input raises a `PageSequenceError` with `kind` and `position`; `PageSequence.create`
|
|
21
|
+
returns `None` instead. `normalize`, `clean` and `equals` work on sequence strings.
|
|
22
|
+
|
|
23
|
+
The plugin systems are imported from their own modules and passed in each call:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from pagesequence import normalize
|
|
27
|
+
from pagesequence.plugins.roman_additive import roman_additive
|
|
28
|
+
|
|
29
|
+
normalize("I, II, III, IIII, V", plugins=[roman_additive]) # "I-V/roman_additive"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The plugins are `roman_additive`, `latin_sv`, `greek` and `hebrew`. The package has no
|
|
33
|
+
dependencies and needs Python 3.11 or later.
|
|
34
|
+
|
|
35
|
+
The notation and the API are in the
|
|
36
|
+
[specification](https://github.com/publication-md/pagesequence/blob/main/spec/pagesequence.md).
|
|
37
|
+
PageSequence is part of [publication-md](https://github.com/publication-md). The code is under the
|
|
38
|
+
MIT licence.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pagesequence"
|
|
3
|
+
version = "2.0.0rc1"
|
|
4
|
+
description = "Map the pages of a document (like a PDF) to the labels printed on them."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[[project.authors]]
|
|
12
|
+
name = "Lasse Hellman"
|
|
13
|
+
email = "github@tvartom.com"
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Homepage = "https://github.com/publication-md/pagesequence"
|
|
17
|
+
Specification = "https://github.com/publication-md/pagesequence/blob/main/spec/pagesequence.md"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["uv_build>=0.13.1,<0.14"]
|
|
21
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pagesequence"
|
|
3
|
+
version = "2.0.0rc1"
|
|
4
|
+
description = "Map the pages of a document (like a PDF) to the labels printed on them."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Lasse Hellman", email = "github@tvartom.com" }]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Homepage = "https://github.com/publication-md/pagesequence"
|
|
14
|
+
Specification = "https://github.com/publication-md/pagesequence/blob/main/spec/pagesequence.md"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.13.1,<0.14"]
|
|
18
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""PageSequence: map the pages of a document to the labels printed on them.
|
|
2
|
+
|
|
3
|
+
The plugin systems are not imported here. Each one is imported from its own module, such as
|
|
4
|
+
``pagesequence.plugins.roman_additive``, and passed in ``plugins``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._core import (
|
|
8
|
+
Cleaned,
|
|
9
|
+
NumberingSystem,
|
|
10
|
+
PageSequence,
|
|
11
|
+
PageSequenceError,
|
|
12
|
+
alphabet,
|
|
13
|
+
clean,
|
|
14
|
+
equals,
|
|
15
|
+
normalize,
|
|
16
|
+
)
|
|
17
|
+
from .roman import from_roman, to_roman
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Cleaned",
|
|
21
|
+
"NumberingSystem",
|
|
22
|
+
"PageSequence",
|
|
23
|
+
"PageSequenceError",
|
|
24
|
+
"alphabet",
|
|
25
|
+
"clean",
|
|
26
|
+
"equals",
|
|
27
|
+
"from_roman",
|
|
28
|
+
"normalize",
|
|
29
|
+
"to_roman",
|
|
30
|
+
]
|
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
"""PageSequence: map the pages of a document to the labels printed on them.
|
|
2
|
+
|
|
3
|
+
The format is in spec/pagesequence.md; this module follows it section by section, and has the same
|
|
4
|
+
structure as the TypeScript module ts/src/pagesequence.ts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import unicodedata
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import NamedTuple, Protocol, Sequence, overload
|
|
13
|
+
|
|
14
|
+
from .roman import from_roman, to_roman
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PageSequenceError(ValueError):
|
|
18
|
+
"""An invalid sequence. ``position`` counts code points after NFC, or is ``None`` for page
|
|
19
|
+
counts."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, kind: str, position: int | None, message: str | None = None):
|
|
22
|
+
if message is None:
|
|
23
|
+
message = kind if position is None else f"{kind} at position {position}"
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.kind = kind
|
|
26
|
+
self.position = position
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NumberingSystem(Protocol):
|
|
30
|
+
"""A numbering system (see "API" in the spec)."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
#: The characters that widening moves over, or ``""``.
|
|
34
|
+
token_chars: str
|
|
35
|
+
|
|
36
|
+
def read(self, token: str) -> tuple[int, str] | None: ...
|
|
37
|
+
|
|
38
|
+
def format(self, value: int, style: str) -> str | None: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Alphabet:
|
|
42
|
+
def __init__(self, name: str, upper: str, lower: str | None):
|
|
43
|
+
self.name = name
|
|
44
|
+
self.token_chars = ""
|
|
45
|
+
self._cases = [upper] if lower is None else [upper, lower]
|
|
46
|
+
self._styles = [""] if lower is None else ["upper", "lower"]
|
|
47
|
+
|
|
48
|
+
def read(self, token: str) -> tuple[int, str] | None:
|
|
49
|
+
if len(token) != 1:
|
|
50
|
+
return None
|
|
51
|
+
for letters, style in zip(self._cases, self._styles):
|
|
52
|
+
i = letters.find(token)
|
|
53
|
+
if i >= 0:
|
|
54
|
+
return i + 1, style
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def format(self, value: int, style: str) -> str | None:
|
|
58
|
+
if style not in self._styles:
|
|
59
|
+
return None
|
|
60
|
+
letters = self._cases[self._styles.index(style)]
|
|
61
|
+
return letters[value - 1] if 1 <= value <= len(letters) else None
|
|
62
|
+
|
|
63
|
+
def __repr__(self) -> str:
|
|
64
|
+
return f"alphabet({self.name!r})"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def alphabet(name: str, upper: str, lower: str | None = None) -> NumberingSystem:
|
|
68
|
+
"""Make a system of single letters with values by position, A = 1. Without ``lower``, the
|
|
69
|
+
letters have no case."""
|
|
70
|
+
return _Alphabet(name, upper, lower)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _Roman:
|
|
74
|
+
name = "roman"
|
|
75
|
+
token_chars = "IVXLCDMivxlcdm"
|
|
76
|
+
|
|
77
|
+
def read(self, token: str) -> tuple[int, str] | None:
|
|
78
|
+
value = from_roman(token)
|
|
79
|
+
return None if value is None else (value, "upper" if token == token.upper() else "lower")
|
|
80
|
+
|
|
81
|
+
def format(self, value: int, style: str) -> str | None:
|
|
82
|
+
return to_roman(value, style == "lower") if 1 <= value <= 3999 else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _Integer:
|
|
86
|
+
name = "integer"
|
|
87
|
+
token_chars = "0123456789"
|
|
88
|
+
|
|
89
|
+
def read(self, token: str) -> tuple[int, str] | None:
|
|
90
|
+
# [0-9] is ASCII only; int() alone would also read other digits.
|
|
91
|
+
return (int(token), "") if re.fullmatch(r"0|[1-9][0-9]{0,8}", token) else None
|
|
92
|
+
|
|
93
|
+
def format(self, value: int, style: str) -> str | None:
|
|
94
|
+
return str(value) if 0 <= value <= 999_999_999 else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_integer = _Integer()
|
|
98
|
+
#: The built-in systems, in hierarchy order.
|
|
99
|
+
_BUILT_IN: tuple[NumberingSystem, ...] = (
|
|
100
|
+
_Roman(),
|
|
101
|
+
alphabet("latin", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz"),
|
|
102
|
+
_integer,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
_MAX_LABEL = 100
|
|
106
|
+
_DEFAULT_MAX_LABELS = 10_000
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class _Context:
|
|
111
|
+
"""The systems and limit one call reads with."""
|
|
112
|
+
|
|
113
|
+
by_name: dict[str, NumberingSystem]
|
|
114
|
+
#: The built-in systems in hierarchy order, then the plugins in name order (for the fold).
|
|
115
|
+
fold_order: list[NumberingSystem]
|
|
116
|
+
max_labels: int
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _context(plugins: Sequence[NumberingSystem] = (), max_labels: int = _DEFAULT_MAX_LABELS) -> _Context:
|
|
120
|
+
by_name = {s.name: s for s in _BUILT_IN}
|
|
121
|
+
for p in plugins:
|
|
122
|
+
if p.name in by_name:
|
|
123
|
+
what = "built in" if by_name[p.name] in _BUILT_IN else "given twice"
|
|
124
|
+
raise TypeError(f'plugins: "{p.name}" is {what}')
|
|
125
|
+
by_name[p.name] = p
|
|
126
|
+
if isinstance(max_labels, bool) or not isinstance(max_labels, int) or max_labels < 1:
|
|
127
|
+
raise ValueError(f"max_labels: {max_labels!r} is not a positive integer")
|
|
128
|
+
return _Context(by_name, [*_BUILT_IN, *sorted(plugins, key=lambda p: p.name)], max_labels)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------- Syntax
|
|
132
|
+
|
|
133
|
+
#: Reserved outside quotes: the operators, the characters ``clean`` repairs, and ``\``.
|
|
134
|
+
_RESERVED = set(',-+!"/\\;|…–—−“”„')
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _is_control(c: str) -> bool:
|
|
138
|
+
return c <= "\x1f" or "\x7f" <= c <= "\x9f"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _is_reserved(c: str) -> bool:
|
|
142
|
+
return c in _RESERVED or _is_control(c)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class _Token:
|
|
147
|
+
#: "label", "-", "+", "..", "|" or "/".
|
|
148
|
+
t: str
|
|
149
|
+
#: The label's text, or the flag's name.
|
|
150
|
+
text: str = ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class _LexedItem:
|
|
155
|
+
#: The first non-space character, or just after the comma for an empty item.
|
|
156
|
+
start: int
|
|
157
|
+
ignored: bool
|
|
158
|
+
tokens: list[_Token]
|
|
159
|
+
#: Index of the comma that ends the item, or the text's length.
|
|
160
|
+
end: int
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _lex_item(cps: str, i: int, pipe: bool = False) -> _LexedItem:
|
|
164
|
+
"""Lex one item from ``i`` (just after a comma, or 0). With ``pipe``, ``|`` is an operator
|
|
165
|
+
(for ``clean``)."""
|
|
166
|
+
n = len(cps)
|
|
167
|
+
j = i
|
|
168
|
+
while j < n and cps[j] == " ":
|
|
169
|
+
j += 1
|
|
170
|
+
start = j
|
|
171
|
+
|
|
172
|
+
def fail(kind: str):
|
|
173
|
+
raise PageSequenceError(kind, start)
|
|
174
|
+
|
|
175
|
+
tokens: list[_Token] = []
|
|
176
|
+
ignored = False
|
|
177
|
+
if j < n and cps[j] == "!":
|
|
178
|
+
ignored = True
|
|
179
|
+
j += 1
|
|
180
|
+
label_starts = True
|
|
181
|
+
while True:
|
|
182
|
+
while j < n and cps[j] == " ":
|
|
183
|
+
j += 1
|
|
184
|
+
if j == n or cps[j] == ",":
|
|
185
|
+
break
|
|
186
|
+
c = cps[j]
|
|
187
|
+
if c == '"' and label_starts:
|
|
188
|
+
text = ""
|
|
189
|
+
j += 1
|
|
190
|
+
while True:
|
|
191
|
+
if j == n:
|
|
192
|
+
fail("unclosed-quote")
|
|
193
|
+
ch = cps[j]
|
|
194
|
+
j += 1
|
|
195
|
+
if ch == '"':
|
|
196
|
+
break
|
|
197
|
+
if ch == "\\":
|
|
198
|
+
if j == n:
|
|
199
|
+
fail("unclosed-quote")
|
|
200
|
+
if cps[j] not in '"\\':
|
|
201
|
+
fail("syntax")
|
|
202
|
+
text += cps[j]
|
|
203
|
+
j += 1
|
|
204
|
+
else:
|
|
205
|
+
if _is_control(ch):
|
|
206
|
+
fail("reserved-char")
|
|
207
|
+
text += ch
|
|
208
|
+
tokens.append(_Token("label", text))
|
|
209
|
+
label_starts = False
|
|
210
|
+
elif c in "-+" or (c == "|" and pipe):
|
|
211
|
+
tokens.append(_Token(c))
|
|
212
|
+
j += 1
|
|
213
|
+
label_starts = True
|
|
214
|
+
elif c == "." and cps[j + 1 : j + 2] == ".":
|
|
215
|
+
tokens.append(_Token(".."))
|
|
216
|
+
j += 2
|
|
217
|
+
label_starts = True
|
|
218
|
+
elif c == "/":
|
|
219
|
+
j += 1
|
|
220
|
+
k = j
|
|
221
|
+
while k < n and re.fullmatch(r"[a-z0-9_]", cps[k]):
|
|
222
|
+
k += 1
|
|
223
|
+
tokens.append(_Token("/", cps[j:k]))
|
|
224
|
+
j = k
|
|
225
|
+
label_starts = False
|
|
226
|
+
elif _is_reserved(c):
|
|
227
|
+
fail("reserved-char")
|
|
228
|
+
else:
|
|
229
|
+
k = j
|
|
230
|
+
while k < n and not _is_reserved(cps[k]) and not (cps[k] == "." and cps[k + 1 : k + 2] == "."):
|
|
231
|
+
k += 1
|
|
232
|
+
text = cps[j:k].rstrip(" ")
|
|
233
|
+
j = k
|
|
234
|
+
if text.startswith(".") or text.endswith("."):
|
|
235
|
+
fail("dot-at-edge")
|
|
236
|
+
tokens.append(_Token("label", text))
|
|
237
|
+
label_starts = False
|
|
238
|
+
last = tokens[-1]
|
|
239
|
+
if last.t == "label" and len(last.text) > _MAX_LABEL:
|
|
240
|
+
fail("too-long")
|
|
241
|
+
if not tokens:
|
|
242
|
+
start = i
|
|
243
|
+
return _LexedItem(start, ignored, tokens, j)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass
|
|
247
|
+
class _Item:
|
|
248
|
+
"""A parsed item: a page, a range (one label per page) or a run."""
|
|
249
|
+
|
|
250
|
+
start: int
|
|
251
|
+
ignored: bool
|
|
252
|
+
first: list[str]
|
|
253
|
+
last: list[str] | None = None
|
|
254
|
+
flag: str | None = None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _parse_item(lexed: _LexedItem, ref: bool = False) -> _Item:
|
|
258
|
+
"""Parse the tokens of an item. With ``ref``, equal ends are one page (the reference
|
|
259
|
+
repair)."""
|
|
260
|
+
tokens, start = lexed.tokens, lexed.start
|
|
261
|
+
|
|
262
|
+
def fail(kind: str):
|
|
263
|
+
raise PageSequenceError(kind, start)
|
|
264
|
+
|
|
265
|
+
if not tokens:
|
|
266
|
+
fail("empty-item")
|
|
267
|
+
k = 0
|
|
268
|
+
|
|
269
|
+
def page() -> list[str]:
|
|
270
|
+
nonlocal k
|
|
271
|
+
labels = []
|
|
272
|
+
while True:
|
|
273
|
+
if k >= len(tokens) or tokens[k].t != "label":
|
|
274
|
+
fail("syntax")
|
|
275
|
+
labels.append(tokens[k].text)
|
|
276
|
+
k += 1
|
|
277
|
+
if k >= len(tokens) or tokens[k].t != "+":
|
|
278
|
+
return labels
|
|
279
|
+
k += 1
|
|
280
|
+
|
|
281
|
+
first = page()
|
|
282
|
+
item = _Item(start, lexed.ignored, first)
|
|
283
|
+
op = tokens[k].t if k < len(tokens) else None
|
|
284
|
+
if op in ("-", ".."):
|
|
285
|
+
k += 1
|
|
286
|
+
if k == len(tokens):
|
|
287
|
+
fail("open-end" if op == "-" and len(first) == 1 else "syntax")
|
|
288
|
+
last = page()
|
|
289
|
+
if op == "-" and (len(first) > 1 or len(last) > 1):
|
|
290
|
+
fail("syntax")
|
|
291
|
+
if op == "..":
|
|
292
|
+
if len(first) == 1 and len(last) == 1:
|
|
293
|
+
fail("single-label-run")
|
|
294
|
+
if len(first) != len(last):
|
|
295
|
+
fail("run-shape")
|
|
296
|
+
if k < len(tokens) and tokens[k].t == "/":
|
|
297
|
+
if tokens[k].text == "":
|
|
298
|
+
fail("syntax")
|
|
299
|
+
item.flag = tokens[k].text
|
|
300
|
+
k += 1
|
|
301
|
+
if first == last:
|
|
302
|
+
if not ref:
|
|
303
|
+
fail("equal-ends")
|
|
304
|
+
else:
|
|
305
|
+
item.last = last
|
|
306
|
+
elif op == "/":
|
|
307
|
+
fail("reserved-char")
|
|
308
|
+
if k < len(tokens):
|
|
309
|
+
fail("syntax")
|
|
310
|
+
return item
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ---------------------------------------------------------------- Numbering
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _char_case(c: str) -> str:
|
|
317
|
+
if c != c.lower():
|
|
318
|
+
return "upper"
|
|
319
|
+
if c != c.upper():
|
|
320
|
+
return "lower"
|
|
321
|
+
return ""
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclass
|
|
325
|
+
class _Step:
|
|
326
|
+
"""A reading of two end labels by one system: the cut, and the values at both ends."""
|
|
327
|
+
|
|
328
|
+
system: NumberingSystem
|
|
329
|
+
prefix: str
|
|
330
|
+
suffix: str
|
|
331
|
+
#: Where the cut is, for "the first valid reading fixes where the label is cut".
|
|
332
|
+
cut: tuple[int, int]
|
|
333
|
+
first: tuple[int, str]
|
|
334
|
+
last: tuple[int, str]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _read_token(system: NumberingSystem, token: str) -> tuple[int, str] | None:
|
|
338
|
+
"""Read a token, checking the round trip."""
|
|
339
|
+
r = system.read(token)
|
|
340
|
+
if r is None:
|
|
341
|
+
return None
|
|
342
|
+
value, style = r
|
|
343
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
344
|
+
return None
|
|
345
|
+
return r if system.format(value, style) == token else None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _find_step(a: str, b: str, systems: Sequence[NumberingSystem], step: int) -> _Step | str:
|
|
349
|
+
""""Finding what steps": read ``a`` → ``b`` with ``systems``, stepping by ``step``."""
|
|
350
|
+
p0 = 0
|
|
351
|
+
while p0 < len(a) and p0 < len(b) and a[p0] == b[p0]:
|
|
352
|
+
p0 += 1
|
|
353
|
+
s0 = 0
|
|
354
|
+
while s0 < len(a) - p0 and s0 < len(b) - p0 and a[len(a) - 1 - s0] == b[len(b) - 1 - s0]:
|
|
355
|
+
s0 += 1
|
|
356
|
+
error = None
|
|
357
|
+
valid: list[_Step] = []
|
|
358
|
+
for system in systems:
|
|
359
|
+
p, s = p0, s0
|
|
360
|
+
# Widening: move over token characters, never across case.
|
|
361
|
+
middle_case = ""
|
|
362
|
+
for c in a[p : len(a) - s] + b[p : len(b) - s]:
|
|
363
|
+
middle_case = _char_case(c)
|
|
364
|
+
if middle_case:
|
|
365
|
+
break
|
|
366
|
+
|
|
367
|
+
def joins(c: str) -> bool:
|
|
368
|
+
nonlocal middle_case
|
|
369
|
+
if c not in system.token_chars:
|
|
370
|
+
return False
|
|
371
|
+
cc = _char_case(c)
|
|
372
|
+
if cc and middle_case and cc != middle_case:
|
|
373
|
+
return False
|
|
374
|
+
if cc:
|
|
375
|
+
middle_case = cc
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
while p > 0 and joins(a[p - 1]):
|
|
379
|
+
p -= 1
|
|
380
|
+
while s > 0 and joins(a[len(a) - s]):
|
|
381
|
+
s -= 1
|
|
382
|
+
first = _read_token(system, a[p : len(a) - s])
|
|
383
|
+
last = _read_token(system, b[p : len(b) - s])
|
|
384
|
+
if first is None or last is None:
|
|
385
|
+
continue
|
|
386
|
+
if first[1] != last[1]:
|
|
387
|
+
e = "different-case"
|
|
388
|
+
elif last[0] < first[0]:
|
|
389
|
+
e = "descending"
|
|
390
|
+
elif (last[0] - first[0]) % step:
|
|
391
|
+
e = "uneven-run"
|
|
392
|
+
else:
|
|
393
|
+
e = None
|
|
394
|
+
if e:
|
|
395
|
+
error = error or e
|
|
396
|
+
continue
|
|
397
|
+
valid.append(_Step(system, a[:p], a[len(a) - s :], (p, s), first, last))
|
|
398
|
+
if not valid:
|
|
399
|
+
return error or "no-system"
|
|
400
|
+
best = valid[0]
|
|
401
|
+
for r in valid:
|
|
402
|
+
if r.cut == best.cut and r.last[0] < best.last[0]:
|
|
403
|
+
best = r
|
|
404
|
+
return best
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _label_at(step: _Step, value: int) -> str | None:
|
|
408
|
+
"""The label ``step`` gives for ``value``, or ``None`` if the system can't write it."""
|
|
409
|
+
token = step.system.format(value, step.first[1])
|
|
410
|
+
return None if token is None else step.prefix + token + step.suffix
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _read_item(first: list[str], last: list[str], systems: Sequence[NumberingSystem]) -> list[_Step] | str:
|
|
414
|
+
"""Read the ends of an item column by column. Each column steps the same number of times."""
|
|
415
|
+
width = len(first)
|
|
416
|
+
steps: list[_Step] = []
|
|
417
|
+
for a, b in zip(first, last):
|
|
418
|
+
if a == b:
|
|
419
|
+
return "uneven-run"
|
|
420
|
+
r = _find_step(a, b, systems, width)
|
|
421
|
+
if isinstance(r, str):
|
|
422
|
+
return r
|
|
423
|
+
if steps and r.last[0] - r.first[0] != steps[0].last[0] - steps[0].first[0]:
|
|
424
|
+
return "uneven-run"
|
|
425
|
+
steps.append(r)
|
|
426
|
+
return steps
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ---------------------------------------------------------------- Expansion
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _page(ignored: bool, labels: list[str]) -> dict:
|
|
433
|
+
return {"ignored": ignored, "labels": labels}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _parse(text: str, ctx: _Context, ref: bool = False) -> list[dict]:
|
|
437
|
+
"""Parse a sequence to its page model."""
|
|
438
|
+
if not isinstance(text, str):
|
|
439
|
+
raise TypeError("the sequence must be a str")
|
|
440
|
+
cps = unicodedata.normalize("NFC", text)
|
|
441
|
+
if cps.strip(" ") == "":
|
|
442
|
+
raise PageSequenceError("empty", 0)
|
|
443
|
+
pages: list[dict] = []
|
|
444
|
+
seen: set[str] = set()
|
|
445
|
+
total = 0
|
|
446
|
+
i = 0
|
|
447
|
+
while True:
|
|
448
|
+
lexed = _lex_item(cps, i)
|
|
449
|
+
item = _parse_item(lexed, ref)
|
|
450
|
+
|
|
451
|
+
def fail(kind: str):
|
|
452
|
+
raise PageSequenceError(kind, item.start)
|
|
453
|
+
|
|
454
|
+
if item.last is None:
|
|
455
|
+
item_pages = [_page(item.ignored, item.first)]
|
|
456
|
+
else:
|
|
457
|
+
systems: Sequence[NumberingSystem] = _BUILT_IN
|
|
458
|
+
if item.flag is not None:
|
|
459
|
+
if item.flag not in ctx.by_name:
|
|
460
|
+
fail("unknown-system")
|
|
461
|
+
systems = [ctx.by_name[item.flag]]
|
|
462
|
+
steps = _read_item(item.first, item.last, systems)
|
|
463
|
+
if isinstance(steps, str):
|
|
464
|
+
fail(steps)
|
|
465
|
+
width = len(item.first)
|
|
466
|
+
count = (steps[0].last[0] - steps[0].first[0]) // width + 1
|
|
467
|
+
if total + count * width > ctx.max_labels:
|
|
468
|
+
fail("too-long")
|
|
469
|
+
item_pages = []
|
|
470
|
+
for n in range(count):
|
|
471
|
+
labels = [_label_at(s, s.first[0] + n * width) for s in steps]
|
|
472
|
+
if any(label is None for label in labels):
|
|
473
|
+
fail("no-system")
|
|
474
|
+
if any(len(label) > _MAX_LABEL for label in labels):
|
|
475
|
+
fail("too-long")
|
|
476
|
+
item_pages.append(_page(item.ignored, labels))
|
|
477
|
+
for page in item_pages:
|
|
478
|
+
total += len(page["labels"])
|
|
479
|
+
if total > ctx.max_labels:
|
|
480
|
+
fail("too-long")
|
|
481
|
+
if page["ignored"]:
|
|
482
|
+
continue
|
|
483
|
+
for label in page["labels"]:
|
|
484
|
+
if label == "":
|
|
485
|
+
continue
|
|
486
|
+
key = label.lower()
|
|
487
|
+
if key in seen:
|
|
488
|
+
fail("duplicate-label")
|
|
489
|
+
seen.add(key)
|
|
490
|
+
pages.extend(item_pages)
|
|
491
|
+
if lexed.end >= len(cps):
|
|
492
|
+
break
|
|
493
|
+
i = lexed.end + 1
|
|
494
|
+
return pages
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _check_length(length) -> None:
|
|
498
|
+
if length is not None and (isinstance(length, bool) or not isinstance(length, int) or length < 1):
|
|
499
|
+
raise PageSequenceError("bad-length", None, f"bad-length: {length!r} is not a positive whole number")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# ---------------------------------------------------------------- Normal form
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _quote(label: str) -> str:
|
|
506
|
+
"""Write a label, quoted when it can't be written bare or contains a space."""
|
|
507
|
+
bare = (
|
|
508
|
+
label != ""
|
|
509
|
+
and not any(c == " " or _is_reserved(c) for c in label)
|
|
510
|
+
and ".." not in label
|
|
511
|
+
and not label.startswith(".")
|
|
512
|
+
and not label.endswith(".")
|
|
513
|
+
)
|
|
514
|
+
return label if bare else '"' + re.sub(r'[\\"]', lambda m: "\\" + m.group(0), label) + '"'
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _write_page(labels: list[str]) -> str:
|
|
518
|
+
return "+".join(_quote(label) for label in labels)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _reads_as(pages: list[dict], first: list[str], last: list[str]) -> bool:
|
|
522
|
+
"""Do ``first``→``last``, read without a flag, give exactly ``pages``?"""
|
|
523
|
+
steps = _read_item(first, last, _BUILT_IN)
|
|
524
|
+
if isinstance(steps, str):
|
|
525
|
+
return False
|
|
526
|
+
width = len(first)
|
|
527
|
+
if (steps[0].last[0] - steps[0].first[0]) // width + 1 != len(pages):
|
|
528
|
+
return False
|
|
529
|
+
return all(
|
|
530
|
+
_label_at(s, s.first[0] + n * width) == page["labels"][c]
|
|
531
|
+
for n, page in enumerate(pages)
|
|
532
|
+
for c, s in enumerate(steps)
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _fold(pages: list[dict], ctx: _Context) -> str:
|
|
537
|
+
""""Normal form", step 2 to 4: fold the page model in one pass."""
|
|
538
|
+
items: list[str] = []
|
|
539
|
+
i = 0
|
|
540
|
+
while i < len(pages):
|
|
541
|
+
page = pages[i]
|
|
542
|
+
width = len(page["labels"])
|
|
543
|
+
|
|
544
|
+
def same(q: int) -> bool:
|
|
545
|
+
return q < len(pages) and pages[q]["ignored"] == page["ignored"] and len(pages[q]["labels"]) == width
|
|
546
|
+
|
|
547
|
+
best = None
|
|
548
|
+
if same(i + 1):
|
|
549
|
+
nxt = pages[i + 1]
|
|
550
|
+
for system in ctx.fold_order:
|
|
551
|
+
steps: list[_Step] = []
|
|
552
|
+
for c in range(width):
|
|
553
|
+
r = _find_step(page["labels"][c], nxt["labels"][c], [system], width)
|
|
554
|
+
if isinstance(r, str) or r.last[0] - r.first[0] != width:
|
|
555
|
+
break
|
|
556
|
+
steps.append(r)
|
|
557
|
+
if len(steps) < width:
|
|
558
|
+
continue
|
|
559
|
+
n = 2
|
|
560
|
+
while same(i + n) and all(
|
|
561
|
+
_label_at(s, s.first[0] + n * width) == pages[i + n]["labels"][c] for c, s in enumerate(steps)
|
|
562
|
+
):
|
|
563
|
+
n += 1
|
|
564
|
+
if best is None or n > best[1]:
|
|
565
|
+
best = (system, n)
|
|
566
|
+
mark = "!" if page["ignored"] else ""
|
|
567
|
+
if best is not None and best[1] >= (2 if width == 1 else 3):
|
|
568
|
+
stretch = pages[i : i + best[1]]
|
|
569
|
+
last = stretch[-1]["labels"]
|
|
570
|
+
item = mark + _write_page(page["labels"]) + ("-" if width == 1 else "..") + _write_page(last)
|
|
571
|
+
if not _reads_as(stretch, page["labels"], last):
|
|
572
|
+
item += "/" + best[0].name
|
|
573
|
+
items.append(item)
|
|
574
|
+
i += best[1]
|
|
575
|
+
else:
|
|
576
|
+
items.append(mark + _write_page(page["labels"]))
|
|
577
|
+
i += 1
|
|
578
|
+
return ", ".join(items)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def normalize(text: str, *, plugins: Sequence[NumberingSystem] = (), max_labels: int = _DEFAULT_MAX_LABELS) -> str:
|
|
582
|
+
"""The normal form of a sequence. Raises ``PageSequenceError`` on invalid input."""
|
|
583
|
+
ctx = _context(plugins, max_labels)
|
|
584
|
+
return _fold(_parse(text, ctx), ctx)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def equals(
|
|
588
|
+
a: str, b: str, *, plugins: Sequence[NumberingSystem] = (), max_labels: int = _DEFAULT_MAX_LABELS
|
|
589
|
+
) -> bool:
|
|
590
|
+
"""Do two sequences have the same page model? Labels are compared exactly."""
|
|
591
|
+
ctx = _context(plugins, max_labels)
|
|
592
|
+
return _parse(a, ctx) == _parse(b, ctx)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
# ---------------------------------------------------------------- Cleaning
|
|
596
|
+
|
|
597
|
+
_DASHES = "–—−"
|
|
598
|
+
_CURLY = "“”„"
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _outside_quotes(cps: str, fn) -> str:
|
|
602
|
+
"""Map the characters outside straight quotes."""
|
|
603
|
+
out = []
|
|
604
|
+
i = 0
|
|
605
|
+
while i < len(cps):
|
|
606
|
+
c = cps[i]
|
|
607
|
+
i += 1
|
|
608
|
+
if c != '"':
|
|
609
|
+
out.append(fn(c))
|
|
610
|
+
continue
|
|
611
|
+
out.append(c)
|
|
612
|
+
while i < len(cps):
|
|
613
|
+
out.append(cps[i])
|
|
614
|
+
i += 1
|
|
615
|
+
if out[-1] == "\\" and i < len(cps):
|
|
616
|
+
out.append(cps[i])
|
|
617
|
+
i += 1
|
|
618
|
+
elif out[-1] == '"':
|
|
619
|
+
break
|
|
620
|
+
return "".join(out)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _split_items(text: str) -> list[str]:
|
|
624
|
+
"""Split at the commas outside straight quotes."""
|
|
625
|
+
items = []
|
|
626
|
+
current = ""
|
|
627
|
+
quoted = False
|
|
628
|
+
i = 0
|
|
629
|
+
while i < len(text):
|
|
630
|
+
c = text[i]
|
|
631
|
+
i += 1
|
|
632
|
+
if quoted and c == "\\" and i < len(text):
|
|
633
|
+
current += c + text[i]
|
|
634
|
+
i += 1
|
|
635
|
+
continue
|
|
636
|
+
if c == '"':
|
|
637
|
+
quoted = not quoted
|
|
638
|
+
if c == "," and not quoted:
|
|
639
|
+
items.append(current)
|
|
640
|
+
current = ""
|
|
641
|
+
else:
|
|
642
|
+
current += c
|
|
643
|
+
items.append(current)
|
|
644
|
+
return items
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _write_tokens(tokens: list[_Token]) -> str:
|
|
648
|
+
return "".join(_quote(t.text) if t.t == "label" else "/" + t.text if t.t == "/" else t.t for t in tokens)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _page_width(tokens: list[_Token]) -> int:
|
|
652
|
+
"""Tokens [label, "+", label, …] → number of labels, or 0."""
|
|
653
|
+
if len(tokens) % 2 == 0:
|
|
654
|
+
return 0
|
|
655
|
+
if any(t.t != ("label" if k % 2 == 0 else "+") for k, t in enumerate(tokens)):
|
|
656
|
+
return 0
|
|
657
|
+
return (len(tokens) + 1) // 2
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _clean_item(text: str, repairs: list[str]) -> tuple[str, tuple[bool, str] | None]:
|
|
661
|
+
"""The repairs of one item. Gives the repaired text, or the item as it was if it doesn't lex,
|
|
662
|
+
and the open range ``(ignored, label)`` if the item is one."""
|
|
663
|
+
try:
|
|
664
|
+
lexed = _lex_item(text, 0, True)
|
|
665
|
+
except PageSequenceError:
|
|
666
|
+
return text, None
|
|
667
|
+
tokens = lexed.tokens
|
|
668
|
+
ops = [t for t in tokens if t.t in ("-", "..")]
|
|
669
|
+
if any(t.t == "|" for t in tokens):
|
|
670
|
+
if ops:
|
|
671
|
+
return text, None
|
|
672
|
+
tokens = [_Token("+") if t.t == "|" else t for t in tokens]
|
|
673
|
+
repairs.append("pipe")
|
|
674
|
+
mark = "!" if lexed.ignored else ""
|
|
675
|
+
if len(ops) == 1:
|
|
676
|
+
at = next(k for k, t in enumerate(tokens) if t.t in ("-", ".."))
|
|
677
|
+
flagged = tokens[-1].t == "/"
|
|
678
|
+
left = tokens[:at]
|
|
679
|
+
right = tokens[at + 1 : len(tokens) - 1 if flagged else len(tokens)]
|
|
680
|
+
wl, wr = _page_width(left), _page_width(right)
|
|
681
|
+
if tokens[at].t == "-" and not right and wl == 1 and not flagged:
|
|
682
|
+
return mark + _write_tokens(tokens), (lexed.ignored, left[0].text)
|
|
683
|
+
if wl and wl == wr:
|
|
684
|
+
if tokens[at].t == ".." and wl == 1:
|
|
685
|
+
tokens[at] = _Token("-")
|
|
686
|
+
repairs.append("single-label-run")
|
|
687
|
+
elif tokens[at].t == "-" and wl > 1:
|
|
688
|
+
tokens[at] = _Token("..")
|
|
689
|
+
repairs.append("dash-run")
|
|
690
|
+
if _write_tokens(left) == _write_tokens(right):
|
|
691
|
+
tokens = left
|
|
692
|
+
repairs.append("equal-ends")
|
|
693
|
+
return mark + _write_tokens(tokens), None
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
class Cleaned(NamedTuple):
|
|
697
|
+
normalized: str
|
|
698
|
+
repairs: list[str]
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def clean(
|
|
702
|
+
text: str,
|
|
703
|
+
length: int | None = None,
|
|
704
|
+
*,
|
|
705
|
+
plugins: Sequence[NumberingSystem] = (),
|
|
706
|
+
max_labels: int = _DEFAULT_MAX_LABELS,
|
|
707
|
+
) -> Cleaned:
|
|
708
|
+
"""Repair what a person typed or pasted, then normalise strictly. With ``length``, an open
|
|
709
|
+
range at the end is closed at that page count, and the result is checked against it."""
|
|
710
|
+
ctx = _context(plugins, max_labels)
|
|
711
|
+
_check_length(length)
|
|
712
|
+
repairs: list[str] = []
|
|
713
|
+
cps = unicodedata.normalize("NFC", text)
|
|
714
|
+
# Character repairs, outside straight quotes. Curly quotes become straight ones.
|
|
715
|
+
chars = []
|
|
716
|
+
i = 0
|
|
717
|
+
while i < len(cps):
|
|
718
|
+
c = cps[i]
|
|
719
|
+
i += 1
|
|
720
|
+
if c == '"':
|
|
721
|
+
chars.append(c)
|
|
722
|
+
while i < len(cps):
|
|
723
|
+
chars.append(cps[i])
|
|
724
|
+
i += 1
|
|
725
|
+
if chars[-1] == "\\" and i < len(cps):
|
|
726
|
+
chars.append(cps[i])
|
|
727
|
+
i += 1
|
|
728
|
+
elif chars[-1] == '"':
|
|
729
|
+
break
|
|
730
|
+
elif c in _CURLY:
|
|
731
|
+
repairs.append("curly-quote")
|
|
732
|
+
chars.append('"')
|
|
733
|
+
while i < len(cps) and cps[i] not in _CURLY and cps[i] != '"':
|
|
734
|
+
chars.append("\\\\" if cps[i] == "\\" else cps[i])
|
|
735
|
+
i += 1
|
|
736
|
+
if i < len(cps):
|
|
737
|
+
chars.append('"')
|
|
738
|
+
i += 1
|
|
739
|
+
elif c != " " and c.isspace():
|
|
740
|
+
repairs.append("space")
|
|
741
|
+
chars.append(" ")
|
|
742
|
+
elif c in _DASHES:
|
|
743
|
+
repairs.append("dash")
|
|
744
|
+
chars.append("-")
|
|
745
|
+
elif c == ";":
|
|
746
|
+
repairs.append("semicolon")
|
|
747
|
+
chars.append(",")
|
|
748
|
+
elif c == "…":
|
|
749
|
+
repairs.append("ellipsis")
|
|
750
|
+
chars.append("..")
|
|
751
|
+
else:
|
|
752
|
+
chars.append(c)
|
|
753
|
+
items: list[str] = []
|
|
754
|
+
open_range = None
|
|
755
|
+
open_at = -1
|
|
756
|
+
for raw in _split_items("".join(chars)):
|
|
757
|
+
if re.fullmatch(r" *!? *", raw):
|
|
758
|
+
repairs.append("empty-item")
|
|
759
|
+
continue
|
|
760
|
+
repaired, is_open = _clean_item(raw, repairs)
|
|
761
|
+
if is_open:
|
|
762
|
+
open_range, open_at = is_open, len(items)
|
|
763
|
+
items.append(repaired)
|
|
764
|
+
if not items and length is not None:
|
|
765
|
+
open_range, open_at = (False, "1"), 0
|
|
766
|
+
items.append("1-")
|
|
767
|
+
if open_range and open_at == len(items) - 1 and length is not None and _read_token(_integer, open_range[1]):
|
|
768
|
+
before = 0 if open_at == 0 else len(_parse(", ".join(items[:open_at]), ctx))
|
|
769
|
+
n = length - before
|
|
770
|
+
if n < 1:
|
|
771
|
+
raise PageSequenceError("open-end", 0, "open-end: no pages are left for the open range")
|
|
772
|
+
ignored, label = open_range
|
|
773
|
+
first = int(label)
|
|
774
|
+
items[open_at] = ("!" if ignored else "") + (label if n == 1 else f"{first}-{first + n - 1}")
|
|
775
|
+
repairs.append("open-end")
|
|
776
|
+
seq = PageSequence(", ".join(items), length, plugins=plugins, max_labels=max_labels)
|
|
777
|
+
return Cleaned(str(seq), repairs)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
# ---------------------------------------------------------------- The class
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
class PageSequence:
|
|
784
|
+
"""A page sequence: the labels printed on each page of a document.
|
|
785
|
+
|
|
786
|
+
``PageSequence(text, length=None, *, plugins=(), max_labels=10000)`` parses ``text`` strictly
|
|
787
|
+
and raises ``PageSequenceError`` on invalid input. Pass the document's page count as ``length``
|
|
788
|
+
whenever it is known.
|
|
789
|
+
"""
|
|
790
|
+
|
|
791
|
+
def __init__(
|
|
792
|
+
self,
|
|
793
|
+
text: str,
|
|
794
|
+
length: int | None = None,
|
|
795
|
+
*,
|
|
796
|
+
plugins: Sequence[NumberingSystem] = (),
|
|
797
|
+
max_labels: int = _DEFAULT_MAX_LABELS,
|
|
798
|
+
):
|
|
799
|
+
_check_length(length)
|
|
800
|
+
self._ctx = _context(plugins, max_labels)
|
|
801
|
+
self._pages = _parse(text, self._ctx)
|
|
802
|
+
if length is not None and length != len(self._pages):
|
|
803
|
+
raise PageSequenceError(
|
|
804
|
+
"length-mismatch",
|
|
805
|
+
None,
|
|
806
|
+
f"length-mismatch: the sequence has {len(self._pages)} document pages, not {length}",
|
|
807
|
+
)
|
|
808
|
+
#: Lowercased label → (index, label as written), for the pages that are not ignored.
|
|
809
|
+
self._lookup: dict[str, tuple[int, str]] = {}
|
|
810
|
+
for index, page in enumerate(self._pages):
|
|
811
|
+
if not page["ignored"]:
|
|
812
|
+
for label in page["labels"]:
|
|
813
|
+
self._lookup[label.lower()] = (index, label)
|
|
814
|
+
|
|
815
|
+
@classmethod
|
|
816
|
+
def create(cls, text: str, length: int | None = None, **options) -> PageSequence | None:
|
|
817
|
+
"""Like the constructor, but gives ``None`` instead of raising ``PageSequenceError``."""
|
|
818
|
+
try:
|
|
819
|
+
return cls(text, length, **options)
|
|
820
|
+
except PageSequenceError:
|
|
821
|
+
return None
|
|
822
|
+
|
|
823
|
+
def get_index_length_all(self) -> int:
|
|
824
|
+
"""The number of document pages, ignored ones included."""
|
|
825
|
+
return len(self._pages)
|
|
826
|
+
|
|
827
|
+
def get_index_length(self) -> int:
|
|
828
|
+
"""The number of document pages that are not ignored."""
|
|
829
|
+
return sum(1 for p in self._pages if not p["ignored"])
|
|
830
|
+
|
|
831
|
+
def get_label_length(self) -> int:
|
|
832
|
+
"""The number of labels on the pages that are not ignored."""
|
|
833
|
+
return sum(len(p["labels"]) for p in self._pages if not p["ignored"])
|
|
834
|
+
|
|
835
|
+
def _find(self, pages: str) -> list[tuple[int, str]] | None:
|
|
836
|
+
"""The labels of a reference, or ``None`` if it is invalid or names a missing label."""
|
|
837
|
+
text = _outside_quotes(unicodedata.normalize("NFC", pages), lambda c: "-" if c in _DASHES else c)
|
|
838
|
+
try:
|
|
839
|
+
ref = _parse(text, self._ctx, True)
|
|
840
|
+
except PageSequenceError:
|
|
841
|
+
return None
|
|
842
|
+
found = []
|
|
843
|
+
for page in ref:
|
|
844
|
+
for label in page["labels"]:
|
|
845
|
+
hit = None if label == "" else self._lookup.get(label.lower())
|
|
846
|
+
if hit is None:
|
|
847
|
+
return None
|
|
848
|
+
found.append(hit)
|
|
849
|
+
return found
|
|
850
|
+
|
|
851
|
+
@overload
|
|
852
|
+
def get_index_list(self) -> list[int]: ...
|
|
853
|
+
@overload
|
|
854
|
+
def get_index_list(self, pages: str | None = None) -> list[int] | None: ...
|
|
855
|
+
|
|
856
|
+
def get_index_list(self, pages: str | None = None) -> list[int] | None:
|
|
857
|
+
"""Without ``pages``, the indexes of all pages that are not ignored. With a reference such
|
|
858
|
+
as ``"20-21"``, the indexes of the pages that carry its labels, each once, in first-seen
|
|
859
|
+
order."""
|
|
860
|
+
if pages is None:
|
|
861
|
+
return [i for i, p in enumerate(self._pages) if not p["ignored"]]
|
|
862
|
+
found = self._find(pages)
|
|
863
|
+
return None if found is None else list(dict.fromkeys(index for index, _ in found))
|
|
864
|
+
|
|
865
|
+
@overload
|
|
866
|
+
def get_label_list(self) -> list[str]: ...
|
|
867
|
+
@overload
|
|
868
|
+
def get_label_list(self, pages: str | None = None) -> list[str] | None: ...
|
|
869
|
+
|
|
870
|
+
def get_label_list(self, pages: str | None = None) -> list[str] | None:
|
|
871
|
+
"""Without ``pages``, all labels of the pages that are not ignored. With a reference, its
|
|
872
|
+
labels."""
|
|
873
|
+
if pages is None:
|
|
874
|
+
return [label for p in self._pages if not p["ignored"] for label in p["labels"]]
|
|
875
|
+
found = self._find(pages)
|
|
876
|
+
return None if found is None else [label for _, label in found]
|
|
877
|
+
|
|
878
|
+
def labels_at(self, index: int) -> list[str] | None:
|
|
879
|
+
"""The labels of one document page, or ``None`` outside the document."""
|
|
880
|
+
if isinstance(index, bool) or not isinstance(index, int) or not 0 <= index < len(self._pages):
|
|
881
|
+
return None
|
|
882
|
+
return list(self._pages[index]["labels"])
|
|
883
|
+
|
|
884
|
+
def format_at(self, index: int) -> str | None:
|
|
885
|
+
"""The labels of one document page as displayed (``"2+3"``), or ``None`` outside the document."""
|
|
886
|
+
labels = self.labels_at(index)
|
|
887
|
+
return None if labels is None else "+".join(labels)
|
|
888
|
+
|
|
889
|
+
def pages(self) -> list[dict]:
|
|
890
|
+
"""The page model: a list of ``{"ignored": bool, "labels": [str, …]}``."""
|
|
891
|
+
return [_page(p["ignored"], list(p["labels"])) for p in self._pages]
|
|
892
|
+
|
|
893
|
+
def __str__(self) -> str:
|
|
894
|
+
"""The normal form, written with the sequence's own plugins."""
|
|
895
|
+
return _fold(self._pages, self._ctx)
|
|
896
|
+
|
|
897
|
+
def __repr__(self) -> str:
|
|
898
|
+
return f"PageSequence({str(self)!r})"
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""The plugin system ``hebrew``: the 22 letters א–ת, without case. The final forms are not read.
|
|
2
|
+
This is the alphabet, not gematria."""
|
|
3
|
+
|
|
4
|
+
from .._core import alphabet
|
|
5
|
+
|
|
6
|
+
#: The Hebrew alphabet (``א-ת/hebrew``).
|
|
7
|
+
hebrew = alphabet("hebrew", "אבגדהוזחטיכלמנסעפצקרשת")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""The plugin system ``roman_additive``: roman numerals 1–3999 written without subtraction, so 4
|
|
2
|
+
is ``IIII`` and 9 is ``VIIII``."""
|
|
3
|
+
|
|
4
|
+
_DIGITS = ((1000, "M"), (500, "D"), (100, "C"), (50, "L"), (10, "X"), (5, "V"), (1, "I"))
|
|
5
|
+
_VALUES = {d: v for v, d in _DIGITS}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _RomanAdditive:
|
|
9
|
+
name = "roman_additive"
|
|
10
|
+
token_chars = "IVXLCDMivxlcdm"
|
|
11
|
+
|
|
12
|
+
# Lenient: the reader keeps a token only if ``format`` gives it back.
|
|
13
|
+
def read(self, token: str) -> tuple[int, str] | None:
|
|
14
|
+
upper = token.upper()
|
|
15
|
+
if token != upper and token != token.lower():
|
|
16
|
+
return None
|
|
17
|
+
if any(c not in _VALUES for c in upper):
|
|
18
|
+
return None
|
|
19
|
+
return sum(_VALUES[c] for c in upper), "upper" if token == upper else "lower"
|
|
20
|
+
|
|
21
|
+
def format(self, value: int, style: str) -> str | None:
|
|
22
|
+
if not 1 <= value <= 3999:
|
|
23
|
+
return None
|
|
24
|
+
result = ""
|
|
25
|
+
for v, d in _DIGITS:
|
|
26
|
+
while value >= v:
|
|
27
|
+
result += d
|
|
28
|
+
value -= v
|
|
29
|
+
return result.lower() if style == "lower" else result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
#: Roman numerals without subtraction (``I-X/roman_additive``).
|
|
33
|
+
roman_additive = _RomanAdditive()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Roman numerals, read and written as the roman numbering system does."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
_ROMAN = (
|
|
6
|
+
(1000, "M"),
|
|
7
|
+
(900, "CM"),
|
|
8
|
+
(500, "D"),
|
|
9
|
+
(400, "CD"),
|
|
10
|
+
(100, "C"),
|
|
11
|
+
(90, "XC"),
|
|
12
|
+
(50, "L"),
|
|
13
|
+
(40, "XL"),
|
|
14
|
+
(10, "X"),
|
|
15
|
+
(9, "IX"),
|
|
16
|
+
(5, "V"),
|
|
17
|
+
(4, "IV"),
|
|
18
|
+
(1, "I"),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def to_roman(num: int, lower_case: bool = False) -> str:
|
|
23
|
+
"""Write an integer from 1 to 3999 as a canonical roman numeral: 6 is ``VI``.
|
|
24
|
+
|
|
25
|
+
Raises ``ValueError`` for any other number.
|
|
26
|
+
"""
|
|
27
|
+
if isinstance(num, bool) or not isinstance(num, int) or not 1 <= num <= 3999:
|
|
28
|
+
raise ValueError(f"to_roman: {num!r} is not an integer from 1 to 3999")
|
|
29
|
+
result = ""
|
|
30
|
+
for value, digits in _ROMAN:
|
|
31
|
+
while num >= value:
|
|
32
|
+
result += digits
|
|
33
|
+
num -= value
|
|
34
|
+
return result.lower() if lower_case else result
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def from_roman(roman: str) -> int | None:
|
|
38
|
+
"""Read a roman numeral as the roman numbering system reads it: ``IV`` is 4.
|
|
39
|
+
|
|
40
|
+
It must be canonical, 1–3999, and all upper or all lower case. Anything else (``IIII``,
|
|
41
|
+
``IC``, ``Iv``, ``MMMM``, ``""``) gives ``None``.
|
|
42
|
+
"""
|
|
43
|
+
if not isinstance(roman, str) or not re.fullmatch(r"[IVXLCDM]+|[ivxlcdm]+", roman):
|
|
44
|
+
return None
|
|
45
|
+
upper = roman.upper()
|
|
46
|
+
value = 0
|
|
47
|
+
i = 0
|
|
48
|
+
for v, digits in _ROMAN:
|
|
49
|
+
while upper.startswith(digits, i):
|
|
50
|
+
value += v
|
|
51
|
+
i += len(digits)
|
|
52
|
+
# Canonical means that writing the value gives the same numeral back.
|
|
53
|
+
if i != len(upper) or value > 3999 or to_roman(value) != upper:
|
|
54
|
+
return None
|
|
55
|
+
return value
|