add USB Type-C Spec R2.5

This commit is contained in:
0m.ax 2026-09-27 02:22:31 +02:00
parent a1c36ccc90
commit 7956e6ca25
281 changed files with 13432 additions and 7 deletions

View file

@ -33,10 +33,19 @@ from dataclasses import dataclass
from pathlib import Path
# --- page geometry, in PDF points (origin top-left, y increasing downward) ---
# The first five are template-specific; each has a command-line override of the
# same name (--header-bottom, --footer-top, ...) so a different vendor template
# can be tuned without editing the defaults.
HEADER_BOTTOM = 62.0 # running head + its horizontal rule (ends ~60pt)
FOOTER_TOP = 740.0 # doc id / revision / page number sit below this
BODY_MIN_SIZE = 9.5 # body text and section headings are >= 10pt
BODY_MAX_X0 = 60.0 # ...and start at the left margin (~47.5pt)
BODY_MIN_WIDTH = float("inf") # ...or, for indented prose (bullets, numbered
# lists), are at least this wide. Off by default; set
# it between the widest *in-figure* label and the
# narrowest full line of prose. A right-edge test is
# not enough: a right-aligned figure label also ends
# at the right margin.
CAPTION_MAX_SIZE = 13.0 # captions are bold, 9-12pt depending on template
CAPTION_LINE_GAP = 5.0 # max vertical gap between wrapped caption lines
ROW_TOLERANCE = 6.0 # captions starting within this many points of each
@ -45,7 +54,30 @@ BODY_GAP = 5.0 # clearance below body text; stext line bboxes clip
# descenders, which would otherwise leak into a crop
PAD = 6.0 # padding around the ink bbox, in points
CAPTION_RE = re.compile(r"^\s*Figure\s+(\d+(?:\.\d+)?)\s*\.\s*(.*)$")
# Two printed caption conventions are supported:
# "Figure 12. Title" / "Figure 4.1. Title" -- number, period, title
# "Figure 3-8 Title" / "Figure A-14 Title" -- chapter/appendix-hyphen-number,
# no period (USB Type-C spec)
# Group "num" is the figure number as printed, and is used verbatim in the
# output filename ("figure-4.1.png", "figure-3-8.png", "figure-A-14.png").
CAPTION_RE = re.compile(
r"^\s*Figure\s+(?:"
r"(?P<dotted>\d+(?:\.\d+)?)\s*\.\s*" # Figure 12. / Figure 4.1.
r"|"
r"(?P<hyphen>(?:\d+|[A-Z])-\d+)\s*\.?(?:\s+|$)" # Figure 3-8 / Figure A-14
r")(?P<title>.*)$"
)
# A bold "Table N ..." caption is body furniture, not figure ink, and bounds a
# figure band exactly like a body-text line does. Same two number conventions.
TABLE_CAPTION_RE = re.compile(
r"^\s*Table\s+(?:\d+(?:\.\d+)?\s*\.|(?:\d+|[A-Z])-\d+)(?:\s|$)"
)
def caption_number(m: re.Match) -> str:
return m.group("dotted") or m.group("hyphen")
INK_THRESHOLD = 200 # 0-255 grey; anything darker counts as ink
ANALYSIS_DPI = 100 # cheap render used only for ink detection
@ -81,6 +113,7 @@ class Figure:
bottom: float
left: float
right: float
name: str = "" # output filename, assigned during extraction
def run(cmd: list[str]) -> str:
@ -123,7 +156,7 @@ def find_captions(lines: list[Line]) -> list[Caption]:
m = CAPTION_RE.match(line.text)
if not m or line.size > CAPTION_MAX_SIZE or not line.bold:
continue
title = m.group(2).strip()
title = m.group("title").strip()
# Absorb continuation lines: same size, directly below, horizontally
# overlapping (a side-by-side caption is not a continuation), and not
# itself a new caption.
@ -143,7 +176,7 @@ def find_captions(lines: list[Line]) -> list[Caption]:
title = f"{title} {nxt.text.strip()}".strip()
x0, x1 = min(x0, nxt.x0), max(x1, nxt.x1)
prev = nxt
out.append(Caption(m.group(1), title, line.y0, prev.y1, x0, x1))
out.append(Caption(caption_number(m), title, line.y0, prev.y1, x0, x1))
out.sort(key=lambda c: (c.top, c.x0))
return out
@ -161,6 +194,25 @@ def caption_rows(captions: list[Caption]) -> list[list[Caption]]:
return rows
def is_body_line(line: Line) -> bool:
"""Body text / heading / table caption, i.e. page furniture that bounds a
figure band -- as opposed to a label drawn inside the figure itself.
Three shapes count:
* a line at the left margin (plain paragraphs, section headings);
* an indented but full-width line (bullets and numbered lists) -- only
recognised once --body-min-width is set, because the cut-off has to sit
above the widest label inside any figure. A right-edge test would not
do: a right-aligned label inside a figure also ends at the margin;
* a bold "Table N ..." caption, which is often centred and short.
"""
if line.size < BODY_MIN_SIZE:
return False
if line.x0 <= BODY_MAX_X0 or line.x1 - line.x0 >= BODY_MIN_WIDTH:
return True
return bool(line.bold and TABLE_CAPTION_RE.match(line.text))
def figures_on_page(pdf: Path, page: int) -> list[Figure]:
width, _, lines = page_lines(pdf, page)
captions = find_captions(lines)
@ -182,7 +234,7 @@ def figures_on_page(pdf: Path, page: int) -> list[Figure]:
continue
if line.y0 >= FOOTER_TOP:
continue
is_body = line.size >= BODY_MIN_SIZE and line.x0 <= BODY_MAX_X0
is_body = is_body_line(line)
if is_body and line.y1 + BODY_GAP > upper:
# Do not step over an earlier row's figures.
if any(upper < c.top < line.y1 for pr in rows[:r] for c in pr):
@ -225,7 +277,7 @@ def figures_on_page_above(pdf: Path, page: int) -> list[Figure]:
continue
if line.bold and CAPTION_RE.match(line.text):
continue # a real next caption; already bounded via rows[r+1]
is_body = line.size >= BODY_MIN_SIZE and line.x0 <= BODY_MAX_X0
is_body = is_body_line(line)
if is_body and line.y0 - BODY_GAP < lower:
lower = line.y0 - BODY_GAP
# Split the row horizontally at the midpoints between its captions.
@ -339,6 +391,10 @@ def extract(pdf: Path, outdir: Path, dpi: int, dry_run: bool,
re.search(r"Pages:\s+(\d+)", run(["pdfinfo", str(pdf)])).group(1)
)
results: list[tuple[Figure, str]] = []
# Multi-page drawings repeat the same caption verbatim (with ", cont."
# appended) on every sheet. Keep them all: the first sheet keeps the plain
# name, later ones get -cont1, -cont2, ... in page order.
seen: dict[str, int] = {}
tmp = Path(tempfile.mkdtemp(prefix="figextract-"))
try:
for page in range(1, pages + 1):
@ -358,6 +414,9 @@ def extract(pdf: Path, outdir: Path, dpi: int, dry_run: bool,
"-l", str(page), "-singlefile", str(pdf), str(full)])
for fig in figs:
n = seen.get(fig.number, 0)
seen[fig.number] = n + 1
fig.name = f"figure-{fig.number}{'' if n == 0 else f'-cont{n}'}.png"
box = ink_bbox(pgm, fig.top, fig.bottom, scale,
fig.left, fig.right)
if box is None:
@ -369,7 +428,7 @@ def extract(pdf: Path, outdir: Path, dpi: int, dry_run: bool,
x0, x1 = max(fig.left, x0 - PAD), min(fig.right, x1 + PAD)
y0 = max(y0 - PAD, fig.top)
y1 = min(y1 + PAD, fig.bottom - 2.0)
name = f"figure-{fig.number}.png"
name = fig.name
note = (f"p{fig.page} band {fig.top:.0f}-{fig.bottom:.0f}pt "
f"x {fig.left:.0f}-{fig.right:.0f}pt "
f"-> ink {x0:.0f},{y0:.0f} {x1 - x0:.0f}x{y1 - y0:.0f}pt")
@ -387,6 +446,7 @@ def extract(pdf: Path, outdir: Path, dpi: int, dry_run: bool,
def main() -> int:
global HEADER_BOTTOM, FOOTER_TOP, BODY_MAX_X0, BODY_MIN_WIDTH, BODY_GAP, PAD
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("pdfs", nargs="+", type=Path)
@ -402,8 +462,37 @@ def main() -> int:
ap.add_argument("--caption-position", choices=["below", "above"], default="below",
help="whether 'Figure N. Title' sits below the figure "
"(Renesas-style, default) or above it (e.g. USB PD spec)")
# Page-layout tuning. Defaults match the Renesas template; override rather
# than edit for a different vendor's page furniture.
ap.add_argument("--header-bottom", type=float, default=HEADER_BOTTOM,
metavar="PT", help=f"y below the running head "
f"(default {HEADER_BOTTOM:g})")
ap.add_argument("--footer-top", type=float, default=FOOTER_TOP,
metavar="PT", help=f"y above the page footer "
f"(default {FOOTER_TOP:g})")
ap.add_argument("--body-max-x0", type=float, default=BODY_MAX_X0,
metavar="PT", help="max left edge of a body-text/heading "
f"line (default {BODY_MAX_X0:g}; the USB "
"Type-C template indents to 72)")
ap.add_argument("--body-min-width", type=float, default=BODY_MIN_WIDTH,
metavar="PT", help="treat an indented line (bullet, numbered "
"list) as body text when it is at least "
"this wide (default: off)")
ap.add_argument("--body-gap", type=float, default=BODY_GAP,
metavar="PT", help=f"clearance around body text "
f"(default {BODY_GAP:g})")
ap.add_argument("--pad", type=float, default=PAD,
metavar="PT", help=f"padding around the ink bbox "
f"(default {PAD:g})")
args = ap.parse_args()
HEADER_BOTTOM = args.header_bottom
FOOTER_TOP = args.footer_top
BODY_MAX_X0 = args.body_max_x0
BODY_MIN_WIDTH = args.body_min_width
BODY_GAP = args.body_gap
PAD = args.pad
if args.region and len(args.pdfs) != 1:
ap.error("--region applies to exactly one PDF")
@ -412,7 +501,7 @@ def main() -> int:
outdir = root / pdf.stem
print(f"\n=== {pdf.name} -> {outdir}/")
for fig, note in extract(pdf, outdir, args.dpi, args.dry_run, args.caption_position):
print(f" figure-{fig.number} {note}")
print(f" {fig.name[:-4]} {note}")
print(f" {fig.title[:88]}")
for spec in args.region:
note = crop_region(pdf, outdir, args.dpi, spec, args.dry_run)