3"""Recover the RA8 pin-list tables from the datasheet PDFs.
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.
13Two independent renderings of the same fact are read:
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.
20Used by ``gen_pinouts.py``; see that file for the whole pipeline.
23from __future__
import annotations
30from collections.abc
import Iterator
31from pathlib
import Path
33sys.path.insert(0, str(Path(__file__).resolve().parent))
34from pinout_model
import (
53def pdf_text(pdf: Path) -> str:
54 """Return the whole PDF as layout-preserving text."""
55 pdftotext = shutil.which(
"pdftotext")
58 "pdftotext not found. Install poppler "
59 "(apt-get install poppler-utils / brew install poppler)."
63 msg = f
"datasheet not found: {pdf}"
65 proc = subprocess.run(
66 [pdftotext,
"-layout", str(pdf),
"-"],
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.
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.
81 for page
in text.split(
"\f"):
84 for line
in page.split(
"\n"):
85 match = TABLE_RE.match(line.strip())
87 if current
is not None:
88 yield (*current, lines)
91 match.group(2).lower(),
96 elif current
is not None:
98 if current
is not None:
99 yield (*current, lines)
102def column_origins(rows: list[str], n_cols: int) -> tuple[int, ...]:
103 """Recover the column origins of one printed page from its data rows.
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.
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)
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)
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))]
127def aligned_continuation(line: str, origins: tuple[int, ...]) -> bool:
128 """True if every word on ``line`` starts on a column origin.
130 This is what separates a wrapped cell from the running page header and
131 the page footer, which are never column-aligned.
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)
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)]
143 origins = column_origins(data, n_cols)
145 rows: list[list[str]] = []
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)
153 elif rows
and aligned_continuation(line, origins):
156 for i, frag
in enumerate(slice_row(line, origins)):
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
166 seen_parts: set[int] = set()
168 rows: list[list[str]] = []
169 for table_no, block_kind, part, total, lines
in split_table_blocks(text):
170 if block_kind != kind:
174 f
"{group.name}: expected the {kind} pin list to be Table "
175 f
"{want}, found Table {table_no}"
177 raise ParseError(msg)
180 rows.extend(parse_block(lines, len(ball_cols)))
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
187 msg = f
"{group.name} {kind} pin list: missing page(s) {sorted(missing)} of {total_parts}"
188 raise ParseError(msg)
192 balls = [c
if c != DASH
else None for c
in cells[: len(ball_cols)]]
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
204def figure_port_sets(text: str) -> dict:
205 """Read the port pins off section 1.6's ball-grid figures.
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.
215 lines = text.replace(
"\f",
"\n").split(
"\n")
217 for i, line
in enumerate(lines):
218 match = FIGURE_RE.match(line)
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)
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
236def extract_parts(text: str, group: Group) -> list[Part]:
237 """Pull the product list for one group out of its datasheet."""
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)
245 if n.startswith((f
"R7KA8{group.name[3:]}", f
"R7JA8{group.name[3:]}"))
248 msg = f
"{group.name}: no part numbers found"
249 raise ParseError(msg)