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.
@@ -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,2 @@
1
+ """The plugin systems that ship with the package, one module each. Importing this package imports
2
+ none of them."""
@@ -0,0 +1,6 @@
1
+ """The plugin system ``greek``: the 24 letters Α–Ω and α–ω. The final sigma ``ς`` is not read."""
2
+
3
+ from .._core import alphabet
4
+
5
+ #: The Greek alphabet (``Α-Ω/greek``).
6
+ greek = alphabet("greek", "ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩ", "αβγδεζηθικλμνξοπρστυφχψω")
@@ -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,6 @@
1
+ """The plugin system ``latin_sv``: the Swedish alphabet, A–Z then Å, Ä, Ö."""
2
+
3
+ from .._core import alphabet
4
+
5
+ #: The Swedish alphabet (``A-Ö/latin_sv``).
6
+ latin_sv = alphabet("latin_sv", "ABCDEFGHIJKLMNOPQRSTUVWXYZÅÄÖ", "abcdefghijklmnopqrstuvwxyzåäö")
@@ -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