File size: 41,892 Bytes
547ce6e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
"""Structure-aware parsing of the corpus PDFs into numbered articles.

Why this file is not three lines of ``page.get_text()``
------------------------------------------------------
The corpus is real government typesetting. Five separate properties of it would each
silently poison every downstream metric if the text were extracted naively. Every one
was found by measuring the actual files, and every one is asserted in
``tests/test_parse.py`` against those files.

1. **Two-column layout.** The MOHRE Labour Law is a landscape two-column document.
   Reading blocks in y-order interleaves the columns, splicing Article 2 into Article 4
   mid-sentence. Blocks are assigned to a column and read column-major.

2. **A broken ``Th`` ligature.** The Labour Law embeds ``CronosPro`` with the ``Th``
   ligature mapped to the single codepoint ``T``, so the text layer literally reads
   "Te employer shall" and "Tis Decree-Law". A word list would be a guess ("Ten",
   "Tree" and "Tan" are real words). The defect is instead detected geometrically: the
   ligature glyph is drawn twice as wide as a real ``T``. Measured over the whole
   document the two populations are cleanly separated with no overlap β€”

       real ``T``        n=95   advance/font-size 0.52 - 0.62   ("Type of", "Their")
       ``Th`` ligature   n=306  advance/font-size 1.00 - 1.13   ("Te employer")

   so :data:`WIDE_GLYPH_RATIO` = 0.80 sits inside a 0.38-wide empty gap. Only the wide
   population is rewritten. ``scripts/glyph_audit.py`` is the tool that found this and
   re-runs on any new corpus to prove no other glyph is affected.

3. **Contents pages that look exactly like article headings.** Both instruments in the
   Labour Law PDF open with a table of contents listing ``Article (1) ... Article (74)``.
   Indexing those would create 113 empty articles. Contents pages are detected by their
   "Contents" heading and excluded from the body pass β€” then *reused* as an independent
   ground-truth index to validate what the body pass produced (see
   :func:`reconcile_with_contents`). The document checks the parser.

4. **One PDF, several instruments.** The Labour Law file also contains Cabinet
   Resolution No. 1 of 2022, whose article numbering restarts at 1. "Article 5" is
   ambiguous across them, so instruments are split into parts and every citation key
   carries its part.

5. **A missing heading in the source document.** Cabinet Resolution Article (2) has no
   ``Article (2)`` label on its body page β€” the official PDF prints only its section
   title, though its own table of contents lists the article. The gap is recovered from
   the section-title marker and reported in the diagnostics rather than being papered
   over.
"""

from __future__ import annotations

import json
import logging
import re
import unicodedata
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Final

import fitz

from app.core.models import Article, ParsedDocument
from app.core.settings import Settings, get_settings

logger = logging.getLogger(__name__)

# ── glyph-level repair ───────────────────────────────────────────────────────
# A glyph whose advance exceeds this multiple of its font size is drawing more than
# one character. Only applied to codepoints listed in LIGATURE_REPAIRS: naturally wide
# letters such as m, W and M also exceed this ratio and must not be touched.
WIDE_GLYPH_RATIO: Final = 0.80
LIGATURE_REPAIRS: Final[dict[str, str]] = {"T": "Th"}

# ── line classification ──────────────────────────────────────────────────────
# An article heading is a line consisting of nothing but the article label, optionally
# followed by a footnote marker. Anchoring BOTH ends is what stops the in-body
# cross-reference "...in accordance with Article (9) of this Law." being read as a
# heading. Verified against every `Article ...` line in the corpus: 270 bare labels,
# 2 with a trailing asterisk, 1 contents range, 1 in-body cross-reference.
ARTICLE_HEADING_RE: Final = re.compile(
    r"^\s*Article\s*\(?\s*(\d{1,3})\s*\)?\s*[*†‑]?\s*[:.–—-]?\s*$",
    re.IGNORECASE,
)
# Contents rows may cover a span, e.g. "Article (58-64)".
CONTENTS_ENTRY_RE: Final = re.compile(
    r"^\s*Article\s*\(?\s*(\d{1,3})\s*(?:[-–—]\s*(\d{1,3}))?\s*\)?\s*$",
    re.IGNORECASE,
)
# A legal instrument's own title line: the designation and NOTHING else. Anchoring the
# end is essential β€” the recitals of every Dubai law list other instruments
# ("Law No. (16) of 2007 Establishing the Real Estate Regulatory Agency;") and those
# must not be mistaken for the title of the document being parsed.
INSTRUMENT_TITLE_RE: Final = re.compile(
    r"^\s*(?P<kind>(?:Federal\s+)?(?:Decree[-\s]?Law|Cabinet\s+Resolution|Law|Decree|Resolution))"
    r"\s+No\.?\s*\(?\s*(?P<number>\d{1,4})\s*\)?\s*of\s+(?P<year>\d{4})\s*[*†‑]?\s*\d{0,2}\s*$",
    re.IGNORECASE,
)
# Same designation, but allowed to be followed by descriptive text. Used only to derive
# the canonical key of the manifest title, never to detect a title line in the PDF.
INSTRUMENT_KEY_RE: Final = re.compile(
    r"(?P<kind>(?:Federal\s+)?(?:Decree[-\s]?Law|Cabinet\s+Resolution|Law|Decree|Resolution))"
    r"\s+No\.?\s*\(?\s*(?P<number>\d{1,4})\s*\)?\s*of\s+(?P<year>\d{4})",
    re.IGNORECASE,
)
PAGE_FURNITURE_RE: Final = re.compile(r"^\s*(?:page\s+\d+\s+of\s+\d+|\d{1,4})\s*$", re.IGNORECASE)
CONTENTS_HEADING_RE: Final = re.compile(r"^\s*contents\s*$", re.IGNORECASE)
# The Labour Law's definitions table sets the term, its colon and its definition as
# three separate blocks. A lone colon carries no letters, so the generic
# "drop punctuation-only lines" rule (which removes bullet glyphs and the Arabic rules
# that separate Dubai law headings) would silently delete every separator in the table,
# yielding "State United Arab Emirates." instead of "State: United Arab Emirates.".
PUNCTUATION_JOINER_RE: Final = re.compile(r"^[:;]$")
# Section-title bullet the Labour Law prefixes to every article title.
TITLE_MARKER_CHARS: Final = "►▢‒●β–ͺΒ»"

# A line is page furniture when its font is meaningfully smaller than the body font.
# Relative, not absolute: body text is 8pt in the Labour Law and 11pt in the Dubai
# laws, while their footnotes are 6.5pt and 8pt respectively.
FURNITURE_SIZE_RATIO: Final = 0.90
FULL_WIDTH_RATIO: Final = 0.75
GUTTER_RATIO: Final = 0.02
MAX_TITLE_CHARS: Final = 90
CENTRED_TOLERANCE_RATIO: Final = 0.10
MIN_BLOCKS_FOR_COLUMN_DETECTION: Final = 4
# A contents row needs at least a label and a title to be usable.
# A contents row ends in the page it points at, and an entry spanning pages prints a
# range: "Article (58-64) | Penalties | 39-40". PAGE_FURNITURE_RE covers a bare page
# number but not a range, so the range survived into the title and seven articles were
# indexed as "Penalties 39-40". Kept separate from PAGE_FURNITURE_RE deliberately: this
# shape is only unambiguous inside a table of contents, where a trailing numeric cell is
# always a page reference. In body text it could be a real range.
CONTENTS_PAGE_CELL_RE: Final = re.compile(r"^\s*\d{1,4}\s*[-–—]\s*\d{1,4}\s*$")
# The same range, welded to the title when the PDF emits both in one block.
CONTENTS_TRAILING_PAGE_RE: Final = re.compile(r"[\s.Β·β€’]*\b\d{1,4}(?:\s*[-–—]\s*\d{1,4})?\s*$")
MIN_CONTENTS_ROW_CELLS: Final = 2
# A page is either one column or two; nothing in this corpus uses three.
TWO_COLUMNS: Final = 2
# Baseline tolerance when reassembling table-of-contents rows from stacked blocks.
ROW_TOLERANCE_PT: Final = 3.0
# Blocks and lines whose tops fall in the same band of this height are one visual row
# and must read left-to-right. Without it, the Labour Law definitions table inverts:
# the definition block starts at y=121.6 and its own term at y=121.7, so ordering by
# raw y emits "United Arab Emirates." before the word "State" it defines. The band is
# far narrower than the tightest line pitch in the corpus (10pt), so genuine rows never
# merge.
ROW_QUANTUM_PT: Final = 3.0
# Largest article span a single contents row may cover, e.g. "Article (58-64)".
MAX_CONTENTS_SPAN: Final = 50
# A line reaching this fraction of the text-column width is a justified body line,
# never a title. See the docstring of `_looks_like_title`.
JUSTIFIED_LINE_RATIO: Final = 0.90
# When a title is recognised only by being centred, it must also be clearly short.
CENTRED_TITLE_WIDTH_RATIO: Final = 0.60
# Percentile of body-line widths used as the text-column width.
BODY_WIDTH_PERCENTILE: Final = 0.90
# Whitespace handling after NFKC. Written with escapes so a reviewer can read the exact
# ranges rather than trust invisible literals.
# Zero-width characters carry no spacing and must be DELETED, not collapsed to a
# space: turning U+200B into " " inserts a word break the document never had.
_ZERO_WIDTH_RE: Final = re.compile("[\\u200b-\\u200d\\ufeff]")
# Everything genuinely space-like collapses to one space.
_WHITESPACE_RE: Final = re.compile("[ \\t\\r\\n\\u00a0\\u2000-\\u200a\\u202f\\u205f\\u3000]+")

InstrumentKey = tuple[str, int, int]

# `ParsedDocument` is defined in app.core.models but is part of this module's public
# surface: callers parse a corpus and receive them. Declared explicitly because
# `no_implicit_reexport` (correctly) refuses to forward an import by accident.
__all__ = [
    "ARTICLE_HEADING_RE",
    "INSTRUMENT_TITLE_RE",
    "LIGATURE_REPAIRS",
    "WIDE_GLYPH_RATIO",
    "Article",
    "ContentsEntry",
    "ParseDiagnostics",
    "ParseError",
    "ParsedDocument",
    "load_manifest",
    "parse_corpus",
    "parse_pdf",
    "reconcile_with_contents",
]


class ParseError(RuntimeError):
    """The corpus could not be parsed into a well-formed article structure."""


@dataclass(frozen=True, slots=True)
class _Line:
    """One visual line of text with the geometry needed to classify it."""

    text: str
    page: int
    x0: float
    y0: float
    x1: float
    size: float
    bold: bool

    @property
    def centre(self) -> float:
        return (self.x0 + self.x1) / 2.0


@dataclass(frozen=True, slots=True)
class _Block:
    """A layout block: the unit that column ordering operates on."""

    lines: tuple[_Line, ...]
    x0: float
    y0: float
    x1: float

    @property
    def centre(self) -> float:
        return (self.x0 + self.x1) / 2.0

    @property
    def width(self) -> float:
        return self.x1 - self.x0


@dataclass(frozen=True, slots=True)
class _Layout:
    """Document-wide typographic scale, measured once from the file itself.

    Nothing here is hardcoded per document: `body_size` is the font size carrying the
    most characters, and `body_line_width` is the 90th-percentile width of lines set in
    it β€” i.e. the width of a full justified line in the text column.
    """

    body_size: float
    body_line_width: float

    @property
    def furniture_ceiling(self) -> float:
        return self.body_size * FURNITURE_SIZE_RATIO


@dataclass(frozen=True, slots=True)
class ContentsEntry:
    """One row of a document's own table of contents β€” independent ground truth."""

    article_no: int
    title: str


@dataclass(slots=True)
class ParseDiagnostics:
    """Observable evidence that the parser did what it claims.

    Printed by ``make index`` and asserted in tests, so a regression in extraction
    quality fails a build instead of quietly degrading retrieval.
    """

    ligature_repairs: int = 0
    contents_pages_skipped: int = 0
    furniture_lines_dropped: int = 0
    duplicate_article_splits: int = 0
    parts_detected: int = 0
    inferred_headings: int = 0
    contents_entries_seen: int = 0
    titles_from_contents: int = 0
    contents_missing_in_body: list[str] = field(default_factory=list)
    contents_extra_in_body: list[str] = field(default_factory=list)

    def as_dict(self) -> dict[str, Any]:
        return {
            "ligature_repairs": self.ligature_repairs,
            "contents_pages_skipped": self.contents_pages_skipped,
            "furniture_lines_dropped": self.furniture_lines_dropped,
            "duplicate_article_splits": self.duplicate_article_splits,
            "parts_detected": self.parts_detected,
            "inferred_headings": self.inferred_headings,
            "contents_entries_seen": self.contents_entries_seen,
            "titles_from_contents": self.titles_from_contents,
            "contents_missing_in_body": self.contents_missing_in_body,
            "contents_extra_in_body": self.contents_extra_in_body,
        }

    @property
    def contents_check_passed(self) -> bool:
        return not self.contents_missing_in_body and not self.contents_extra_in_body


# ─────────────────────────────────────────────────────────────────────────────
# Glyph -> text
# ─────────────────────────────────────────────────────────────────────────────


def _span_text(span: dict[str, Any], diagnostics: ParseDiagnostics) -> str:
    """Reconstruct a span's text, repairing ligatures that lost characters."""
    size = float(span.get("size") or 0.0)
    out: list[str] = []
    for char in span["chars"]:
        code: str = char["c"]
        replacement = LIGATURE_REPAIRS.get(code)
        if replacement is not None and size > 0:
            bbox = char["bbox"]
            if (bbox[2] - bbox[0]) / size > WIDE_GLYPH_RATIO:
                out.append(replacement)
                diagnostics.ligature_repairs += 1
                continue
        out.append(code)
    return "".join(out)


def _normalise(text: str) -> str:
    """NFKC-fold compatibility ligatures (fi, ffi, fl) and collapse whitespace.

    NFKC already maps U+00A0 to a plain space, so no separate pass is needed for it.
    """
    folded = _ZERO_WIDTH_RE.sub("", unicodedata.normalize("NFKC", text))
    return _WHITESPACE_RE.sub(" ", folded).strip()


def _has_letters_or_digits(text: str) -> bool:
    return any(ch.isalnum() for ch in text)


def _instrument_key(text: str) -> InstrumentKey | None:
    """Canonical identity of a legal instrument: ``(kind, number, year)``."""
    match = INSTRUMENT_KEY_RE.search(text)
    if match is None:
        return None
    kind = re.sub(r"[\s-]+", "", match.group("kind")).lower()
    return (kind, int(match.group("number")), int(match.group("year")))


# ─────────────────────────────────────────────────────────────────────────────
# Page -> ordered lines
# ─────────────────────────────────────────────────────────────────────────────


def _extract_blocks(page: fitz.Page, page_no: int, diagnostics: ParseDiagnostics) -> list[_Block]:
    raw: dict[str, Any] = page.get_text("rawdict")
    blocks: list[_Block] = []
    for raw_block in raw["blocks"]:
        if raw_block.get("type") != 0:
            continue
        lines: list[_Line] = []
        for raw_line in raw_block.get("lines", []):
            spans = raw_line["spans"]
            if not spans:
                continue
            text = _normalise("".join(_span_text(sp, diagnostics) for sp in spans))
            if not text:
                continue
            lx0, ly0, lx1, _ly1 = raw_line["bbox"]
            lines.append(
                _Line(
                    text=text,
                    page=page_no,
                    x0=lx0,
                    y0=ly0,
                    x1=lx1,
                    size=max(float(sp["size"]) for sp in spans),
                    bold=any(
                        "bold" in str(sp.get("font", "")).lower()
                        or bool(int(sp.get("flags", 0)) & 16)
                        for sp in spans
                    ),
                )
            )
        if not lines:
            continue
        # Within a block, horizontal text reads top-down then left-right. The PDF's
        # internal line order does not guarantee this (the Labour Law emits some
        # headings after their own subtitle), so it is imposed here.
        lines.sort(key=lambda ln: (_row_band(ln.y0), ln.x0))
        bx0, by0, bx1, _by1 = raw_block["bbox"]
        blocks.append(_Block(lines=tuple(lines), x0=bx0, y0=by0, x1=bx1))
    return blocks


def _row_band(y: float) -> int:
    """Quantise a top coordinate into a visual row band. See ROW_QUANTUM_PT."""
    return int(y // ROW_QUANTUM_PT)


def _is_two_column(blocks: list[_Block], page_width: float) -> bool:
    """True when the page is typeset in two columns separated by a clear gutter."""
    mid = page_width / 2.0
    margin = page_width * GUTTER_RATIO
    narrow = [b for b in blocks if b.width < page_width * FULL_WIDTH_RATIO]
    if len(narrow) < MIN_BLOCKS_FOR_COLUMN_DETECTION:
        return False
    if any(b.x0 < mid - margin and b.x1 > mid + margin for b in narrow):
        return False
    return any(b.x1 <= mid for b in narrow) and any(b.x0 >= mid for b in narrow)


def _order_blocks(blocks: list[_Block], page_width: float) -> list[_Block]:
    """Return blocks in human reading order."""
    if not _is_two_column(blocks, page_width):
        return sorted(blocks, key=lambda b: (_row_band(b.y0), b.x0))
    mid = page_width / 2.0
    return sorted(blocks, key=lambda b: (0 if b.centre < mid else 1, _row_band(b.y0), b.x0))


def _page_lines(page: fitz.Page, page_no: int, diagnostics: ParseDiagnostics) -> list[_Line]:
    blocks = _extract_blocks(page, page_no, diagnostics)
    return [line for block in _order_blocks(blocks, page.rect.width) for line in block.lines]


def _is_contents_page(lines: list[_Line], body_size: float) -> bool:
    return any(CONTENTS_HEADING_RE.match(ln.text) and ln.size >= body_size for ln in lines)


def _measure_layout(all_lines: list[_Line]) -> _Layout:
    """Measure the document's body font size and text-column width."""
    weight: Counter[float] = Counter()
    for line in all_lines:
        weight[round(line.size, 1)] += len(line.text)
    if not weight:
        raise ParseError("document contains no extractable text")
    body_size = weight.most_common(1)[0][0]

    widths = sorted(ln.x1 - ln.x0 for ln in all_lines if round(ln.size, 1) == body_size)
    if not widths:
        raise ParseError("document contains no body-size lines")
    index = min(len(widths) - 1, int(len(widths) * BODY_WIDTH_PERCENTILE))
    return _Layout(body_size=body_size, body_line_width=widths[index])


# ─────────────────────────────────────────────────────────────────────────────
# Contents pages -> independent ground truth
# ─────────────────────────────────────────────────────────────────────────────


def _parse_contents_page(lines: list[_Line], page_width: float) -> list[ContentsEntry]:
    """Read a table-of-contents page into ``(article_no, title)`` rows.

    A contents row reads ``Article (N) | Title | page``, but the PDF does not emit it as
    one block: the labels are one stacked block, the titles another, the page numbers a
    third. Rows are therefore reassembled geometrically β€” lines sharing a baseline
    within :data:`ROW_TOLERANCE_PT` form a row, and within a row the label is the entry,
    the trailing number is the page, and whatever lies between them is the title.
    """
    mid = page_width / 2.0
    columns: list[list[_Line]] = [[], []] if _is_two_column_lines(lines, page_width) else [[]]
    two_columns = len(columns) == TWO_COLUMNS
    for line in lines:
        columns[1 if two_columns and line.centre >= mid else 0].append(line)

    entries: list[ContentsEntry] = []
    for column in columns:
        for row in _group_rows(column):
            entries.extend(_row_to_entries(row))
    return entries


def _is_two_column_lines(lines: list[_Line], page_width: float) -> bool:
    mid = page_width / 2.0
    margin = page_width * GUTTER_RATIO
    if any(ln.x0 < mid - margin and ln.x1 > mid + margin for ln in lines):
        return False
    return any(ln.x1 <= mid for ln in lines) and any(ln.x0 >= mid for ln in lines)


def _group_rows(lines: list[_Line]) -> list[list[_Line]]:
    """Cluster lines that share a baseline into rows, each sorted left to right."""
    rows: list[list[_Line]] = []
    for line in sorted(lines, key=lambda ln: (ln.page, ln.y0, ln.x0)):
        if rows and abs(rows[-1][0].y0 - line.y0) <= ROW_TOLERANCE_PT:
            rows[-1].append(line)
        else:
            rows.append([line])
    for row in rows:
        row.sort(key=lambda ln: ln.x0)
    return rows


def _row_to_entries(row: list[_Line]) -> list[ContentsEntry]:
    """Turn one contents row into its article entries, expanding any span."""
    label_index = next(
        (i for i, ln in enumerate(row) if CONTENTS_ENTRY_RE.match(ln.text)),
        None,
    )
    if label_index is None:
        return []
    match = CONTENTS_ENTRY_RE.match(row[label_index].text)
    if match is None:  # pragma: no cover - guarded by the search above
        return []
    first = int(match.group(1))
    last = int(match.group(2)) if match.group(2) else first
    if last < first or last - first > MAX_CONTENTS_SPAN:
        return []

    title_parts = [
        ln.text.strip().lstrip(TITLE_MARKER_CHARS).strip()
        for ln in row[label_index + 1 :]
        if not PAGE_FURNITURE_RE.match(ln.text.strip())
        and not CONTENTS_PAGE_CELL_RE.match(ln.text.strip())
        and not CONTENTS_ENTRY_RE.match(ln.text.strip())
    ]
    title = _normalise(" ".join(part for part in title_parts if part))
    # Only strip when something with letters survives: better a title carrying a stray
    # page number than no title at all.
    stripped = CONTENTS_TRAILING_PAGE_RE.sub("", title).strip()
    if stripped and any(ch.isalpha() for ch in stripped):
        title = stripped
    return [ContentsEntry(article_no=n, title=title) for n in range(first, last + 1)]


def reconcile_with_contents(
    articles: list[Article],
    contents_by_part: list[list[ContentsEntry]],
    diagnostics: ParseDiagnostics,
    law_id: str,
) -> list[Article]:
    """Cross-check and enrich parsed articles using the document's own contents pages.

    The k-th contents page in a file describes the k-th instrument in it, which makes it
    an index produced by the publisher rather than by this parser β€” genuinely
    independent evidence. It is used twice:

    * **Validation.** Any article the contents promises but the body pass did not
      produce, or vice versa, is recorded by name. A layout change then surfaces as a
      named discrepancy instead of a quietly smaller index.
    * **Titles.** The contents carries the official title of every article, already
      de-hyphenated and never truncated, so it supersedes the title inferred from page
      geometry (which can clip a title that wraps onto a second line).
    """
    if not contents_by_part:
        return articles

    order: list[str] = []
    by_part: dict[str, list[Article]] = {}
    for article in articles:
        if article.part_id not in by_part:
            order.append(article.part_id)
            by_part[article.part_id] = []
        by_part[article.part_id].append(article)

    titles: dict[tuple[str, int], str] = {}
    for index, part_id in enumerate(order):
        if index >= len(contents_by_part):
            break
        entries = contents_by_part[index]
        expected = {entry.article_no for entry in entries}
        diagnostics.contents_entries_seen += len(expected)
        produced = {article.article_no for article in by_part[part_id]}
        for missing in sorted(expected - produced):
            diagnostics.contents_missing_in_body.append(f"{law_id}/{part_id}#art{missing}")
        for extra in sorted(produced - expected):
            diagnostics.contents_extra_in_body.append(f"{law_id}/{part_id}#art{extra}")
        for entry in entries:
            if entry.title:
                titles[(part_id, entry.article_no)] = entry.title

    enriched: list[Article] = []
    for article in articles:
        official = titles.get((article.part_id, article.article_no))
        if official and official != article.title:
            diagnostics.titles_from_contents += 1
            enriched.append(article.model_copy(update={"title": official}))
        else:
            enriched.append(article)
    return enriched


# ─────────────────────────────────────────────────────────────────────────────
# Body pages -> articles
# ─────────────────────────────────────────────────────────────────────────────


def _looks_like_title(line: _Line, layout: _Layout, page_width: float) -> bool:
    """A short, heading-ish line that names the article beside it.

    The geometric width test is load-bearing, not cosmetic. Body text in the Dubai laws
    is fully justified, so the first line of an article spans the whole text column and
    its midpoint lands exactly on the page centre β€” indistinguishable from a centred
    title by position alone. Without the width test, Decree 43/2013 Article 1 loses its
    opening line ("When renewing Real Property Lease Contracts...") to the title field:
    silent corpus corruption that no downstream check would catch.

    A real title is therefore recognised by a *positive* typographic signal β€” a bullet
    marker, bold weight, or a larger font β€” and the centre-alignment fallback (needed
    only by Decree 43/2013, which sets titles in the same light face as its body) is
    additionally required to be narrow.
    """
    text = line.text.strip().lstrip(TITLE_MARKER_CHARS).strip()
    if not text or len(text) > MAX_TITLE_CHARS:
        return False
    if text.endswith((".", ";", ":", ",")):
        return False
    if ARTICLE_HEADING_RE.match(text) or PAGE_FURNITURE_RE.match(text):
        return False
    if not _has_letters_or_digits(text):
        return False

    width = line.x1 - line.x0
    if width >= layout.body_line_width * JUSTIFIED_LINE_RATIO:
        return False

    if line.bold or line.size > layout.body_size:
        return True
    if line.text.startswith(tuple(TITLE_MARKER_CHARS)):
        return True
    centred = abs(line.centre - page_width / 2.0) < page_width * CENTRED_TOLERANCE_RATIO
    return centred and width < layout.body_line_width * CENTRED_TITLE_WIDTH_RATIO


def _slugify(text: str) -> str:
    slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
    return slug[:60] or "part"


@dataclass(slots=True)
class _ArticleAccumulator:
    part_id: str
    part_title: str
    article_no: int
    title: str
    page_start: int
    page_end: int
    body: list[str] = field(default_factory=list)

    def add(self, line: _Line) -> None:
        self.body.append(line.text)
        self.page_end = max(self.page_end, line.page)

    def text(self) -> str:
        return re.sub(r"\s{2,}", " ", " ".join(self.body)).strip()


@dataclass(slots=True)
class _Segmenter:
    """Walks the ordered line stream and emits articles.

    Kept as a class purely so the state machine's variables are named and inspectable
    rather than a dozen ``nonlocal`` bindings.
    """

    law_id: str
    law_label: str
    law_title: str
    layout: _Layout
    diagnostics: ParseDiagnostics

    articles: list[Article] = field(default_factory=list)
    current: _ArticleAccumulator | None = None
    pending_title: str = ""
    pending_key: InstrumentKey | None = None
    open_part_key: InstrumentKey | None = None
    open_part_id: str = ""
    open_part_title: str = ""
    part_ordinal: int = 0
    seen_in_part: set[int] = field(default_factory=set)
    unassigned_chars: int = 0
    part_opened: bool = False

    def __post_init__(self) -> None:
        self.pending_title = self.law_title
        self.pending_key = _instrument_key(self.law_title)

    # ── article lifecycle ────────────────────────────────────────────────
    def close(self) -> None:
        if self.current is None:
            return
        body = self.current.text()
        if body:
            self.articles.append(
                Article(
                    law_id=self.law_id,
                    law_label=self.law_label,
                    law_title=self.law_title,
                    part_id=self.current.part_id,
                    part_title=self.current.part_title,
                    article_no=self.current.article_no,
                    title=self.current.title,
                    text=body,
                    page_start=self.current.page_start,
                    page_end=self.current.page_end,
                )
            )
        self.current = None

    def _open_part_if_needed(self, article_no: int) -> None:
        same_instrument = self.part_opened and self.open_part_key == self.pending_key
        duplicate = article_no in self.seen_in_part
        if same_instrument and not duplicate:
            return
        if same_instrument and duplicate:
            # A repeated number inside one instrument means a new structural section
            # began: the Dubai amendment restates articles of the law it amends, then
            # closes with its own Article 2. Splitting keeps every citation key unique,
            # so a chip can never resolve to two different texts.
            self.diagnostics.duplicate_article_splits += 1
        self.part_ordinal += 1
        self.open_part_key = self.pending_key
        self.open_part_title = self.pending_title
        self.open_part_id = f"{_slugify(self.pending_title)}-{self.part_ordinal}"
        self.seen_in_part = set()
        self.part_opened = True
        self.diagnostics.parts_detected += 1

    def start_article(self, article_no: int, title: str, page: int) -> None:
        self.close()
        self._open_part_if_needed(article_no)
        self.seen_in_part.add(article_no)
        self.current = _ArticleAccumulator(
            part_id=self.open_part_id,
            part_title=self.open_part_title,
            article_no=article_no,
            title=title,
            page_start=page,
            page_end=page,
        )

    # ── stream handling ──────────────────────────────────────────────────
    def on_instrument_title(self, text: str) -> None:
        key = _instrument_key(text)
        if key is None or key == self.pending_key:
            return
        # A different instrument begins here; whatever article was open ended with the
        # previous one. Without this the Cabinet Resolution's recitals are appended to
        # Labour Law Article 74.
        self.close()
        self.pending_key = key
        self.pending_title = text

    def attach_punctuation(self, mark: str) -> None:
        """Glue a standalone separator onto the text it belongs to."""
        if self.current is not None and self.current.body:
            self.current.body[-1] = f"{self.current.body[-1]}{mark}"

    def on_body(self, line: _Line) -> None:
        if self.current is None:
            self.unassigned_chars += len(line.text)
            return
        self.current.add(line)


def parse_pdf(
    pdf_path: Path,
    *,
    law_id: str,
    law_label: str,
    law_title: str,
    diagnostics: ParseDiagnostics | None = None,
) -> ParsedDocument:
    """Parse one corpus PDF into its numbered articles.

    Raises:
        ParseError: if the file yields no articles, which means the layout changed and
            the extraction rules no longer describe it. Failing loudly here is
            deliberate: a silently empty index makes every downstream metric meaningless.
    """
    diag = diagnostics if diagnostics is not None else ParseDiagnostics()
    document = fitz.open(pdf_path)
    try:
        pages = [
            (index + 1, document[index].rect.width, _page_lines(document[index], index + 1, diag))
            for index in range(document.page_count)
        ]
        page_count = document.page_count
    finally:
        document.close()

    layout = _measure_layout([ln for _, _, lines in pages for ln in lines])

    contents_by_part: list[list[ContentsEntry]] = []
    segmenter = _Segmenter(
        law_id=law_id,
        law_label=law_label,
        law_title=law_title,
        layout=layout,
        diagnostics=diag,
    )

    for page_no, page_width, lines in pages:
        if _is_contents_page(lines, layout.body_size):
            diag.contents_pages_skipped += 1
            contents_by_part.append(_parse_contents_page(lines, page_width))
            continue

        for position, line in enumerate(lines):
            stripped = line.text.strip()

            if line.size < layout.furniture_ceiling or PAGE_FURNITURE_RE.match(stripped):
                diag.furniture_lines_dropped += 1
                continue
            if PUNCTUATION_JOINER_RE.match(stripped):
                segmenter.attach_punctuation(stripped)
                continue
            if not _has_letters_or_digits(stripped):
                diag.furniture_lines_dropped += 1
                continue

            heading = ARTICLE_HEADING_RE.match(stripped)
            if heading is not None:
                segmenter.start_article(
                    int(heading.group(1)),
                    _find_article_title(lines, position, layout, page_width),
                    page_no,
                )
                continue

            if INSTRUMENT_TITLE_RE.match(stripped) and line.size >= layout.body_size:
                segmenter.on_instrument_title(stripped)
                continue

            if _is_consumed_title(lines, position, segmenter.current):
                continue

            inferred = _infer_missing_heading(lines, position, segmenter, layout, page_width)
            if inferred is not None:
                diag.inferred_headings += 1
                segmenter.start_article(inferred[0], inferred[1], page_no)
                continue

            segmenter.on_body(line)

    segmenter.close()

    if not segmenter.articles:
        raise ParseError(
            f"{pdf_path.name}: no articles were extracted. The document layout no longer "
            "matches the extraction rules in app/rag/parse.py."
        )

    articles = _propagate_sections(
        reconcile_with_contents(segmenter.articles, contents_by_part, diag, law_id)
    )

    return ParsedDocument(
        law_id=law_id,
        law_label=law_label,
        law_title=law_title,
        filename=pdf_path.name,
        page_count=page_count,
        articles=tuple(articles),
        unassigned_chars=segmenter.unassigned_chars,
    )


def _propagate_sections(articles: list[Article]) -> list[Article]:
    """Give every article the nearest preceding heading inside its own instrument.

    Dubai Law 26/2007 prints chapter headings ("Term of Lease Contract", "Eviction
    Cases") above the first article of each chapter only, so Articles 6, 7 and 8 carry
    no heading of their own while plainly belonging to one. Carrying the chapter forward
    gives those articles topical context in the embedded text, which is precisely the
    signal a bare "Where the term of a Lease Contract expires..." otherwise lacks.

    The carry resets at every part boundary so a chapter can never leak from one
    instrument into another.
    """
    out: list[Article] = []
    current_part = ""
    section = ""
    for article in articles:
        if article.part_id != current_part:
            current_part = article.part_id
            section = ""
        if article.title:
            section = article.title
        out.append(article.model_copy(update={"section": section}))
    return out


def _find_article_title(
    lines: list[_Line], position: int, layout: _Layout, page_width: float
) -> str:
    """Locate the human title beside an article heading.

    The corpus uses both conventions: the Labour Law emits ``Article (6)`` and
    ``> Recruitment and Employment of Workers`` inside one block in either order, while
    the Dubai laws centre the title on the line above the number.
    """
    for neighbour in (position + 1, position - 1):
        if not 0 <= neighbour < len(lines):
            continue
        candidate = lines[neighbour]
        if candidate.size < layout.furniture_ceiling:
            continue
        if _looks_like_title(candidate, layout, page_width):
            return candidate.text.strip().lstrip(TITLE_MARKER_CHARS).strip()
    return ""


def _is_consumed_title(
    lines: list[_Line], position: int, current: _ArticleAccumulator | None
) -> bool:
    """True when this line was already taken as the current article's title."""
    if current is None or not current.title or current.body:
        return False
    text = lines[position].text.strip().lstrip(TITLE_MARKER_CHARS).strip()
    return text == current.title


def _infer_missing_heading(
    lines: list[_Line],
    position: int,
    segmenter: _Segmenter,
    layout: _Layout,
    page_width: float,
) -> tuple[int, str] | None:
    """Recover an article whose ``Article (N)`` label is absent from the source PDF.

    Cabinet Resolution No. 1 of 2022 prints the section title of its Article 2 but not
    the label, while its own table of contents lists the article. A bare section-title
    marker appearing mid-body, not attached to any label, is therefore treated as the
    start of the next article β€” but only when that number is unused and the next
    explicit label in the stream is further ahead, so a title that simply precedes its
    own label is never mistaken for a missing one.
    """
    line = lines[position]
    if segmenter.current is None or not segmenter.current.body:
        return None
    if not line.text.startswith(tuple(TITLE_MARKER_CHARS)):
        return None
    if not _looks_like_title(line, layout, page_width):
        return None

    candidate_no = segmenter.current.article_no + 1
    if candidate_no in segmenter.seen_in_part:
        return None

    for following in lines[position + 1 :]:
        heading = ARTICLE_HEADING_RE.match(following.text.strip())
        if heading is None:
            continue
        # The very next label is the one this title belongs to, or it re-uses the
        # number we would have inferred. Either way, do not invent an article.
        return None if int(heading.group(1)) <= candidate_no else _titled(candidate_no, line)
    return _titled(candidate_no, line)


def _titled(article_no: int, line: _Line) -> tuple[int, str]:
    return article_no, line.text.strip().lstrip(TITLE_MARKER_CHARS).strip()


# ─────────────────────────────────────────────────────────────────────────────
# Corpus-level entry point
# ─────────────────────────────────────────────────────────────────────────────


def load_manifest(settings: Settings | None = None) -> list[dict[str, Any]]:
    cfg = settings or get_settings()
    if not cfg.manifest_path.exists():
        raise ParseError(
            f"{cfg.manifest_path} not found. Run `make corpus` first β€” it downloads the "
            "official law PDFs and writes the provenance manifest."
        )
    payload: dict[str, Any] = json.loads(cfg.manifest_path.read_text(encoding="utf-8"))
    documents: list[dict[str, Any]] = payload["documents"]
    return documents


def parse_corpus(
    settings: Settings | None = None,
    diagnostics: ParseDiagnostics | None = None,
) -> list[ParsedDocument]:
    """Parse every document named in the corpus manifest."""
    cfg = settings or get_settings()
    diag = diagnostics if diagnostics is not None else ParseDiagnostics()
    parsed: list[ParsedDocument] = []
    for entry in load_manifest(cfg):
        pdf_path = cfg.pdf_dir / entry["filename"]
        if not pdf_path.exists():
            raise ParseError(f"{pdf_path} is missing. Run `make corpus`.")
        document = parse_pdf(
            pdf_path,
            law_id=entry["law_id"],
            law_label=entry["label"],
            law_title=entry["title"],
            diagnostics=diag,
        )
        logger.info(
            "parsed %s: %d articles across %d pages",
            document.law_id,
            len(document.articles),
            document.page_count,
        )
        parsed.append(document)
    return parsed