Files
wehub-resource-sync e904b667c6
Build/Publish Develop Docs / deploy (push) Failing after 1s
PaddleOCR Code Style Check / check-code-style (push) Failing after 1s
PaddleOCR PR Tests GPU / detect-changes (push) Failing after 1s
PaddleOCR PR Tests / detect-changes (push) Failing after 1s
PaddleOCR PR Tests GPU / test-pr-gpu (push) Has been cancelled
PaddleOCR PR Tests / test-pr (push) Has been cancelled
PaddleOCR PR Tests GPU / test-pr-gpu-impl (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.13) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.8) (push) Has been cancelled
PaddleOCR PR Tests / test-pr-python (3.9) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:59:26 +08:00

564 lines
21 KiB
Python

# Copyright (c) 2025 PaddlePaddle Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from pathlib import Path
from ..base import BaseConverter, ConvertResult
from ..math import (
extract_math_from_paragraph as _extract_math_from_paragraph,
paragraph_has_math as _paragraph_has_math,
)
from ..registry import default_registry
# pptx XML namespace for DrawingML run properties
_A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
_A = "{" + _A_NS + "}"
_A_STRIKE = f"{_A}strike"
_A_RPR = f"{_A}rPr"
_A_P = f"{_A}p"
# Markup Compatibility namespace
_MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
_MC = "{" + _MC_NS + "}"
_MC_CHOICE = f"{_MC}Choice"
_MC_ALT = f"{_MC}AlternateContent"
# Chart namespace
_C_NS = "http://schemas.openxmlformats.org/drawingml/2006/chart"
_C = "{" + _C_NS + "}"
# Chart type name lookup (used in _chart_to_html)
_CHART_TYPE_NAMES = {
1: "Area Chart", # AREA
2: "Area Chart", # AREA_STACKED
4: "Line Chart", # LINE
5: "Pie Chart", # PIE
15: "Bubble Chart", # BUBBLE
51: "Column Chart", # COLUMN_CLUSTERED
52: "Column Chart", # COLUMN_STACKED
53: "Column Chart", # COLUMN_STACKED_100
57: "Bar Chart", # BAR_CLUSTERED
58: "Bar Chart", # BAR_STACKED
65: "Line Chart", # LINE_MARKERS
-4120: "Doughnut Chart", # DOUGHNUT
-4151: "Radar Chart", # RADAR
-4169: "Scatter Chart", # XY_SCATTER
}
def _pptx_run_strike(run) -> bool:
"""Return True if the run has strikethrough (sngStrike or dblStrike) set in XML."""
try:
rPr = run._r.find(_A_RPR)
if rPr is not None:
val = rPr.get(_A_STRIKE)
return val in ("sngStrike", "dblStrike")
except Exception:
pass
return False
def _pptx_run_script(run) -> str:
"""Return 'super', 'sub', or '' based on DrawingML baseline attribute.
baseline > 0 -> superscript, baseline < 0 -> subscript.
baseline attribute is on <a:rPr> with no namespace prefix.
"""
try:
rPr = run._r.find(_A_RPR)
if rPr is not None:
val = rPr.get("baseline")
if val is not None:
n = int(val)
if n > 0:
return "super"
if n < 0:
return "sub"
except Exception:
pass
return ""
def _escape_md_url(url: str) -> str:
"""Escape parentheses in URL for Markdown link syntax."""
return url.replace("(", "%28").replace(")", "%29")
def _classify_part(part: str) -> str:
"""Classify a slide content part for grouping (heading/html/list/blockquote/other)."""
s = part.lstrip()
if s.startswith("##"):
return "heading"
if s.startswith("<"):
return "html"
if s.startswith("- "):
return "list"
if s.startswith(">"):
return "blockquote"
return "other"
def _format_run_segment(
seg: str,
bold: bool,
italic: bool,
underline: bool,
strikethrough: bool,
script: str,
url: str,
) -> str:
"""Apply Markdown/HTML inline formatting to a text segment."""
t = seg
if bold or italic or underline or strikethrough or script:
leading = len(t) - len(t.lstrip())
trailing = len(t) - len(t.rstrip())
prefix = t[:leading] if leading else ""
suffix = t[len(t) - trailing :] if trailing else ""
inner = t.strip()
if inner:
if strikethrough:
inner = f"~~{inner}~~"
if bold and italic:
inner = f"***{inner}***"
elif bold:
inner = f"**{inner}**"
elif italic:
inner = f"*{inner}*"
if underline:
inner = f"<u>{inner}</u>"
if script == "super":
inner = f"<sup>{inner}</sup>"
elif script == "sub":
inner = f"<sub>{inner}</sub>"
t = prefix + inner + suffix
elif underline and t:
# Pure whitespace + underline = fill-in line
# Replace spaces with NBSP so Markdown renderers preserve width
t = "<u>" + "\u00a0" * len(t) + "</u>"
if url:
escaped_url = _escape_md_url(url)
t = f"[{t}]({escaped_url})"
return t
@default_registry.register
class PptxConverter(BaseConverter):
supported_extensions = ["pptx"]
supported_mimetypes = [
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
]
def convert_file(self, file_path: Path, **kwargs) -> ConvertResult:
try:
from pptx import Presentation
from pptx.shapes.picture import Picture
except ImportError:
raise RuntimeError(
"PPTX conversion requires python-pptx: pip install paddleocr[doc2md]"
)
try:
from pptx.enum.shapes import MSO_SHAPE_TYPE
except ImportError:
MSO_SHAPE_TYPE = None # type: ignore[assignment]
prs = Presentation(str(file_path))
slides_md = []
images: dict = {}
image_counter = [0]
slide_width = prs.slide_width
self._Picture = Picture
self._MSO_SHAPE_TYPE = MSO_SHAPE_TYPE
for slide in prs.slides:
slide_parts = []
# Process all shapes
for shape in slide.shapes:
self._process_shape(
shape, slide_parts, images, image_counter, slide_width, slide.part
)
# Handle math formulas inside mc:AlternateContent elements
# (python-pptx doesn't expose these as Shape objects)
for alt_content in slide._element.iter(_MC_ALT):
# Only look at mc:Choice (the preferred rendering path)
choice = alt_content.find(_MC_CHOICE)
if choice is None:
continue
for para_elem in choice.iter(_A_P):
if _paragraph_has_math(para_elem):
math_items = _extract_math_from_paragraph(para_elem)
for latex in math_items:
slide_parts.append(f"$$\n{latex}\n$$")
# Speaker notes
if slide.has_notes_slide:
notes_text = slide.notes_slide.notes_text_frame.text.strip()
if notes_text:
slide_parts.append(f"\n> **Notes**: {notes_text}")
# Group parts by content type and separate groups with blank lines
# to prevent HTML blocks from consuming adjacent list items
groups: list[list[str]] = []
for part in slide_parts:
kind = _classify_part(part)
if groups and _classify_part(groups[-1][0]) == kind:
groups[-1].append(part)
else:
groups.append([part])
slides_md.append("\n\n".join("\n".join(g) for g in groups))
md_text = "\n\n---\n\n".join(slides_md)
return ConvertResult(
markdown=md_text,
images=images,
metadata={
"format": "PPTX",
"slide_count": len(prs.slides),
},
)
def _process_shape(
self, shape, slide_parts, images, image_counter, slide_width, slide_part
):
"""Recursively process a shape: Picture, GroupShape, Chart, Table, or TextFrame."""
Picture = self._Picture
MSO_SHAPE_TYPE = self._MSO_SHAPE_TYPE
# 1. Picture
if isinstance(shape, Picture):
try:
img = shape.image
image_counter[0] += 1
filename = f"image{image_counter[0]}.{img.ext}"
rel_path = f"images/{filename}"
images[rel_path] = img.blob
if shape.width and slide_width:
pct = min(round(shape.width / slide_width * 100), 100)
slide_parts.append(f'<img src="images/{filename}" width="{pct}%">')
else:
slide_parts.append(f'<img src="images/{filename}">')
except (ValueError, AttributeError):
pass
return
# 2. GroupShape - recurse into child shapes
if MSO_SHAPE_TYPE and shape.shape_type == MSO_SHAPE_TYPE.GROUP:
try:
for child in shape.shapes:
self._process_shape(
child,
slide_parts,
images,
image_counter,
slide_width,
slide_part,
)
except AttributeError:
pass
return
# 3. Chart
if shape.has_chart:
slide_parts.append(self._chart_to_html(shape.chart))
return
# 4. Table
if shape.has_table:
slide_parts.append(
self._table_to_html(shape.table, slide_part, image_counter, images)
)
return
# 5. TextFrame
if shape.has_text_frame:
for paragraph in shape.text_frame.paragraphs:
# Check for math elements first
para_xml = paragraph._p
if _paragraph_has_math(para_xml):
math_items = _extract_math_from_paragraph(para_xml)
for latex in math_items:
slide_parts.append(f"$$\n{latex}\n$$")
continue
parts = []
for run in paragraph.runs:
t = run.text
if not t:
continue
try:
url = run.hyperlink.address
except Exception:
url = None
bold = bool(run.font.bold)
italic = bool(run.font.italic)
underline = bool(run.font.underline) and not url
strikethrough = _pptx_run_strike(run)
script = _pptx_run_script(run)
if "\n" in t:
segments = t.split("\n")
for j, seg in enumerate(segments):
if seg:
parts.append(
_format_run_segment(
seg,
bold,
italic,
underline,
strikethrough,
script,
url,
)
)
if j < len(segments) - 1:
parts.append("<br>\n")
else:
parts.append(
_format_run_segment(
t, bold, italic, underline, strikethrough, script, url
)
)
text = "".join(parts).strip()
if not text:
continue
level = paragraph.level
indent = " " * level
# List item continuation lines need indentation; revert <br>\n → <br>
text = text.replace("<br>\n", "<br>")
slide_parts.append(f"{indent}- {text}")
def _chart_to_html(self, chart) -> str:
"""Extract chart data as an HTML table."""
try:
chart_type_val = chart.chart_type.value if chart.chart_type else 0
chart_type_name = _CHART_TYPE_NAMES.get(chart_type_val, "Chart")
except Exception:
chart_type_name = "Chart"
try:
title_text = ""
try:
title_text = chart.chart_title.text_frame.text.strip()
except Exception:
pass
# Extract axis info from OOXML
chart_root = chart._element
cat_ax_title = ""
for ax_tag in (f"{_C}catAx", f"{_C}dateAx"):
ax_el = chart_root.find(f".//{ax_tag}")
if ax_el is not None:
t_el = ax_el.find(f"{_C}title")
if t_el is not None:
texts = t_el.findall(f".//{_A}t")
cat_ax_title = "".join(el.text or "" for el in texts).strip()
break
has_date_ax = chart_root.find(f".//{_C}dateAx") is not None
val_ax_title = ""
val_ax_el = chart_root.find(f".//{_C}valAx")
if val_ax_el is not None:
t_el = val_ax_el.find(f"{_C}title")
if t_el is not None:
texts = t_el.findall(f".//{_A}t")
val_ax_title = "".join(el.text or "" for el in texts).strip()
plot = chart.plots[0]
categories = list(plot.categories) if plot.categories else []
series_list = list(plot.series)
if not series_list:
return f"[{chart_type_name}]"
# Convert Excel date serials to YYYY-MM-DD for date axes
if has_date_ax and categories:
from datetime import datetime, timedelta
converted = []
for c in categories:
try:
dt = datetime(1899, 12, 30) + timedelta(days=float(c))
converted.append(dt.strftime("%Y-%m-%d"))
except (ValueError, TypeError):
converted.append(str(c) if c is not None else "")
categories = converted
# Collect series names and values
series_names = []
series_values = []
for idx, series in enumerate(series_list):
try:
name = (
(series.tx.text if series.tx else "")
or val_ax_title
or f"Series{idx+1}"
)
except Exception:
name = val_ax_title or f"Series{idx+1}"
series_names.append(name)
try:
vals = [
str(round(v, 4)) if v is not None else "" for v in series.values
]
except Exception:
vals = []
series_values.append(vals)
# Build HTML table
html_parts = ["<table>"]
if title_text:
html_parts.append(f"<caption>{title_text}</caption>")
has_header = cat_ax_title or any(name for name in series_names)
if has_header:
html_parts.append("<thead><tr>")
html_parts.append(f"<th>{cat_ax_title}</th>")
for name in series_names:
html_parts.append(f"<th>{name}</th>")
html_parts.append("</tr></thead>")
html_parts.append("<tbody>")
if categories:
for i, cat in enumerate(categories):
html_parts.append(f"<tr><td>{cat}</td>")
for vals in series_values:
v = vals[i] if i < len(vals) else ""
html_parts.append(f"<td>{v}</td>")
html_parts.append("</tr>")
else:
max_len = max((len(v) for v in series_values), default=0)
for i in range(max_len):
html_parts.append(f"<tr><td>Item{i+1}</td>")
for vals in series_values:
v = vals[i] if i < len(vals) else ""
html_parts.append(f"<td>{v}</td>")
html_parts.append("</tr>")
html_parts.append("</tbody></table>")
return "\n".join(html_parts)
except Exception:
return f"[{chart_type_name}]"
@staticmethod
def _table_to_html(
table, slide_part, image_counter_list: list, images: dict
) -> str:
"""Convert a PPTX table to an HTML table, handling merged cells and cell background images."""
_BLIP_NS = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
_REL_NS = (
"{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
)
visited: set[tuple[int, int]] = set()
html_parts = ["<table>"]
for i, row in enumerate(table.rows):
html_parts.append("<tr>")
for j, cell in enumerate(row.cells):
if (i, j) in visited:
continue
tag = "th" if i == 0 else "td"
attrs = ""
if cell.is_merge_origin:
rs = cell.span_height
cs = cell.span_width
if cs > 1:
attrs += f' colspan="{cs}"'
if rs > 1:
attrs += f' rowspan="{rs}"'
for di in range(rs):
for dj in range(cs):
if (di, dj) != (0, 0):
visited.add((i + di, j + dj))
content_parts = []
# Extract cell background blip images
blips = cell._tc.findall(f".//{_BLIP_NS}blip")
for blip in blips:
r_embed = blip.get(f"{_REL_NS}embed")
if r_embed:
try:
image_part = slide_part.related_parts[r_embed]
image_counter_list[0] += 1
ext = image_part.content_type.split("/")[-1]
filename = f"image{image_counter_list[0]}.{ext}"
rel_path = f"images/{filename}"
images[rel_path] = image_part.blob
content_parts.append(
f'<img src="images/{filename}" width="100%">'
)
except (KeyError, AttributeError):
pass
cell_text_parts = []
for para in cell.text_frame.paragraphs:
# Check for math elements first
para_xml = para._p
if _paragraph_has_math(para_xml):
math_items = _extract_math_from_paragraph(para_xml)
for latex in math_items:
cell_text_parts.append(f"${latex}$")
continue
run_parts = []
for run in para.runs:
t = run.text or ""
if not t:
continue
try:
url = run.hyperlink.address
except Exception:
url = None
bold = bool(run.font.bold)
italic = bool(run.font.italic)
underline = bool(run.font.underline) and not url
strikethrough = bool(run.font.strike)
script = _pptx_run_script(run)
if bold:
t = f"<b>{t}</b>"
if italic:
t = f"<i>{t}</i>"
if underline:
t = f"<u>{t}</u>"
if strikethrough:
t = f"<del>{t}</del>"
if script == "super":
t = f"<sup>{t}</sup>"
elif script == "sub":
t = f"<sub>{t}</sub>"
if url:
run_parts.append(f'<a href="{url}">{t}</a>')
else:
run_parts.append(t)
cell_text_parts.append("".join(run_parts))
text = "<br>".join(p for p in cell_text_parts if p.strip())
if text:
content_parts.append(text)
cell_html = "<br>".join(content_parts) if content_parts else ""
html_parts.append(f"<{tag}{attrs}>{cell_html}</{tag}>")
html_parts.append("</tr>")
html_parts.append("</table>")
return "\n".join(html_parts)