408 lines
13 KiB
Python
408 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import zipfile
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from _pptx_common import (
|
|
OOXML_PRESENTATION_SUFFIXES,
|
|
SkillArgumentParser,
|
|
input_file,
|
|
inspect_archive,
|
|
parse_xml_bytes,
|
|
run_cli,
|
|
)
|
|
|
|
|
|
EMU_PER_INCH = 914400
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = SkillArgumentParser(description="分段检查演示文稿的结构、文本和媒体。")
|
|
parser.add_argument("--input", required=True)
|
|
parser.add_argument("--start-slide", type=int, default=1)
|
|
parser.add_argument("--max-slides", type=int, default=30)
|
|
parser.add_argument("--max-shapes", type=int, default=200)
|
|
parser.add_argument("--max-table-cells", type=int, default=500)
|
|
parser.add_argument("--max-chars", type=int, default=100000)
|
|
parser.add_argument("--include-runs", action="store_true")
|
|
return parser
|
|
|
|
|
|
def _inches(value: Any) -> Optional[float]:
|
|
try:
|
|
return round(int(value) / EMU_PER_INCH, 4)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _iter_shapes(shapes: Any):
|
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
|
|
for shape in shapes:
|
|
yield shape
|
|
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
|
|
yield from _iter_shapes(shape.shapes)
|
|
|
|
|
|
def _shape_text_char_count(shape: Any) -> int:
|
|
texts: list[str] = []
|
|
if getattr(shape, "has_text_frame", False):
|
|
texts.append(shape.text_frame.text)
|
|
if getattr(shape, "has_table", False):
|
|
for row in shape.table.rows:
|
|
texts.extend(cell.text for cell in row.cells)
|
|
return sum(
|
|
1
|
|
for text in texts
|
|
for character in text
|
|
if character.isalnum()
|
|
)
|
|
|
|
|
|
def _contains_picture(shape: Any) -> bool:
|
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
|
|
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
|
return True
|
|
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
|
|
return any(_contains_picture(child) for child in shape.shapes)
|
|
return False
|
|
|
|
|
|
def _slide_media_profile(
|
|
slide: Any,
|
|
slide_width: int,
|
|
slide_height: int,
|
|
) -> dict[str, Any]:
|
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
|
|
shapes = list(_iter_shapes(slide.shapes))
|
|
image_count = sum(
|
|
1 for shape in shapes
|
|
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE
|
|
)
|
|
chart_count = sum(
|
|
1 for shape in shapes
|
|
if getattr(shape, "has_chart", False)
|
|
)
|
|
native_text_char_count = sum(
|
|
_shape_text_char_count(shape) for shape in shapes
|
|
)
|
|
image_area = 0.0
|
|
slide_area = slide_width * slide_height
|
|
if slide_area > 0:
|
|
for shape in slide.shapes:
|
|
if not _contains_picture(shape):
|
|
continue
|
|
try:
|
|
raw_left = int(shape.left)
|
|
raw_top = int(shape.top)
|
|
left = max(0, raw_left)
|
|
top = max(0, raw_top)
|
|
right = min(slide_width, raw_left + int(shape.width))
|
|
bottom = min(slide_height, raw_top + int(shape.height))
|
|
except (AttributeError, TypeError, ValueError):
|
|
continue
|
|
if right > left and bottom > top:
|
|
image_area += (right - left) * (bottom - top) / slide_area
|
|
return {
|
|
"image_count": image_count,
|
|
"chart_count": chart_count,
|
|
"image_area_ratio": round(min(1.0, image_area), 4),
|
|
"native_text_char_count": native_text_char_count,
|
|
"has_images": image_count > 0,
|
|
}
|
|
|
|
|
|
def _run_payload(run: Any) -> dict[str, Any]:
|
|
font = run.font
|
|
hyperlink = None
|
|
try:
|
|
hyperlink = run.hyperlink.address
|
|
except (AttributeError, KeyError, ValueError):
|
|
pass
|
|
return {
|
|
"text": run.text,
|
|
"bold": font.bold,
|
|
"italic": font.italic,
|
|
"underline": font.underline,
|
|
"font_name": font.name,
|
|
"font_size_pt": round(font.size.pt, 2) if font.size is not None else None,
|
|
"hyperlink": hyperlink,
|
|
}
|
|
|
|
|
|
def _paragraph_payload(paragraph: Any, *, include_runs: bool) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {
|
|
"text": paragraph.text,
|
|
"level": paragraph.level,
|
|
"alignment": str(paragraph.alignment) if paragraph.alignment else None,
|
|
}
|
|
if include_runs:
|
|
payload["runs"] = [_run_payload(run) for run in paragraph.runs]
|
|
return payload
|
|
|
|
|
|
def _text_frame_payload(text_frame: Any, *, include_runs: bool) -> dict[str, Any]:
|
|
return {
|
|
"text": text_frame.text,
|
|
"paragraphs": [
|
|
_paragraph_payload(paragraph, include_runs=include_runs)
|
|
for paragraph in text_frame.paragraphs
|
|
],
|
|
}
|
|
|
|
|
|
def _chart_payload(shape: Any, *, max_points: int = 100) -> dict[str, Any]:
|
|
chart = shape.chart
|
|
series_payload: list[dict[str, Any]] = []
|
|
for series in list(chart.series)[:50]:
|
|
values: list[Any] = []
|
|
try:
|
|
values = list(series.values)[:max_points]
|
|
except (AttributeError, TypeError, ValueError):
|
|
pass
|
|
series_payload.append(
|
|
{
|
|
"name": getattr(series, "name", None),
|
|
"point_count": len(values),
|
|
"values": values,
|
|
}
|
|
)
|
|
categories: list[str] = []
|
|
try:
|
|
categories = [str(item.label) for item in chart.plots[0].categories][:max_points]
|
|
except (AttributeError, IndexError, TypeError, ValueError):
|
|
pass
|
|
return {
|
|
"chart_type": str(chart.chart_type),
|
|
"has_title": chart.has_title,
|
|
"series": series_payload,
|
|
"categories": categories,
|
|
}
|
|
|
|
|
|
def _shape_payload(
|
|
shape: Any,
|
|
*,
|
|
include_runs: bool,
|
|
max_table_cells: int,
|
|
) -> dict[str, Any]:
|
|
payload: dict[str, Any] = {
|
|
"name": shape.name,
|
|
"shape_type": str(shape.shape_type),
|
|
"x": _inches(shape.left),
|
|
"y": _inches(shape.top),
|
|
"w": _inches(shape.width),
|
|
"h": _inches(shape.height),
|
|
}
|
|
if getattr(shape, "has_text_frame", False):
|
|
payload["text_frame"] = _text_frame_payload(
|
|
shape.text_frame,
|
|
include_runs=include_runs,
|
|
)
|
|
if getattr(shape, "has_table", False):
|
|
rows: list[list[str]] = []
|
|
cell_count = 0
|
|
truncated = False
|
|
for row in shape.table.rows:
|
|
values: list[str] = []
|
|
for cell in row.cells:
|
|
if cell_count >= max_table_cells:
|
|
truncated = True
|
|
break
|
|
values.append(cell.text)
|
|
cell_count += 1
|
|
if values:
|
|
rows.append(values)
|
|
if truncated:
|
|
break
|
|
payload["table"] = {
|
|
"row_count": len(shape.table.rows),
|
|
"column_count": len(shape.table.columns),
|
|
"rows": rows,
|
|
"truncated": truncated,
|
|
}
|
|
if getattr(shape, "has_chart", False):
|
|
payload["chart"] = _chart_payload(shape)
|
|
if shape.shape_type == 13:
|
|
try:
|
|
payload["image"] = {
|
|
"content_type": shape.image.content_type,
|
|
"filename": shape.image.filename,
|
|
"size_bytes": len(shape.image.blob),
|
|
}
|
|
except (AttributeError, KeyError, ValueError):
|
|
payload["image"] = {"readable": False}
|
|
return payload
|
|
|
|
|
|
def _slide_notes(slide: Any) -> str:
|
|
try:
|
|
text_frame = slide.notes_slide.notes_text_frame
|
|
return text_frame.text if text_frame is not None else ""
|
|
except (AttributeError, KeyError, ValueError):
|
|
return ""
|
|
|
|
|
|
def _comment_summary(path: Path) -> dict[str, Any]:
|
|
comment_parts: list[str] = []
|
|
authors: set[str] = set()
|
|
with zipfile.ZipFile(path) as archive:
|
|
for name in archive.namelist():
|
|
if name.startswith("ppt/comments/comment") and name.endswith(".xml"):
|
|
comment_parts.append(name)
|
|
if name in {
|
|
"ppt/commentAuthors.xml",
|
|
"ppt/authors.xml",
|
|
}:
|
|
root = parse_xml_bytes(archive.read(name), label=name)
|
|
for node in root.iter():
|
|
author = node.attrib.get("name")
|
|
if author:
|
|
authors.add(author)
|
|
return {
|
|
"part_count": len(comment_parts),
|
|
"authors": sorted(authors),
|
|
}
|
|
|
|
|
|
def _external_relationships(path: Path) -> list[dict[str, str]]:
|
|
relationships: list[dict[str, str]] = []
|
|
with zipfile.ZipFile(path) as archive:
|
|
for name in archive.namelist():
|
|
if not name.endswith(".rels"):
|
|
continue
|
|
root = parse_xml_bytes(archive.read(name), label=name)
|
|
for node in root:
|
|
if node.attrib.get("TargetMode") != "External":
|
|
continue
|
|
relationships.append(
|
|
{
|
|
"part": name,
|
|
"type": node.attrib.get("Type", ""),
|
|
"target": node.attrib.get("Target", ""),
|
|
}
|
|
)
|
|
return relationships[:200]
|
|
|
|
|
|
def main() -> dict[str, Any]:
|
|
from pptx import Presentation
|
|
|
|
args = build_parser().parse_args()
|
|
if args.start_slide < 1:
|
|
raise ValueError("start-slide 必须大于 0")
|
|
if args.max_slides < 1 or args.max_slides > 100:
|
|
raise ValueError("max-slides 必须在 1 到 100 之间")
|
|
if args.max_shapes < 1 or args.max_shapes > 1000:
|
|
raise ValueError("max-shapes 必须在 1 到 1000 之间")
|
|
if args.max_table_cells < 1 or args.max_table_cells > 10000:
|
|
raise ValueError("max-table-cells 必须在 1 到 10000 之间")
|
|
if args.max_chars < 1000 or args.max_chars > 1_000_000:
|
|
raise ValueError("max-chars 必须在 1000 到 1000000 之间")
|
|
|
|
source = input_file(args.input, OOXML_PRESENTATION_SUFFIXES)
|
|
archive = inspect_archive(source)
|
|
if archive["missing_required_parts"]:
|
|
raise ValueError(
|
|
"演示文稿缺少必要部件:"
|
|
+ "、".join(archive["missing_required_parts"])
|
|
)
|
|
presentation = Presentation(str(source))
|
|
slide_width, slide_height = presentation.slide_width, presentation.slide_height
|
|
if slide_width is None or slide_height is None:
|
|
raise ValueError("演示文稿缺少页面尺寸")
|
|
slide_count = len(presentation.slides)
|
|
if args.start_slide > slide_count and slide_count > 0:
|
|
raise ValueError(f"start-slide 超出页面总数 {slide_count}")
|
|
|
|
end_slide = min(slide_count, args.start_slide + args.max_slides - 1)
|
|
payload_slides: list[dict[str, Any]] = []
|
|
char_count = 0
|
|
truncated_by_chars = False
|
|
for slide_number in range(args.start_slide, end_slide + 1):
|
|
slide = presentation.slides[slide_number - 1]
|
|
title = slide.shapes.title.text if slide.shapes.title is not None else ""
|
|
media_profile = _slide_media_profile(
|
|
slide,
|
|
slide_width,
|
|
slide_height,
|
|
)
|
|
shapes: list[dict[str, Any]] = []
|
|
for shape in list(slide.shapes)[: args.max_shapes]:
|
|
item = _shape_payload(
|
|
shape,
|
|
include_runs=args.include_runs,
|
|
max_table_cells=args.max_table_cells,
|
|
)
|
|
item_chars = len(str(item))
|
|
if char_count + item_chars > args.max_chars:
|
|
truncated_by_chars = True
|
|
break
|
|
shapes.append(item)
|
|
char_count += item_chars
|
|
notes = _slide_notes(slide)
|
|
if char_count + len(notes) > args.max_chars:
|
|
notes = notes[: max(0, args.max_chars - char_count)]
|
|
truncated_by_chars = True
|
|
char_count += len(notes)
|
|
payload_slides.append(
|
|
{
|
|
"number": slide_number,
|
|
"title": title,
|
|
"layout": getattr(slide.slide_layout, "name", None),
|
|
"shape_count": len(slide.shapes),
|
|
"shapes_truncated": len(slide.shapes) > args.max_shapes,
|
|
"shapes": shapes,
|
|
"speaker_notes": notes,
|
|
"media": media_profile,
|
|
}
|
|
)
|
|
if truncated_by_chars:
|
|
break
|
|
|
|
last_slide = payload_slides[-1]["number"] if payload_slides else args.start_slide - 1
|
|
next_slide = last_slide + 1 if last_slide < slide_count else None
|
|
properties = presentation.core_properties
|
|
return {
|
|
"source": str(source),
|
|
"slide_count": slide_count,
|
|
"slide_size": {
|
|
"width_inches": _inches(presentation.slide_width),
|
|
"height_inches": _inches(presentation.slide_height),
|
|
},
|
|
"properties": {
|
|
"title": properties.title,
|
|
"subject": properties.subject,
|
|
"author": properties.author,
|
|
"keywords": properties.keywords,
|
|
"comments": properties.comments,
|
|
"last_modified_by": properties.last_modified_by,
|
|
},
|
|
"selection": {
|
|
"start_slide": args.start_slide,
|
|
"end_slide": last_slide,
|
|
"has_more": next_slide is not None,
|
|
"next_slide": next_slide,
|
|
"truncated_by_chars": truncated_by_chars,
|
|
},
|
|
"slides": payload_slides,
|
|
"image_slides": [
|
|
slide["number"]
|
|
for slide in payload_slides
|
|
if slide["media"]["has_images"]
|
|
],
|
|
"comments": _comment_summary(source),
|
|
"external_relationships": _external_relationships(source),
|
|
"archive": archive,
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(run_cli(main))
|