ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
pinout_parse.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Recover the RA8 pin-list tables from the datasheet PDFs.
4
5``pdftotext -layout`` renders each printed page as fixed-width text, and
6within a single page every table cell is left-aligned on its column. That is
7the only layout fact this module relies on: column origins are read back as
8the modal tuple of word-start offsets across a page's data rows, so nothing
9here carries a hardcoded offset that a document revision could silently
10invalidate. A page whose rows do not agree on one tuple raises rather than
11producing a mangled table.
12
13Two independent renderings of the same fact are read:
14
15* section 1.7's pin-list TABLES, which carry the full alternate-function set
16 per ball and are the parse target, and
17* section 1.6's ball-grid FIGURES, whose per-variant port-pin sets
18 ``gen_pinouts.py`` diffs against the tables as a cross-check.
19
20Used by ``gen_pinouts.py``; see that file for the whole pipeline.
21"""
22
23from __future__ import annotations
24
25import collections
26import re
27import shutil
28import subprocess
29import sys
30from collections.abc import Iterator
31from pathlib import Path
32
33sys.path.insert(0, str(Path(__file__).resolve().parent))
34from pinout_model import (
35 BALL_COLUMNS,
36 BALL_RE,
37 DASH,
38 FIELDS,
39 FIGURE_RE,
40 FIGURE_VARIANTS,
41 PORT_RE,
42 ROW_START_RE,
43 TABLE_RE,
44 Group,
45 ParseError,
46 Part,
47 decode_part,
48)
49
50MIN_LAYOUT_VOTES = 2
51
52
53def pdf_text(pdf: Path) -> str:
54 """Return the whole PDF as layout-preserving text."""
55 pdftotext = shutil.which("pdftotext")
56 if pdftotext is None:
57 msg = (
58 "pdftotext not found. Install poppler "
59 "(apt-get install poppler-utils / brew install poppler)."
60 )
61 raise ParseError(msg)
62 if not pdf.is_file():
63 msg = f"datasheet not found: {pdf}"
64 raise ParseError(msg)
65 proc = subprocess.run( # noqa: S603 -- executable resolved by shutil.which
66 [pdftotext, "-layout", str(pdf), "-"],
67 capture_output=True,
68 text=True,
69 check=True,
70 )
71 return proc.stdout
72
73
74def split_table_blocks(text: str) -> Iterator[tuple[str, str, int, int, list[str]]]:
75 """Yield ``(table_no, kind, part, total, lines)`` per printed table page.
76
77 Each printed page repeats the table caption, so a block is exactly one
78 page of one table -- which is the unit over which ``pdftotext -layout``
79 keeps column offsets constant.
80 """
81 for page in text.split("\f"):
82 current = None
83 lines: list[str] = []
84 for line in page.split("\n"):
85 match = TABLE_RE.match(line.strip())
86 if match:
87 if current is not None:
88 yield (*current, lines)
89 current = (
90 match.group(1),
91 match.group(2).lower(),
92 int(match.group(3)),
93 int(match.group(4)),
94 )
95 lines = []
96 elif current is not None:
97 lines.append(line)
98 if current is not None:
99 yield (*current, lines)
100
101
102def column_origins(rows: list[str], n_cols: int) -> tuple[int, ...]:
103 """Recover the column origins of one printed page from its data rows.
104
105 Every cell is left-aligned on its column and no cell value contains a
106 space, so a row that is not wrapped contributes exactly ``n_cols``
107 word-start offsets -- the column origins. Taking the modal tuple
108 tolerates the wrapped rows without trusting any single row.
109 """
110 votes = collections.Counter(tuple(m.start() for m in re.finditer(r"\S+", row)) for row in rows)
111 for origins, count in votes.most_common():
112 if len(origins) == n_cols:
113 if count < MIN_LAYOUT_VOTES:
114 msg = f"only {count} row(s) agree on a {n_cols}-column layout"
115 raise ParseError(msg)
116 return origins
117 msg = f"no row on this page has {n_cols} columns (saw widths {sorted({len(o) for o in votes})})"
118 raise ParseError(msg)
119
120
121def slice_row(line: str, origins: tuple[int, ...]) -> list[str]:
122 """Split one physical line into cells at the given column origins."""
123 bounds = [*list(origins), len(line) + 1]
124 return [line[bounds[i] : bounds[i + 1]].strip() for i in range(len(origins))]
125
126
127def aligned_continuation(line: str, origins: tuple[int, ...]) -> bool:
128 """True if every word on ``line`` starts on a column origin.
129
130 This is what separates a wrapped cell from the running page header and
131 the page footer, which are never column-aligned.
132 """
133 starts = [m.start() for m in re.finditer(r"\S+", line)]
134 return bool(starts) and all(s in origins for s in starts)
135
136
137def parse_block(lines: list[str], n_ball_cols: int) -> list[list[str]]:
138 """Parse one printed page into fully joined logical rows."""
139 n_cols = n_ball_cols + len(FIELDS)
140 data = [line for line in lines if ROW_START_RE.match(line)]
141 if not data:
142 return []
143 origins = column_origins(data, n_cols)
144
145 rows: list[list[str]] = []
146 for line in lines:
147 if ROW_START_RE.match(line):
148 cells = slice_row(line, origins)
149 if len(cells) != n_cols:
150 msg = f"row split into {len(cells)} cells: {line!r}"
151 raise ParseError(msg)
152 rows.append(cells)
153 elif rows and aligned_continuation(line, origins):
154 # A wrapped cell. pdftotext breaks mid-token, so the fragments
155 # concatenate with no separator.
156 for i, frag in enumerate(slice_row(line, origins)):
157 rows[-1][i] += frag
158 return rows
159
160
161def parse_pin_list(text: str, group: Group, kind: str) -> list[dict]:
162 """Parse one whole pin-list table into logical rows."""
163 ball_cols = BALL_COLUMNS[kind]
164 want = group.std_table if kind == "standard" else group.sip_table
165
166 seen_parts: set[int] = set()
167 total_parts = None
168 rows: list[list[str]] = []
169 for table_no, block_kind, part, total, lines in split_table_blocks(text):
170 if block_kind != kind:
171 continue
172 if table_no != want:
173 msg = (
174 f"{group.name}: expected the {kind} pin list to be Table "
175 f"{want}, found Table {table_no}"
176 )
177 raise ParseError(msg)
178 seen_parts.add(part)
179 total_parts = total
180 rows.extend(parse_block(lines, len(ball_cols)))
181
182 if total_parts is None:
183 msg = f"{group.name}: no {kind} pin list found"
184 raise ParseError(msg)
185 missing = set(range(1, total_parts + 1)) - seen_parts
186 if missing:
187 msg = f"{group.name} {kind} pin list: missing page(s) {sorted(missing)} of {total_parts}"
188 raise ParseError(msg)
189
190 out = []
191 for cells in rows:
192 balls = [c if c != DASH else None for c in cells[: len(ball_cols)]]
193 for ball in balls:
194 if ball is not None and not BALL_RE.match(ball):
195 msg = f"not a ball coordinate: {ball!r}"
196 raise ParseError(msg)
197 entry = {"balls": dict(zip(ball_cols, balls, strict=False))}
198 for name, cell in zip(FIELDS, cells[len(ball_cols) :], strict=False):
199 entry[name] = "" if cell == DASH else cell
200 out.append(entry)
201 return out
202
203
204def figure_port_sets(text: str) -> dict:
205 """Read the port pins off section 1.6's ball-grid figures.
206
207 Section 1.6 and section 1.7 are two independent renderings of the same
208 fact, so agreeing with the figures is evidence the table parse is right
209 rather than merely self-consistent. The figures' cells wrap and stack
210 unpredictably, which is why the pin lists are the parse target and this
211 is only a cross-check -- the SET of port pins per variant survives the
212 figures' messy layout, and it is exactly the quantity a mis-sliced
213 column would corrupt.
214 """
215 lines = text.replace("\f", "\n").split("\n")
216 captions = []
217 for i, line in enumerate(lines):
218 match = FIGURE_RE.match(line)
219 if match:
220 captions.append((i, match.group(1)))
221 if len(captions) != len(FIGURE_VARIANTS):
222 msg = f"expected {len(FIGURE_VARIANTS)} pin-assignment figures, found {len(captions)}"
223 raise ParseError(msg)
224
225 out = {}
226 for index, (end, label) in enumerate(captions):
227 if label not in FIGURE_VARIANTS:
228 msg = f"unknown pin-assignment figure: {label!r}"
229 raise ParseError(msg)
230 start = captions[index - 1][0] + 1 if index else 0
231 ports = set(PORT_RE.findall("\n".join(lines[start:end])))
232 out[FIGURE_VARIANTS[label]] = ports
233 return out
234
235
236def extract_parts(text: str, group: Group) -> list[Part]:
237 """Pull the product list for one group out of its datasheet."""
238 numbers = []
239 for token in re.findall(r"\bR7[KJ]A8(?:D2|P1)[A-Z]{4}A[BCJ]\b", text):
240 if token not in numbers:
241 numbers.append(token)
242 parts = [
243 decode_part(n)
244 for n in numbers
245 if n.startswith((f"R7KA8{group.name[3:]}", f"R7JA8{group.name[3:]}"))
246 ]
247 if not parts:
248 msg = f"{group.name}: no part numbers found"
249 raise ParseError(msg)
250 return parts