harfix 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
harfix/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """harfix: find Arabic PDF text that will silently poison a search index."""
2
+
3
+ from .detect import (
4
+ ROUTES,
5
+ Assessment,
6
+ Segment,
7
+ TextReport,
8
+ Thresholds,
9
+ assess_document,
10
+ assess_text,
11
+ )
12
+ from .model import CharLM
13
+ from .pdf import DocumentReport, FontReport, HarfixError, PageReport, scan_pdf
14
+
15
+ __all__ = [
16
+ "ROUTES",
17
+ "Assessment",
18
+ "CharLM",
19
+ "DocumentReport",
20
+ "FontReport",
21
+ "HarfixError",
22
+ "PageReport",
23
+ "Segment",
24
+ "TextReport",
25
+ "Thresholds",
26
+ "assess_document",
27
+ "assess_text",
28
+ "scan_pdf",
29
+ ]
30
+ __version__ = "0.1.0"
harfix/cli.py ADDED
@@ -0,0 +1,137 @@
1
+ """`harfix scan` for PDFs and `harfix check` for text produced by any extractor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from .detect import ROUTES, assess_document
11
+ from .pdf import HarfixError, scan_pdf
12
+
13
+
14
+ def _parse_pages(spec: str) -> list[int]:
15
+ pages: set[int] = set()
16
+ for part in spec.split(","):
17
+ start, _, end = part.strip().partition("-")
18
+ first = int(start)
19
+ last = int(end) if end else first
20
+ if first < 1 or last < first:
21
+ raise argparse.ArgumentTypeError(f"invalid page range: {part!r}")
22
+ pages.update(range(first, last + 1))
23
+ return sorted(pages)
24
+
25
+
26
+ def _fails(route: str, fail_on: str | None) -> bool:
27
+ return fail_on is not None and ROUTES.index(route) >= ROUTES.index(fail_on)
28
+
29
+
30
+ def _scan(args: argparse.Namespace) -> int:
31
+ worst = "keep"
32
+ reports = []
33
+ for path in args.pdf:
34
+ try:
35
+ report = scan_pdf(path, pages=args.pages)
36
+ except (HarfixError, FileNotFoundError) as error:
37
+ print(f"harfix: {error}", file=sys.stderr)
38
+ return 2
39
+ reports.append(report)
40
+ for page in report.pages:
41
+ worst = max(worst, page.route, key=ROUTES.index)
42
+
43
+ if args.json:
44
+ payload = [r.to_dict() for r in reports]
45
+ json.dump(
46
+ payload[0] if len(payload) == 1 else payload, sys.stdout, ensure_ascii=False, indent=2
47
+ )
48
+ print()
49
+ else:
50
+ for report in reports:
51
+ print(f"{report.path}")
52
+ counts = ", ".join(f"{route}={n}" for route, n in report.route_counts.items() if n)
53
+ print(f" pages: {counts}")
54
+ for page in report.pages:
55
+ if page.route == "keep" and not args.verbose:
56
+ continue
57
+ reasons = ", ".join(page.reasons) or "-"
58
+ print(f" p{page.page:<5} {page.route:<9} {reasons}")
59
+ if args.verbose:
60
+ for font in page.fonts:
61
+ a = font.assessment
62
+ bits = a.metrics.get("bits", "-")
63
+ print(
64
+ f" {font.font[:40]:<40} {a.route:<9} "
65
+ f"letters={a.metrics.get('arabic_letters')} bits={bits} "
66
+ f"{','.join(a.reasons)}"
67
+ )
68
+ return 1 if _fails(worst, args.fail_on) else 0
69
+
70
+
71
+ def _check(args: argparse.Namespace) -> int:
72
+ text = sys.stdin.read() if args.file == "-" else Path(args.file).read_text(encoding="utf-8")
73
+ report = assess_document(text)
74
+ if args.json:
75
+ json.dump(report.to_dict(), sys.stdout, ensure_ascii=False, indent=2)
76
+ print()
77
+ else:
78
+ print(f"{report.route}: {', '.join(report.reasons) or 'no issues found'}")
79
+ counts = {route: 0 for route in ROUTES}
80
+ for segment in report.segments:
81
+ counts[segment.assessment.route] += 1
82
+ unit = "pages" if any(s.page is not None for s in report.segments) else "segments"
83
+ print(f" {unit}: " + ", ".join(f"{route}={n}" for route, n in counts.items() if n))
84
+ for segment in report.segments:
85
+ a = segment.assessment
86
+ if a.route == "keep" and not args.verbose:
87
+ continue
88
+ where = (
89
+ f"p{segment.page}"
90
+ if segment.page is not None
91
+ else f"lines {segment.start_line}-{segment.end_line}"
92
+ )
93
+ print(f" {where:<16} {a.route:<9} {', '.join(a.reasons) or '-'}")
94
+ if args.verbose:
95
+ for key, value in a.metrics.items():
96
+ print(f" {key}: {value}")
97
+ return 1 if _fails(report.route, args.fail_on) else 0
98
+
99
+
100
+ def build_parser() -> argparse.ArgumentParser:
101
+ parser = argparse.ArgumentParser(
102
+ prog="harfix", description="Quality gate for Arabic text extracted from PDFs."
103
+ )
104
+ sub = parser.add_subparsers(dest="command", required=True)
105
+
106
+ scan = sub.add_parser("scan", help="audit the text layer of PDFs, per page and per font")
107
+ scan.add_argument("pdf", nargs="+")
108
+ scan.add_argument("--pages", type=_parse_pages, help="1-based pages, e.g. 1-5,9")
109
+ scan.add_argument("--json", action="store_true", help="print the full report as JSON")
110
+ scan.add_argument("-v", "--verbose", action="store_true", help="show every page and font")
111
+ scan.add_argument(
112
+ "--fail-on", choices=ROUTES[1:], help="exit 1 if any page needs this route or worse"
113
+ )
114
+ scan.set_defaults(func=_scan)
115
+
116
+ check = sub.add_parser(
117
+ "check", help="assess text produced by any extractor, page by page (form feeds)"
118
+ )
119
+ check.add_argument("file", nargs="?", default="-", help="UTF-8 text file, or - for stdin")
120
+ check.add_argument("--json", action="store_true")
121
+ check.add_argument(
122
+ "-v", "--verbose", action="store_true", help="show every segment and its metrics"
123
+ )
124
+ check.add_argument(
125
+ "--fail-on", choices=ROUTES[1:], help="exit 1 if any segment needs this route or worse"
126
+ )
127
+ check.set_defaults(func=_check)
128
+ return parser
129
+
130
+
131
+ def main(argv: list[str] | None = None) -> int:
132
+ args = build_parser().parse_args(argv)
133
+ return args.func(args)
134
+
135
+
136
+ if __name__ == "__main__":
137
+ raise SystemExit(main())
Binary file
@@ -0,0 +1,33 @@
1
+ {
2
+ "thresholds": {
3
+ "min_letters": 80,
4
+ "substitution_bits": 5.0,
5
+ "spike_bits": 18.0,
6
+ "spike_rate": 0.03,
7
+ "spike_min_letters": 200,
8
+ "unspaced_bits": 5.7,
9
+ "short_token_ratio": 0.46,
10
+ "orientation_margin": 0.5,
11
+ "presentation_form_ratio": 0.05,
12
+ "unmapped_ratio": 0.02,
13
+ "unmapped_min_glyphs": 5,
14
+ "legacy_ratio": 0.15,
15
+ "legacy_min_visible": 30,
16
+ "latin_min_words": 15,
17
+ "latin_vowel_ratio": 0.25,
18
+ "latin_irregular_ratio": 0.3,
19
+ "quran_min_forms": 8,
20
+ "quran_ligature_share": 0.5,
21
+ "quran_short_run_share": 0.6,
22
+ "quran_consecutive_share": 0.5,
23
+ "transposition_ratio": 0.27,
24
+ "mixed_order_min_share": 0.2
25
+ },
26
+ "calibration": {
27
+ "date": "2026-10-17",
28
+ "model_sha256": "3704fbc01115f6e6bc6bcfde0aba8881ebb1f2b658fce2904210fda0949e0be8",
29
+ "calib_pages": 73,
30
+ "test_pages": 73,
31
+ "target_false_flag": 0.005
32
+ }
33
+ }
harfix/detect.py ADDED
@@ -0,0 +1,365 @@
1
+ """Turn character statistics and model scores into a keep / repair / reextract / ocr verdict."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass, field
7
+ from functools import lru_cache
8
+ from importlib import resources
9
+
10
+ from .model import ModelPair, default_models
11
+ from .normalize import BOUNDARY, drop_ligature_forms, letters_only, profile, strip_decorations
12
+
13
+ # Ordered from least to most severe; a page takes the most severe route of its parts.
14
+ ROUTES = ("keep", "repair", "reextract", "ocr")
15
+ _MIN_LINE_LETTERS = 20
16
+ # `assess_document` splits text without page breaks into pieces of about this many letters.
17
+ SEGMENT_LETTERS = 500
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Thresholds:
22
+ """Decision thresholds. Packaged values come from `scripts/calibrate.py`."""
23
+
24
+ min_letters: int = 80
25
+ # Mean surprisal (bits/character) of the reading-order-corrected text.
26
+ substitution_bits: float = 4.7
27
+ # A character at least this surprising is a spike; too many spikes means swapped letters.
28
+ spike_bits: float = 18.0
29
+ spike_rate: float = 0.02
30
+ # Spike rates on short runs (headings, captions) are too noisy to act on.
31
+ spike_min_letters: int = 200
32
+ # Implausible text whose letters are fine once spaces are removed has broken word spacing.
33
+ unspaced_bits: float = 5.4
34
+ short_token_ratio: float = 0.46
35
+ orientation_margin: float = 0.5
36
+ presentation_form_ratio: float = 0.05
37
+ unmapped_ratio: float = 0.02
38
+ unmapped_min_glyphs: int = 5
39
+ # Pre-Unicode Arabic fonts: mostly Latin-1/math symbols and almost no Arabic letters.
40
+ legacy_ratio: float = 0.15
41
+ legacy_min_visible: int = 30
42
+ # Pre-Unicode Arabic fonts mapped onto ASCII: Latin "words" with almost no vowels, or
43
+ # with digits, symbols and case flips inside them.
44
+ latin_min_words: int = 15
45
+ latin_vowel_ratio: float = 0.25
46
+ latin_irregular_ratio: float = 0.3
47
+ # Quran glyph fonts: one Presentation Forms-A code point per word, numbered in order.
48
+ quran_min_forms: int = 8
49
+ quran_ligature_share: float = 0.5
50
+ quran_short_run_share: float = 0.6
51
+ quran_consecutive_share: float = 0.5
52
+ transposition_ratio: float = 0.27
53
+ mixed_order_min_share: float = 0.2
54
+
55
+ @classmethod
56
+ def from_dict(cls, data: dict) -> Thresholds:
57
+ known = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
58
+ return cls(**known)
59
+
60
+
61
+ @lru_cache(maxsize=1)
62
+ def default_thresholds() -> Thresholds:
63
+ path = resources.files("harfix") / "data" / "thresholds.json"
64
+ if not path.is_file():
65
+ return Thresholds()
66
+ return Thresholds.from_dict(json.loads(path.read_text(encoding="utf-8"))["thresholds"])
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Measurements:
71
+ letters: int
72
+ orientation: str # "logical", "visual" or "unknown"
73
+ visual_line_share: float
74
+ bits: float
75
+ spike_rate: float
76
+ bits_unspaced: float
77
+ short_token_ratio: float
78
+ transposition_ratio: float
79
+ decoration_runs: int
80
+
81
+
82
+ def measure(text: str, models: ModelPair, thresholds: Thresholds) -> Measurements:
83
+ """Model-based measurements of `text` after correcting each line's reading direction."""
84
+ model = models.spaced
85
+ margin = thresholds.orientation_margin
86
+ lines, decoration_runs = [], 0
87
+ for raw in text.splitlines():
88
+ line, runs = strip_decorations(letters_only(raw))
89
+ decoration_runs += runs
90
+ if line:
91
+ lines.append(line)
92
+ decisions: list[bool | None] = [] # True = visual (reversed) line
93
+ visual_letters = logical_letters = 0
94
+ for line in lines:
95
+ n = len(line.replace(BOUNDARY, ""))
96
+ if n < _MIN_LINE_LETTERS:
97
+ decisions.append(None)
98
+ continue
99
+ forward, _ = model.bits_per_char(line)
100
+ backward, _ = model.bits_per_char(line[::-1])
101
+ if forward - backward > margin:
102
+ decisions.append(True)
103
+ visual_letters += n
104
+ elif backward - forward > margin:
105
+ decisions.append(False)
106
+ logical_letters += n
107
+ else:
108
+ decisions.append(None)
109
+
110
+ if visual_letters or logical_letters:
111
+ document_visual = visual_letters > logical_letters
112
+ orientation = "visual" if document_visual else "logical"
113
+ else:
114
+ joined = BOUNDARY.join(lines)
115
+ forward, _ = model.bits_per_char(joined)
116
+ backward, _ = model.bits_per_char(joined[::-1])
117
+ document_visual = forward - backward > margin
118
+ if document_visual:
119
+ orientation = "visual"
120
+ else:
121
+ orientation = "logical" if backward - forward > margin else "unknown"
122
+ decided = visual_letters + logical_letters
123
+
124
+ oriented = BOUNDARY.join(
125
+ line[::-1] if (document_visual if visual is None else visual) else line
126
+ for line, visual in zip(lines, decisions, strict=True)
127
+ )
128
+ surprisals = model.surprisals(oriented)
129
+ unspaced = oriented.replace(BOUNDARY, "")
130
+ bits_unspaced, _ = models.unspaced.bits_per_char(unspaced)
131
+
132
+ words = oriented.split(BOUNDARY) if oriented else []
133
+ al_words = sum(1 for w in words if len(w) >= 3 and w.startswith("ال"))
134
+ swapped = sum(1 for w in words if len(w) >= 4 and w[0] == "ا" and w[2] == "ل" and w[1] != "ل")
135
+
136
+ return Measurements(
137
+ letters=len(unspaced),
138
+ orientation=orientation,
139
+ visual_line_share=visual_letters / decided if decided else 0.0,
140
+ bits=sum(surprisals) / len(surprisals) if surprisals else 0.0,
141
+ spike_rate=(
142
+ sum(1 for s in surprisals if s >= thresholds.spike_bits) / len(surprisals)
143
+ if surprisals
144
+ else 0.0
145
+ ),
146
+ bits_unspaced=bits_unspaced,
147
+ short_token_ratio=sum(1 for w in words if len(w) <= 2) / len(words) if words else 0.0,
148
+ transposition_ratio=swapped / (al_words + swapped) if al_words + swapped else 0.0,
149
+ decoration_runs=decoration_runs,
150
+ )
151
+
152
+
153
+ @dataclass
154
+ class Assessment:
155
+ route: str
156
+ reasons: list[str]
157
+ sufficient: bool
158
+ metrics: dict[str, float | int | str] = field(default_factory=dict)
159
+
160
+ @property
161
+ def decisive(self) -> bool:
162
+ """Judged on enough text, or flagged by a check that needs no minimum length."""
163
+ return self.sufficient or self.route != "keep"
164
+
165
+ def to_dict(self) -> dict:
166
+ return asdict(self)
167
+
168
+
169
+ def assess_text(
170
+ text: str,
171
+ *,
172
+ order_known: bool = True,
173
+ models: ModelPair | None = None,
174
+ thresholds: Thresholds | None = None,
175
+ ) -> Assessment:
176
+ """Assess one piece of extracted Arabic text.
177
+
178
+ `order_known=True` is for an extractor's final output, where reading order is part of
179
+ what is being checked. `order_known=False` is for raw PDF text-layer runs whose order
180
+ depends on the extractor; reading-order faults are not reported then.
181
+ """
182
+ models = models or default_models()
183
+ t = thresholds or default_thresholds()
184
+ prof = profile(text)
185
+ metrics: dict[str, float | int | str] = {
186
+ "arabic_letters": prof.arabic_letters,
187
+ "arabic_ratio": round(prof.arabic_ratio, 4),
188
+ "presentation_form_ratio": round(prof.presentation_form_ratio, 4),
189
+ "unmapped_ratio": round(prof.unmapped_ratio, 4),
190
+ "legacy_ratio": round(prof.legacy_ratio, 4),
191
+ "latin_vowel_ratio": round(prof.latin_vowel_ratio, 4),
192
+ "irregular_latin_word_ratio": round(prof.irregular_latin_word_ratio, 4),
193
+ "bidi_controls": prof.bidi_controls,
194
+ }
195
+ reasons: list[str] = []
196
+ route = "keep"
197
+
198
+ def flag(reason: str, new_route: str) -> None:
199
+ nonlocal route
200
+ reasons.append(reason)
201
+ if ROUTES.index(new_route) > ROUTES.index(route):
202
+ route = new_route
203
+
204
+ # All three are visible even when too few Arabic letters survive to score.
205
+ if prof.unmapped >= t.unmapped_min_glyphs and prof.unmapped_ratio > t.unmapped_ratio:
206
+ flag("unmapped_glyphs", "ocr")
207
+ symbol_legacy = prof.visible >= t.legacy_min_visible and prof.legacy_ratio > t.legacy_ratio
208
+ ascii_legacy = prof.latin_words >= t.latin_min_words and (
209
+ prof.latin_vowel_ratio < t.latin_vowel_ratio
210
+ or prof.irregular_latin_word_ratio > t.latin_irregular_ratio
211
+ )
212
+ if prof.arabic_ratio < 0.2 and (symbol_legacy or ascii_legacy):
213
+ flag("legacy_encoding", "ocr")
214
+ if (
215
+ prof.presentation_forms >= t.quran_min_forms
216
+ and prof.ligature_form_share > t.quran_ligature_share
217
+ and prof.short_presentation_run_share > t.quran_short_run_share
218
+ and prof.consecutive_ligature_share > t.quran_consecutive_share
219
+ ):
220
+ # Glyph IDs, not letters: NFKC would turn them into wrong words. Judge the rest.
221
+ flag("quran_glyph_codes", "ocr")
222
+ text = drop_ligature_forms(text)
223
+ prof = profile(text)
224
+
225
+ m = measure(text, models, t)
226
+ metrics["arabic_letters"] = m.letters
227
+ if m.letters < t.min_letters:
228
+ if not reasons:
229
+ reasons.append("insufficient_arabic_text")
230
+ return Assessment(route, reasons, False, metrics)
231
+
232
+ metrics.update(
233
+ orientation=m.orientation,
234
+ bits=round(m.bits, 4),
235
+ spike_rate=round(m.spike_rate, 4),
236
+ bits_unspaced=round(m.bits_unspaced, 4),
237
+ short_token_ratio=round(m.short_token_ratio, 4),
238
+ decoration_runs=m.decoration_runs,
239
+ )
240
+ spiky = m.letters >= t.spike_min_letters and m.spike_rate > t.spike_rate
241
+ implausible = m.bits > t.substitution_bits or spiky
242
+ # Only meaningful for an extractor's final output. In a raw text-layer scan the spacing
243
+ # comes from harfix's own extraction, so implausible text is treated as wrong letters.
244
+ spacing_only = (
245
+ order_known
246
+ and m.short_token_ratio > t.short_token_ratio
247
+ and m.bits_unspaced <= t.unspaced_bits
248
+ )
249
+ if spacing_only:
250
+ # The letters are right; the extractor split words apart.
251
+ flag("lost_word_boundaries", "reextract")
252
+ elif implausible:
253
+ # Even after fixing the reading direction the letters do not form Arabic words:
254
+ # the font's glyph-to-letter mapping is wrong.
255
+ flag("same_script_substitution", "ocr")
256
+ if prof.presentation_form_ratio > t.presentation_form_ratio:
257
+ flag("presentation_forms", "repair")
258
+
259
+ if order_known and not implausible:
260
+ metrics.update(
261
+ visual_line_share=round(m.visual_line_share, 4),
262
+ transposition_ratio=round(m.transposition_ratio, 4),
263
+ )
264
+ if t.mixed_order_min_share <= m.visual_line_share <= 1 - t.mixed_order_min_share:
265
+ flag("mixed_reading_order", "reextract")
266
+ elif m.orientation == "visual":
267
+ flag("visual_order", "repair")
268
+ if m.transposition_ratio > t.transposition_ratio:
269
+ flag("lam_alef_transposition", "reextract")
270
+
271
+ return Assessment(route, reasons, True, metrics)
272
+
273
+
274
+ @dataclass
275
+ class Segment:
276
+ """One page (text split at form feeds) or one run of lines of an extractor's output."""
277
+
278
+ index: int
279
+ page: int | None
280
+ start_line: int
281
+ end_line: int
282
+ assessment: Assessment
283
+
284
+ def to_dict(self) -> dict:
285
+ return {
286
+ "index": self.index,
287
+ "page": self.page,
288
+ "start_line": self.start_line,
289
+ "end_line": self.end_line,
290
+ **self.assessment.to_dict(),
291
+ }
292
+
293
+
294
+ @dataclass
295
+ class TextReport:
296
+ route: str
297
+ reasons: list[str]
298
+ segments: list[Segment]
299
+
300
+ def to_dict(self) -> dict:
301
+ return {
302
+ "route": self.route,
303
+ "reasons": self.reasons,
304
+ "segments": [s.to_dict() for s in self.segments],
305
+ }
306
+
307
+
308
+ def worst(assessments: list[Assessment]) -> tuple[str, list[str]]:
309
+ """Most severe route, with the reasons behind it (or every reason if all are keep)."""
310
+ route = max((a.route for a in assessments), default="keep", key=ROUTES.index)
311
+ if route == "keep":
312
+ return route, sorted({r for a in assessments for r in a.reasons})
313
+ return route, sorted({r for a in assessments if a.route != "keep" for r in a.reasons})
314
+
315
+
316
+ def _split(text: str, segment_letters: int, min_letters: int) -> list[tuple[int | None, int, str]]:
317
+ """(page, first line number, text) pieces. Pages come from form feeds (pdftotext)."""
318
+ if "\f" in text:
319
+ pieces, line = [], 1
320
+ for number, page in enumerate(text.split("\f"), start=1):
321
+ if page.strip():
322
+ pieces.append((number, line, page))
323
+ line += page.count("\n")
324
+ return pieces
325
+
326
+ pieces: list[tuple[int | None, int, str]] = []
327
+ lines = text.split("\n")
328
+ start, letters = 0, 0
329
+ for i, line in enumerate(lines):
330
+ letters += len(letters_only(line).replace(BOUNDARY, ""))
331
+ if letters >= segment_letters:
332
+ pieces.append((None, start + 1, "\n".join(lines[start : i + 1])))
333
+ start, letters = i + 1, 0
334
+ rest = "\n".join(lines[start:])
335
+ if rest.strip():
336
+ if pieces and letters < min_letters:
337
+ # Too short to judge alone: fold the tail into the previous piece.
338
+ _, first, previous = pieces.pop()
339
+ pieces.append((None, first, previous + "\n" + rest))
340
+ else:
341
+ pieces.append((None, start + 1, rest))
342
+ return pieces
343
+
344
+
345
+ def assess_document(
346
+ text: str,
347
+ *,
348
+ order_known: bool = True,
349
+ segment_letters: int = SEGMENT_LETTERS,
350
+ models: ModelPair | None = None,
351
+ thresholds: Thresholds | None = None,
352
+ ) -> TextReport:
353
+ """Assess a whole extracted document page by page, so a few bad pages are not averaged
354
+ away. Text with form feeds is split into pages; other text into runs of whole lines
355
+ holding at least `segment_letters` Arabic letters."""
356
+ models = models or default_models()
357
+ t = thresholds or default_thresholds()
358
+ segments = []
359
+ for index, (page, first, piece) in enumerate(_split(text, segment_letters, t.min_letters)):
360
+ assessment = assess_text(piece, order_known=order_known, models=models, thresholds=t)
361
+ last = first + piece.rstrip("\n").count("\n")
362
+ segments.append(Segment(index, page, first, last, assessment))
363
+ decisive = [s.assessment for s in segments if s.assessment.decisive]
364
+ route, reasons = worst(decisive or [s.assessment for s in segments])
365
+ return TextReport(route, reasons, segments)