#!/usr/bin/env python3
"""Extract figures from a (mostly vector) datasheet PDF into per-PDF folders.
Most figures in these Renesas datasheets are vector line art, so `pdfimages`
finds nothing. Instead we:
1. Read the structured text layer (`mutool draw -F stext`) to locate the
"Figure N.
" captions and the surrounding body text.
2. Derive a coarse band for each figure: bounded below by its caption and
above by whichever comes last -- the previous row of captions on the page,
the last body-text/heading line, or the top of the content area.
3. Where a page places figures side by side (performance-graph pages usually
do), split the band horizontally at the midpoints between the captions of
that row, so each figure gets its own column.
4. Render the page and tighten that band to the actual ink bounding box, so
the crop hugs the drawing instead of the column.
4. Write `figure-NN.png` named by the datasheet's own global figure number.
Usage:
extract-figures.py [--dry-run] [--dpi N] [--outdir DIR] file.pdf [file.pdf ...]
"""
from __future__ import annotations
import argparse
import re
import shutil
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
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
# other sit side by side in the same row
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
# 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\d+(?:\.\d+)?)\s*\.\s*" # Figure 12. / Figure 4.1.
r"|"
r"(?P(?:\d+|[A-Z])-\d+)\s*\.?(?:\s+|$)" # Figure 3-8 / Figure A-14
r")(?P.*)$"
)
# 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
@dataclass
class Line:
x0: float
y0: float
x1: float
y1: float
size: float
text: str
bold: bool = False
@dataclass
class Caption:
number: str
title: str
top: float # y0 of the first caption line
bottom: float # y1 of the last wrapped caption line
x0: float # left edge across all wrapped lines
x1: float # right edge across all wrapped lines
@dataclass
class Figure:
number: str
title: str
page: int
top: float
bottom: float
left: float
right: float
name: str = "" # output filename, assigned during extraction
def run(cmd: list[str]) -> str:
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
raise RuntimeError(f"{cmd[0]} failed: {proc.stderr.strip()}")
return proc.stdout
def page_lines(pdf: Path, page: int) -> tuple[float, float, list[Line]]:
xml = run(["mutool", "draw", "-F", "stext", "-o", "-", str(pdf), str(page)])
root = ET.fromstring(xml)
pg = root.find("page")
if pg is None:
return 0.0, 0.0, []
lines: list[Line] = []
for el in pg.iter("line"):
bbox = el.get("bbox")
if not bbox:
continue
x0, y0, x1, y1 = (float(v) for v in bbox.split())
font = el.find("font")
size = float(font.get("size")) if font is not None else 0.0
bold = bool(font is not None and "bold" in (font.get("name") or "").lower())
lines.append(Line(x0, y0, x1, y1, size, el.get("text") or "", bold))
lines.sort(key=lambda l: (l.y0, l.x0))
return float(pg.get("width")), float(pg.get("height")), lines
def find_captions(lines: list[Line]) -> list[Caption]:
"""Locate the "Figure N. " captions on a page.
Captions wrap onto several lines; the bottom of the *last* line is what
bounds the next figure, otherwise the wrapped remainder of this caption
leaks into the top of the following crop. The horizontal extent spans all
the wrapped lines, and is what separates side-by-side figures.
"""
out: list[Caption] = []
for i, line in enumerate(lines):
m = CAPTION_RE.match(line.text)
if not m or line.size > CAPTION_MAX_SIZE or not line.bold:
continue
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.
prev = line
x0, x1 = line.x0, line.x1
for nxt in lines[i + 1:]:
if abs(nxt.size - line.size) > 0.5:
continue
# A caption in the neighbouring column is not a continuation, and
# must not stop the search either.
if nxt.x1 < x0 or nxt.x0 > x1:
continue
if nxt.y0 - prev.y1 > CAPTION_LINE_GAP or nxt.y0 < prev.y1 - 1:
break
if CAPTION_RE.match(nxt.text):
break
title = f"{title} {nxt.text.strip()}".strip()
x0, x1 = min(x0, nxt.x0), max(x1, nxt.x1)
prev = nxt
out.append(Caption(caption_number(m), title, line.y0, prev.y1, x0, x1))
out.sort(key=lambda c: (c.top, c.x0))
return out
def caption_rows(captions: list[Caption]) -> list[list[Caption]]:
"""Group captions that sit side by side into rows, ordered top to bottom."""
rows: list[list[Caption]] = []
for cap in captions:
if rows and abs(cap.top - rows[-1][0].top) <= ROW_TOLERANCE:
rows[-1].append(cap)
else:
rows.append([cap])
for row in rows:
row.sort(key=lambda c: c.x0)
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)
if not captions:
return []
rows = caption_rows(captions)
figures: list[Figure] = []
for r, row in enumerate(rows):
row_top = min(c.top for c in row)
upper = HEADER_BOTTOM
# The previous row of captions bounds this one from above -- use the
# last wrapped line of that row, not its first.
if r > 0:
upper = max(upper, max(c.bottom for c in rows[r - 1]) + BODY_GAP)
# So does the last body-text / heading line above the row.
for line in lines:
if line.y1 >= row_top or line.y1 <= HEADER_BOTTOM:
continue
if line.y0 >= FOOTER_TOP:
continue
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):
continue
upper = line.y1 + BODY_GAP
# Split the row horizontally at the midpoints between its captions.
for i, cap in enumerate(row):
left = 0.0 if i == 0 else (row[i - 1].x1 + cap.x0) / 2.0
right = width if i == len(row) - 1 else (cap.x1 + row[i + 1].x0) / 2.0
figures.append(
Figure(cap.number, cap.title, page, upper, cap.top, left, right)
)
return figures
def figures_on_page_above(pdf: Path, page: int) -> list[Figure]:
"""Same as figures_on_page, but for templates where the caption sits
ABOVE the figure (e.g. USB PD spec) instead of below it (Renesas-style).
"""
width, _, lines = page_lines(pdf, page)
captions = find_captions(lines)
if not captions:
return []
rows = caption_rows(captions)
figures: list[Figure] = []
for r, row in enumerate(rows):
row_bottom = max(c.bottom for c in row)
upper = row_bottom + BODY_GAP
lower = FOOTER_TOP
# The next row of captions bounds this one from below -- use its
# topmost caption line.
if r + 1 < len(rows):
lower = min(lower, min(c.top for c in rows[r + 1]) - BODY_GAP)
# So does the first body-text / heading line below the row.
for line in lines:
if line.y0 <= row_bottom or line.y0 >= lower:
continue
if line.y1 <= HEADER_BOTTOM:
continue
if line.bold and CAPTION_RE.match(line.text):
continue # a real next caption; already bounded via rows[r+1]
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.
for i, cap in enumerate(row):
left = 0.0 if i == 0 else (row[i - 1].x1 + cap.x0) / 2.0
right = width if i == len(row) - 1 else (cap.x1 + row[i + 1].x0) / 2.0
figures.append(
Figure(cap.number, cap.title, page, upper, lower, left, right)
)
return figures
def read_pgm(path: Path) -> tuple[int, int, bytes]:
data = path.read_bytes()
if not data.startswith(b"P5"):
raise ValueError(f"{path} is not a binary PGM")
fields, pos = [], 2
while len(fields) < 3:
while pos < len(data) and data[pos : pos + 1].isspace():
pos += 1
if data[pos : pos + 1] == b"#":
while data[pos : pos + 1] not in (b"\n", b""):
pos += 1
continue
start = pos
while pos < len(data) and not data[pos : pos + 1].isspace():
pos += 1
fields.append(int(data[start:pos]))
return fields[0], fields[1], data[pos + 1 :]
def ink_bbox(
pgm: tuple[int, int, bytes],
top_pt: float,
bottom_pt: float,
scale: float,
left_pt: float = 0.0,
right_pt: float | None = None,
) -> tuple[float, float, float, float] | None:
"""Tightest ink bbox (in points) within the given band."""
w, h, px = pgm
r0 = max(0, int(top_pt * scale))
r1 = min(h, int(bottom_pt * scale) + 1)
c0 = max(0, int(left_pt * scale))
c1 = min(w, int(right_pt * scale) + 1) if right_pt is not None else w
if r1 <= r0 or c1 <= c0:
return None
rows = [r for r in range(r0, r1)
if min(px[r * w + c0 : r * w + c1]) < INK_THRESHOLD]
if not rows:
return None
top_px, bottom_px = rows[0], rows[-1] + 1
span = c1 - c0
col_min = bytearray(b"\xff" * span)
for r in rows:
row = px[r * w + c0 : r * w + c1]
for c in range(span):
if row[c] < col_min[c]:
col_min[c] = row[c]
cols = [c for c in range(span) if col_min[c] < INK_THRESHOLD]
if not cols:
return None
left_px, right_px = c0 + cols[0], c0 + cols[-1] + 1
return (left_px / scale, top_px / scale, right_px / scale, bottom_px / scale)
def crop_region(pdf: Path, outdir: Path, dpi: int, spec: str, dry_run: bool) -> str:
"""Crop an explicitly given region: PAGE:x0,y0,x1,y1:NAME (points).
For figures the datasheet never captioned, so caption detection cannot
find them. The given box is still tightened to the ink inside it.
"""
page_s, box_s, name = spec.split(":", 2)
page = int(page_s)
bx0, by0, bx1, by1 = (float(v) for v in box_s.split(","))
tmp = Path(tempfile.mkdtemp(prefix="figregion-"))
try:
analysis = tmp / "a"
run(["pdftoppm", "-gray", "-r", str(ANALYSIS_DPI), "-f", str(page),
"-l", str(page), "-singlefile", str(pdf), str(analysis)])
pgm = read_pgm(analysis.with_suffix(".pgm"))
box = ink_bbox(pgm, by0, by1, ANALYSIS_DPI / 72.0, bx0, bx1)
if box is None:
return f"SKIP: no ink in region {box_s} on p{page}"
x0, y0, x1, y1 = box
x0, y0 = max(bx0, x0 - PAD), max(by0, y0 - PAD)
x1, y1 = min(bx1, x1 + PAD), min(by1, y1 + PAD)
if not dry_run:
full = tmp / "f"
run(["pdftoppm", "-png", "-r", str(dpi), "-f", str(page),
"-l", str(page), "-singlefile", str(pdf), str(full)])
s = dpi / 72.0
geom = (f"{round((x1 - x0) * s)}x{round((y1 - y0) * s)}"
f"+{round(x0 * s)}+{round(y0 * s)}")
outdir.mkdir(parents=True, exist_ok=True)
run(["magick", str(full.with_suffix(".png")), "-crop", geom,
"+repage", str(outdir / name)])
return (f"p{page} region -> ink {x0:.0f},{y0:.0f} "
f"{x1 - x0:.0f}x{y1 - y0:.0f}pt")
finally:
shutil.rmtree(tmp, ignore_errors=True)
def extract(pdf: Path, outdir: Path, dpi: int, dry_run: bool,
caption_position: str = "below") -> list[tuple[Figure, str]]:
finder = figures_on_page_above if caption_position == "above" else figures_on_page
pages = int(
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):
figs = finder(pdf, page)
if not figs:
continue
analysis = tmp / f"a{page}"
run(["pdftoppm", "-gray", "-r", str(ANALYSIS_DPI), "-f", str(page),
"-l", str(page), "-singlefile", str(pdf), str(analysis)])
pgm = read_pgm(analysis.with_suffix(".pgm"))
scale = ANALYSIS_DPI / 72.0
full = tmp / f"f{page}"
if not dry_run:
run(["pdftoppm", "-png", "-r", str(dpi), "-f", str(page),
"-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:
results.append((fig, "SKIP: no ink found in band"))
continue
x0, y0, x1, y1 = box
# Pad, but never past the band edges: the caption sits just
# below `fig.bottom` and body text just above `fig.top`.
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 = 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")
if not dry_run:
s = dpi / 72.0
geom = (f"{round((x1 - x0) * s)}x{round((y1 - y0) * s)}"
f"+{round(x0 * s)}+{round(y0 * s)}")
outdir.mkdir(parents=True, exist_ok=True)
run(["magick", str(full.with_suffix(".png")), "-crop", geom,
"+repage", str(outdir / name)])
results.append((fig, note))
finally:
shutil.rmtree(tmp, ignore_errors=True)
return results
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)
ap.add_argument("--dpi", type=int, default=300, help="output render DPI")
ap.add_argument("--outdir", type=Path, default=None,
help="output root (default: alongside each PDF)")
ap.add_argument("--dry-run", action="store_true",
help="report detected figures without writing images")
ap.add_argument("--region", action="append", default=[],
metavar="PAGE:x0,y0,x1,y1:NAME",
help="also crop an explicit region (points) from the single "
"given PDF; for figures with no 'Figure N.' caption")
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")
for pdf in args.pdfs:
root = args.outdir if args.outdir else pdf.parent
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" {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)
print(f" {spec.split(':', 2)[2]} {note}")
return 0
if __name__ == "__main__":
sys.exit(main())