File size: 5,767 Bytes
5043c6f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Re-extraction layout-fidele du manuel PCP, avec correction des glyphes Symbol.

Pourquoi : l'extraction v1 (get_text("text") + normalisation ASCII) perdait les
delimiteurs des boites de syntaxe. Diagnostic 2026-06-10 : ces delimiteurs sont
des glyphes de la police Symbol que PyMuPDF restitue en codepoints prives
(U+F000 + code Symbol) ; le normaliseur les supprimait ensuite. Les < > = [ ]
et les grandes accolades (alternatives empilees) sont donc RECUPERABLES
deterministiquement en mappant ces codes vers leur equivalent texte.

Sortie : pcp/data/text_layout/<doc>.txt — texte aligne en colonnes (style
pdftotext -layout) avec les bons caracteres, marqueurs [[page N | label]].
Sert de source de verite pour la reparation des champs syntax du catalogue
et la construction de la KB (docs/PLAN.md section 4).

Usage : python scripts/extract_layout.py [--doc chp02a.langage] [--src DIR]
"""
from __future__ import annotations

import argparse
import sys
from pathlib import Path

import fitz  # PyMuPDF

ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SRC = ROOT.parent / "Documentation"
OUT_DIR = ROOT / "pcp" / "data" / "text_layout"

# Codes Adobe Symbol -> texte. Les pieces d'accolades/crochets multi-lignes
# (alternatives empilees des boites de syntaxe) sont rabattues sur { | } [ ] ( )
# ligne par ligne : une pile verticale d'accolades devient une colonne de { et }.
SYMBOL_MAP = {
    0x22: "∀", 0x24: "∃", 0x2A: "*", 0x2D: "-", 0x40: "≅",
    0xA3: "<=", 0xB3: ">=", 0xB1: "+/-", 0xB9: "!=", 0xB4: "x", 0xB8: "/",
    0xD6: "√", 0xA5: "inf", 0xAE: "->", 0xAC: "<-",
    # lettres grecques (minuscules puis majuscules usuelles)
    0x61: "α", 0x62: "β", 0x67: "γ", 0x64: "δ",
    0x65: "ε", 0x71: "θ", 0x6C: "λ", 0x6D: "μ",
    0x6E: "ν", 0x70: "π", 0x72: "ρ", 0x73: "σ",
    0x74: "τ", 0x66: "φ", 0x77: "ω", 0x68: "η",
    0x44: "Δ", 0x46: "Φ", 0x47: "Γ", 0x4C: "Λ",
    0x50: "Π", 0x51: "Θ", 0x53: "Σ", 0x57: "Ω",
    # pieces de parentheses / crochets / accolades multi-lignes
    0xE6: "(", 0xE7: "(", 0xE8: "(", 0xF6: ")", 0xF7: ")", 0xF8: ")",
    0xE9: "[", 0xEA: "[", 0xEB: "[", 0xF9: "]", 0xFA: "]", 0xFB: "]",
    0xEC: "{", 0xED: "{", 0xEE: "{", 0xEF: "|",
    0xFC: "}", 0xFD: "}", 0xFE: "}",
}

DINGBAT_FONTS = ("Wingdings", "ZapfDingbats", "MT-Extra")

# Largeur de colonne pour l'alignement (pt). Le corps du manuel est en 10-11 pt
# Times/Courier ; 5.2 pt par caractere donne un alignement stable sans etaler.
CHAR_W = 5.2
LINE_TOL = 3.0  # tolerance (pt) de regroupement vertical des spans en lignes


def _decode_pua(ch: str, font: str) -> str:
    code = ord(ch) - 0xF000
    if any(d in font for d in DINGBAT_FONTS):
        return "-"  # puces
    if "Symbol" in font and code in SYMBOL_MAP:
        return SYMBOL_MAP[code]
    if 0x20 <= code <= 0x7E:  # codes a glyphe quasi identique a l'ASCII
        return chr(code)
    return "?"


def _span_text(span: dict) -> str:
    font = span.get("font", "")
    out = []
    for ch in span["text"]:
        o = ord(ch)
        if 0xF000 <= o <= 0xF0FF:
            out.append(_decode_pua(ch, font))
        else:
            out.append(ch)
    return "".join(out)


def _page_lines(page: fitz.Page) -> list[str]:
    """Assemble les spans en lignes alignees en colonnes."""
    spans = []
    for block in page.get_text("dict")["blocks"]:
        for line in block.get("lines", []):
            for s in line["spans"]:
                txt = _span_text(s)
                if txt.strip() or txt:
                    x0, y0, x1, y1 = s["bbox"]
                    spans.append(((y0 + y1) / 2.0, x0, txt))
    if not spans:
        return []
    spans.sort(key=lambda t: (t[0], t[1]))
    # regroupement vertical
    rows: list[list[tuple[float, str]]] = []
    cur_y = None
    for yc, x0, txt in spans:
        if cur_y is None or yc - cur_y > LINE_TOL:
            rows.append([])
            cur_y = yc
        rows[-1].append((x0, txt))
    # placement en colonnes
    lines = []
    for row in rows:
        row.sort()
        buf = ""
        for x0, txt in row:
            col = int(round(x0 / CHAR_W))
            if col > len(buf):
                buf += " " * (col - len(buf))
            elif buf and not buf.endswith(" "):
                buf += " "
            buf += txt
        lines.append(buf.rstrip())
    return lines


def extract(pdf_path: Path, out_dir: Path) -> Path:
    doc = fitz.open(pdf_path)
    parts = []
    for pno in range(doc.page_count):
        page = doc[pno]
        lines = _page_lines(page)
        # libelle de page : derniere ligne courte du type "2-5"
        label = ""
        for ln in reversed(lines[-3:]):
            s = ln.strip()
            if 0 < len(s) <= 8 and "-" in s:
                label = s
                break
        parts.append(f"[[page {pno + 1} | {label}]]")
        parts.extend(lines)
        parts.append("")
    out = out_dir / (pdf_path.stem + ".txt")
    out.write_text("\n".join(parts), encoding="utf-8")
    return out


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--src", type=Path, default=DEFAULT_SRC)
    ap.add_argument("--doc", help="ne traiter que ce document (stem)")
    args = ap.parse_args()
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    pdfs = sorted(args.src.glob("*.pdf"))
    if args.doc:
        pdfs = [p for p in pdfs if p.stem == args.doc]
    if not pdfs:
        print(f"aucun PDF dans {args.src}", file=sys.stderr)
        return 1
    for pdf in pdfs:
        out = extract(pdf, OUT_DIR)
        n_lt = out.read_text(encoding="utf-8").count("<")
        print(f"{pdf.stem:40s} -> {out.name} ({n_lt} '<')")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())