' + 'First body. ' * 40 + '
' + 'First body. ' * 40 + '
diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json
index 097e8218..78724a84 100644
--- a/config/external-sources.lock.json
+++ b/config/external-sources.lock.json
@@ -42,8 +42,8 @@
"repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main",
"adapter": "claude-skill",
- "commit": "295a1f114a138f23b5dfee0e0c6812394dfeb90c",
- "syncedAt": "2026-10-02T16:00:00Z"
+ "commit": "dba27166ad7b146b2bd840dcfb0a7d0fd95f5c8c",
+ "syncedAt": "2026-10-05T16:00:00Z"
},
{
"id": "frontend-slides",
@@ -78,8 +78,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"adapter": "claude-skill",
- "commit": "44c10ed0bc3a9e1df7a25aa179ae7c26db09469b",
- "syncedAt": "2026-10-01T16:00:00Z"
+ "commit": "2d72da616cf9fa40d4dcaf59fd4c980ecf534b7d",
+ "syncedAt": "2026-10-05T16:00:00Z"
},
{
"id": "grill-me",
@@ -87,8 +87,8 @@
"repo": "https://github.com/mattpocock/skills.git",
"ref": "main",
"adapter": "skill-collection",
- "commit": "24fe0ef7737efae15c87225755e9f6f5965e4888",
- "syncedAt": "2026-10-04T16:00:00Z"
+ "commit": "4588b32ecab9ecc9fc8cc6b6c5e7d675b6004b0d",
+ "syncedAt": "2026-10-05T16:00:00Z"
},
{
"id": "updating-gitea-repositories",
@@ -168,8 +168,8 @@
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"adapter": "skill-collection",
- "commit": "ba80ee48fc319735151c3ad6d9bb9a8180c9f09e",
- "syncedAt": "2026-10-04T16:00:00Z"
+ "commit": "41215d77762375b4b5154b12cfbb4372a835f8a0",
+ "syncedAt": "2026-10-05T16:00:00Z"
},
{
"id": "shuorenhua",
diff --git a/plugins/codex/plugins/grill-me/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/grill-me/THIRD_PARTY_SOURCE.json
index 9d113c9a..823e3527 100644
--- a/plugins/codex/plugins/grill-me/THIRD_PARTY_SOURCE.json
+++ b/plugins/codex/plugins/grill-me/THIRD_PARTY_SOURCE.json
@@ -2,8 +2,8 @@
"sourceId": "grill-me",
"repo": "https://github.com/mattpocock/skills.git",
"ref": "main",
- "commit": "24fe0ef7737efae15c87225755e9f6f5965e4888",
+ "commit": "4588b32ecab9ecc9fc8cc6b6c5e7d675b6004b0d",
"adapter": "skill-collection",
"sourcePath": "skills/productivity",
- "syncedAt": "2026-10-04T16:00:00Z"
+ "syncedAt": "2026-10-05T16:00:00Z"
}
diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json
index a32a4fb0..b8900aea 100644
--- a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json
+++ b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
- "syncedAt": "2026-10-04T16:01:56Z"
+ "syncedAt": "2026-10-05T16:02:10Z"
}
diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json
index 5554ebac..dc1c677b 100644
--- a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json
+++ b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
- "commit": "ba80ee48fc319735151c3ad6d9bb9a8180c9f09e",
+ "commit": "41215d77762375b4b5154b12cfbb4372a835f8a0",
"adapter": "skill-collection",
"sourcePath": "skills",
- "syncedAt": "2026-10-04T16:00:00Z"
+ "syncedAt": "2026-10-05T16:00:00Z"
}
diff --git a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json
index 3977a1c7..65e6a468 100644
--- a/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json
+++ b/plugins/codex/plugins/ppt-master/THIRD_PARTY_SOURCE.json
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
- "commit": "44c10ed0bc3a9e1df7a25aa179ae7c26db09469b",
+ "commit": "2d72da616cf9fa40d4dcaf59fd4c980ecf534b7d",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
- "syncedAt": "2026-10-01T16:00:00Z"
+ "syncedAt": "2026-10-05T16:00:00Z"
}
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md
index 6daf4fb7..6dacba58 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/docs/conversion.md
@@ -178,13 +178,15 @@ python3 scripts/source_to_md/excel_to_md.py report.xlsx budget.xlsm
python3 scripts/source_to_md/excel_to_md.py ./workbooks
python3 scripts/source_to_md/excel_to_md.py ./workbooks -o ./markdown # explicit separate output directory
python3 scripts/source_to_md/excel_to_md.py report.xlsm --max-rows 200 --max-cols 40
+python3 scripts/source_to_md/excel_to_md.py report.xlsx --include-hidden # also export hidden sheets
```
Behavior:
- preserves workbook and sheet structure in Markdown
-- exports visible sheets only
+- exports visible sheets by default; skipped hidden sheets are named in a warning, and `--include-hidden` exports them with a `(hidden)` heading
- trims empty outer rows and columns
-- propagates merged-cell labels for readable Markdown tables
+- propagates merged-cell values for readable Markdown tables; merged numeric regions are noted below the table (`> Merged cells: A2:A3 (shared value)`) so a shared amount is not counted twice
+- shows percent formats as percentages and keeps leading zeros of zero-padded identifiers; other number formats keep the stored value at Excel's 15-digit precision
- exports formula cells as cached values; it does not recalculate formulas
- uses the shared best-effort conversion-profile contract after success
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/doc_to_md.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/doc_to_md.py
index a0a6f7b1..56f20522 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/doc_to_md.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/doc_to_md.py
@@ -583,7 +583,12 @@ def _omml_to_latex(elem: ET.Element) -> str:
end = _m_pr_val(elem, "endChr")
beg = "(" if beg is None else (beg or ".")
end = ")" if end is None else (end or ".")
- inner = "".join(_omml_to_latex(c) for c in elem if _local_name(c) == "e")
+ separator = _m_pr_val(elem, "sepChr")
+ separator = "|" if separator is None else separator
+ separator = _latex_literal(separator)
+ beg = {"{": r"\{", "}": r"\}"}.get(beg, beg)
+ end = {"{": r"\{", "}": r"\}"}.get(end, end)
+ inner = separator.join(_omml_to_latex(c) for c in elem if _local_name(c) == "e")
return rf"\left{beg}{inner}\right{end}"
if local == "nary":
chr_ = _m_pr_val(elem, "chr") or "∫"
@@ -608,7 +613,15 @@ def _omml_to_latex(elem: ET.Element) -> str:
cmd = ACCENT_CMDS.get(_m_pr_val(elem, "chr") or "̂", r"\hat")
return cmd + "{" + _omml_part(elem, "e") + "}"
if local == "groupChr":
- return _omml_part(elem, "e")
+ char = _m_pr_val(elem, "chr")
+ char = "⏟" if char is None else char
+ pos = _m_pr_val(elem, "pos") or "bot"
+ body = _omml_part(elem, "e")
+ if (char, pos) in {("⏞", "top"), ("⏟", "bot")}:
+ cmd = r"\overbrace" if pos == "top" else r"\underbrace"
+ return cmd + "{" + body + "}"
+ cmd = r"\overset" if pos == "top" else r"\underset"
+ return cmd + r"{\text{" + _latex_literal(char) + "}}{" + body + "}"
if local == "m":
return _omml_matrix(elem, environment="matrix")
if local == "eqArr":
@@ -617,6 +630,13 @@ def _omml_to_latex(elem: ET.Element) -> str:
return _omml_children(elem)
+def _latex_literal(text: str) -> str:
+ """Escape literal characters that would otherwise change LaTeX syntax."""
+ escapes = {"\\": r"\backslash{}", "{": r"\{", "}": r"\}", "%": r"\%",
+ "&": r"\&", "#": r"\#", "_": r"\_", "$": r"\$"}
+ return "".join(escapes.get(char, char) for char in text)
+
+
def _make_text_run(text: str) -> ET.Element:
"""Build a
".join(paragraphs)
@@ -759,12 +807,12 @@ def _markdown_table_cell(text: str) -> str:
return text.replace("|", r"\|")
-def _docx_table_to_markdown(table: ET.Element) -> str:
+def _docx_table_to_markdown(table: ET.Element, links: dict[str, str] | None = None) -> str:
"""Convert a Word table XML node to a pipe Markdown table."""
rows: list[list[str]] = []
for row in table.findall("w:tr", DOCX_NS):
cells = [
- _markdown_table_cell(_docx_table_cell_text(cell))
+ _markdown_table_cell(_docx_table_cell_text(cell, links))
for cell in row.findall("w:tc", DOCX_NS)
]
if cells:
@@ -785,12 +833,22 @@ def _docx_table_to_markdown(table: ET.Element) -> str:
def _docx_inject_tables_markdown(
input_file: Path,
+ warnings: list[str] | None = None,
) -> tuple[Path, dict[str, str]] | None:
"""Replace text-only DOCX tables with Markdown placeholders in a temp DOCX."""
try:
with zipfile.ZipFile(input_file) as docx:
document_xml = docx.read("word/document.xml")
- footnotes = _docx_footnote_texts(docx)
+ notes = {kind: _docx_note_texts(docx, kind) for kind in ("footnote", "endnote")}
+ try:
+ rels = ET.fromstring(docx.read("word/_rels/document.xml.rels"))
+ except (KeyError, ET.ParseError):
+ rels = ET.Element("relationships")
+ links = {
+ rel.get("Id", ""): rel.get("Target", "")
+ for rel in rels
+ if rel.get("Type", "").endswith("/hyperlink")
+ }
except (KeyError, zipfile.BadZipFile, OSError):
return None
try:
@@ -799,20 +857,48 @@ def _docx_inject_tables_markdown(
return None
parent_map = {child: parent for parent in root.iter() for child in parent}
+ controls = list(root.iter(f"{{{W_NS}}}sdt"))
+ # Mammoth skips content controls. Unwrap only their displayed content,
+ # including nested controls, while leaving runs, tables, and links intact.
+ for control in reversed(controls):
+ parent = parent_map.get(control)
+ content = control.find("w:sdtContent", DOCX_NS)
+ if parent is None or content is None:
+ continue
+ position = list(parent).index(control)
+ parent.remove(control)
+ for offset, child in enumerate(content):
+ parent.insert(position + offset, child)
+ if controls:
+ warning = (f"DOCX content controls: {len(controls)} flattened to displayed content; "
+ "interactive properties omitted.")
+ print(f"[WARN] {warning}", file=sys.stderr)
+ if warnings is not None:
+ warnings.append(warning)
+ parent_map = {child: parent for parent in root.iter() for child in parent}
token_base = uuid.uuid4().hex
replacements: dict[str, str] = {}
for index, table in enumerate(root.findall(".//w:tbl", DOCX_NS)):
- if _docx_table_has_media(table):
+ if _docx_table_needs_mammoth(table):
continue
- markdown = _docx_table_to_markdown(table)
+ markdown = _docx_table_to_markdown(table, links)
if not markdown:
continue
- # The table never reaches mammoth, so its footnotes would lose both
- # the reference and the note text; keep them under the table.
- for reference in table.iter(f"{{{W_NS}}}footnoteReference"):
- note_id = reference.get(f"{{{W_NS}}}id", "")
- if footnotes.get(note_id):
- markdown += f"\n\n[^{note_id}]: {footnotes[note_id]}"
+ # Projected tables never reach Mammoth; retain their notes here.
+ for kind, texts in notes.items():
+ prefix = "endnote-" if kind == "endnote" else ""
+ for reference in table.iter(f"{{{W_NS}}}{kind}Reference"):
+ note_id = reference.get(f"{{{W_NS}}}id", "")
+ if note_id in texts and texts[note_id] is None:
+ continue # Structural separators are not missing note bodies.
+ if texts.get(note_id):
+ markdown += f"\n\n[^{prefix}{note_id}]: {texts[note_id]}"
+ else:
+ warning = f"DOCX {kind} {note_id}: note body unavailable; retain the original DOCX."
+ markdown += f"\n\n> {warning}"
+ print(f"[WARN] {warning}", file=sys.stderr)
+ if warnings is not None:
+ warnings.append(warning)
parent = parent_map.get(table)
if parent is None:
continue
@@ -821,7 +907,7 @@ def _docx_inject_tables_markdown(
parent.remove(table)
parent.insert(position, _make_text_paragraph(token))
replacements[token] = markdown
- if not replacements:
+ if not replacements and not controls:
return None
for prefix, uri in DOCX_NS.items():
@@ -1001,7 +1087,7 @@ def _convert_docx(
# Rewrite OMML equations to LaTeX placeholders before mammoth (which would
# otherwise drop them); the placeholders are swapped back below.
- math_injection = _docx_inject_math_latex(input_file)
+ math_injection = _docx_inject_math_latex(input_file, warnings)
if math_injection is not None:
math_file, math_replacements = math_injection
else:
@@ -1009,7 +1095,7 @@ def _convert_docx(
table_file = None
table_replacements: dict[str, str] = {}
mammoth_source = math_file or input_file
- table_injection = _docx_inject_tables_markdown(mammoth_source)
+ table_injection = _docx_inject_tables_markdown(mammoth_source, warnings)
if table_injection is not None:
table_file, table_replacements = table_injection
mammoth_source = table_file
@@ -1072,7 +1158,7 @@ def _convert_docx(
print(f" Charts: {len(chart_replacements)} rendered from their cached data")
for msg in result.messages:
if msg.type == "warning":
- print(f" [warn] {msg.message}")
+ print(f" [warn] {msg.message}", file=sys.stderr)
if warnings is not None:
warnings.append(msg.message)
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/excel_to_md.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/excel_to_md.py
index 4221458d..047ba960 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/excel_to_md.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/excel_to_md.py
@@ -17,6 +17,7 @@ import argparse
import re
import sys
from datetime import date, datetime, time
+from decimal import Decimal
from pathlib import Path
from typing import Any
@@ -67,17 +68,61 @@ def _markdown_escape(value: str) -> str:
return re.sub(r"\s*\n\s*", "
", value).strip()
-def _format_cell_value(value: Any) -> str:
+def _format_number(value: int | float, number_format: str) -> str | None:
+ """Add percentage/identifier meaning without rounding the stored value."""
+ sections = re.split(r';(?=(?:[^"]*"[^"]*")*[^"]*$)', number_format)
+ section = sections[1] if value < 0 and len(sections) > 1 else sections[0]
+ section = _strip_display_tokens(section)
+ if re.fullmatch(r"[+\-()]?(?:0+|#,##0)(?:\.[0#]+)?%\)?", section):
+ # Match the plain-number path: Excel holds 15 significant digits, so
+ # scale that value rather than the 17-digit binary repr.
+ number = Decimal(f"{value:.15g}") * 100
+ if number.is_finite():
+ rendered = format(number, "f")
+ if "." in rendered:
+ rendered = rendered.rstrip("0").rstrip(".")
+ return rendered + "%"
+ if re.fullmatch(r"0+", section) and value == int(value):
+ return ("-" if value < 0 else "") + str(abs(int(value))).zfill(len(section))
+ return None
+
+
+def _strip_display_tokens(number_format: str) -> str:
+ """Remove accounting decoration while retaining units and scale tokens."""
+ # Accounting spacing, colors, currency locales, and currency labels do not
+ # change a raw number's meaning. Other literal units or scaling can do so.
+ cleaned = re.sub(r"_.?|\*.", "", number_format)
+ cleaned = re.sub(r"\[(?:Black|Blue|Cyan|Green|Magenta|Red|White|Yellow|Color\d+|\$[^\]]*)\]",
+ "", cleaned, flags=re.I)
+ cleaned = re.sub(r'"(?:USD|EUR|GBP|CNY|RMB|JPY|HKD|[$€£¥¥\s-])*"', "", cleaned)
+ cleaned = re.sub(r"\\([$€£¥¥ ()+-])", r"\1", cleaned)
+ return cleaned.strip()
+
+
+def _format_needs_warning(number_format: str) -> bool:
+ """Identify unhandled semantic formats, excluding ordinary display styling."""
+ if number_format in {"General", "@"}:
+ return False
+ cleaned = _strip_display_tokens(number_format)
+ return (not re.fullmatch(r"[0-9#?.,Ee+\-;/()@$€£¥¥\s]*", cleaned)
+ or bool(re.search(r",\s*(?:;|$)", cleaned)))
+
+
+def _format_cell_value(value: Any, number_format: str = "General") -> str:
if _is_empty(value):
return ""
if isinstance(value, bool):
return "TRUE" if value else "FALSE"
if isinstance(value, datetime):
- return value.isoformat(sep=" ", timespec="seconds")
+ return value.isoformat(sep=" ")
if isinstance(value, date):
return value.isoformat()
if isinstance(value, time):
- return value.isoformat(timespec="seconds")
+ return value.isoformat()
+ if _is_numeric_value(value):
+ formatted = _format_number(value, number_format)
+ if formatted is not None:
+ return formatted
if isinstance(value, float):
# Excel shows 15 significant digits; that keeps 1234567.89 exact while
# 0.1 + 0.2 reads as 0.3 rather than its binary expansion.
@@ -246,11 +291,17 @@ def _column_alignments(rows: list[list[Any]]) -> list[str]:
return alignments
-def _rows_to_markdown_table(rows: list[list[Any]]) -> str:
+def _rows_to_markdown_table(
+ rows: list[list[Any]], number_formats: list[list[str]] | None = None,
+) -> str:
if not rows:
return "_No tabular content found._"
- formatted_rows = [[_format_cell_value(value) for value in row] for row in rows]
+ formatted_rows = [
+ [_format_cell_value(value, number_formats[i][j] if number_formats else "General")
+ for j, value in enumerate(row)]
+ for i, row in enumerate(rows)
+ ]
width = len(formatted_rows[0])
separator = _column_alignments(rows)
lines = [
@@ -268,7 +319,10 @@ def _rows_to_markdown_table(rows: list[list[Any]]) -> str:
# Excel → Markdown
# ─────────────────────────────────────────────────────────────
-def _convert_excel(input_file: Path, out_file: Path, max_rows: int, max_cols: int) -> str:
+def _convert_excel(
+ input_file: Path, out_file: Path, max_rows: int, max_cols: int,
+ include_hidden: bool = False, warnings: list[str] | None = None,
+) -> str:
try:
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
@@ -278,6 +332,8 @@ def _convert_excel(input_file: Path, out_file: Path, max_rows: int, max_cols: in
workbook = load_workbook(input_file, data_only=True, read_only=False)
visible_sheets = [sheet for sheet in workbook.worksheets if sheet.sheet_state == "visible"]
+ hidden_sheets = [sheet for sheet in workbook.worksheets if sheet.sheet_state != "visible"]
+ selected_sheets = workbook.worksheets if include_hidden else visible_sheets
lines: list[str] = [
f"# Spreadsheet Source: {input_file.name}",
@@ -291,15 +347,23 @@ def _convert_excel(input_file: Path, out_file: Path, max_rows: int, max_cols: in
"",
]
- if not visible_sheets:
+ if hidden_sheets and not include_hidden:
+ warning = "Skipped hidden sheets: " + ", ".join(sheet.title for sheet in hidden_sheets)
+ warning += ". Use --include-hidden to export them."
+ lines.extend([f"> Warning: {warning}", ""])
+ print(f"[WARN] {warning}", file=sys.stderr)
+ if warnings is not None:
+ warnings.append(warning)
+
+ if not selected_sheets:
lines.extend(["_No visible sheets found._", ""])
- for worksheet in visible_sheets:
+ for worksheet in selected_sheets:
merged_values = _merged_value_map(worksheet)
bounds = _content_bounds(worksheet, merged_values)
lines.extend([
- f"## Sheet: {worksheet.title}",
+ f"## Sheet: {worksheet.title}" + (" (hidden)" if worksheet.sheet_state != "visible" else ""),
"",
f"- State: {_sheet_state_label(worksheet.sheet_state)}",
])
@@ -328,7 +392,34 @@ def _convert_excel(input_file: Path, out_file: Path, max_rows: int, max_cols: in
"",
])
+ # Propagated merged values must use the anchor's display format too.
+ format_anchors = {
+ (row, col): (region.min_row, region.min_col)
+ for region in worksheet.merged_cells.ranges
+ for row in range(region.min_row, region.max_row + 1)
+ for col in range(region.min_col, region.max_col + 1)
+ if (row, col) in merged_values
+ }
+ number_formats = []
+ unsupported_formats: dict[str, int] = {}
+ for i, row in enumerate(rows, min_row):
+ formats = []
+ for j, value in enumerate(row, min_col):
+ anchor = format_anchors.get((i, j), (i, j))
+ cell = worksheet.cell(*anchor)
+ fmt = cell.number_format
+ formats.append(fmt)
+ if (_is_numeric_value(value) and anchor == (i, j)
+ and _format_number(value, fmt) is None and _format_needs_warning(fmt)):
+ unsupported_formats[fmt] = unsupported_formats.get(fmt, 0) + 1
+ number_formats.append(formats)
+
+ original_rows = rows
captions, rows = _split_caption_rows(rows, min_row, min_col, merged_values)
+ for i in range(len(captions)):
+ j = next(j for j, value in enumerate(original_rows[i]) if not _is_empty(value))
+ captions[i] = _format_cell_value(original_rows[i][j], number_formats[i][j])
+ number_formats = number_formats[len(captions):]
for caption in captions:
lines.extend([f"> {caption}", ""])
@@ -340,8 +431,20 @@ def _convert_excel(input_file: Path, out_file: Path, max_rows: int, max_cols: in
limit_notes.append(f"columns limited to {max_cols}")
lines.extend([f"> Truncated: {', '.join(limit_notes)}.", ""])
- lines.extend([_rows_to_markdown_table(rows), ""])
+ lines.extend([_rows_to_markdown_table(rows, number_formats), ""])
+ for region in sorted(worksheet.merged_cells.ranges, key=lambda r: (r.min_row, r.min_col)):
+ value = worksheet.cell(region.min_row, region.min_col).value
+ if _is_numeric_value(value):
+ lines.extend([f"> Merged cells: {region} (shared value)", ""])
+ if unsupported_formats:
+ formats = "; ".join(f"{fmt!r} ({count} cells)" for fmt, count in unsupported_formats.items())
+ warning = f"{worksheet.title}: unsupported number formats: {formats}; raw values retained."
+ print(f"[WARN] {warning}", file=sys.stderr)
+ if warnings is not None:
+ warnings.append(warning)
+ lines.extend([f"> {_markdown_escape(warning)}", ""])
+ workbook.close()
markdown = "\n".join(lines).rstrip() + "\n"
out_file.write_text(markdown, encoding="utf-8")
_report_result(out_file)
@@ -357,6 +460,7 @@ def convert_to_markdown(
output_path: str | None = None,
max_rows: int = 0,
max_cols: int = 0,
+ include_hidden: bool = False,
) -> str:
input_file = Path(input_path)
if not input_file.exists():
@@ -382,13 +486,18 @@ def convert_to_markdown(
out_file.parent.mkdir(parents=True, exist_ok=True)
print(f"[INFO] Converting Excel workbook: {input_file.name}")
- markdown = _convert_excel(input_file, out_file, max_rows=max_rows, max_cols=max_cols)
+ warnings: list[str] = []
+ markdown = _convert_excel(
+ input_file, out_file, max_rows=max_rows, max_cols=max_cols,
+ include_hidden=include_hidden, warnings=warnings,
+ )
if markdown:
profile_path = write_conversion_profile_best_effort(
input_path=str(input_file),
markdown_path=out_file,
converter="excel_to_md.py",
conversion_type=suffix.lstrip("."),
+ warnings=warnings,
)
if profile_path:
print(f" Wrote conversion profile -> {profile_path}")
@@ -432,6 +541,7 @@ Unsupported by default:
default=0,
help="Maximum columns per sheet to export (0 = no limit)",
)
+ parser.add_argument("--include-hidden", action="store_true", help="Export hidden worksheets as well")
args = parser.parse_args()
return run_path_batch(
@@ -444,6 +554,7 @@ Unsupported by default:
str(output),
max_rows=args.max_rows,
max_cols=args.max_cols,
+ include_hidden=args.include_hidden,
)
),
)
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/pdf_to_md.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/pdf_to_md.py
index 666fbb36..da5cccb9 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/pdf_to_md.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/pdf_to_md.py
@@ -46,6 +46,7 @@ FONT_H2_SIZE = 18
FONT_H3_SIZE = 14
HEADER_FOOTER_SAMPLE_LIMIT = 40
HEADER_FOOTER_EDGE_SAMPLE_SIZE = 20
+HEADER_FOOTER_BAND_RATIO = 0.15
CONTROL_CHARS_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f]')
@@ -299,16 +300,29 @@ def remove_page_footer(text: str) -> str:
return text.rstrip()
-def detect_headers_footers(doc: fitz.Document, threshold_ratio: float = 0.6) -> set[str]:
+def _header_footer_band(bbox: fitz.Rect, page_rect: fitz.Rect) -> str | None:
+ """Classify fully contained edge text; keep text crossing a band boundary."""
+ band_height = page_rect.height * HEADER_FOOTER_BAND_RATIO
+ if page_rect.y0 <= bbox.y0 and bbox.y1 <= page_rect.y0 + band_height:
+ return "header"
+ if page_rect.y1 - band_height <= bbox.y0 and bbox.y1 <= page_rect.y1:
+ return "footer"
+ return None
+
+
+def detect_headers_footers(
+ doc: fitz.Document, threshold_ratio: float = 0.6,
+) -> tuple[set[str], set[str]]:
"""
Detect headers and footers statistically.
Principle: Headers and footers typically appear at fixed positions (top or bottom)
on each page with the same content. We collect top and bottom text from all pages,
and if certain text appears more frequently than the threshold, it is treated as noise.
+ Return header and footer noise separately so filtering stays in the matching band.
"""
if len(doc) < 3:
- return set()
+ return set(), set()
headers = []
footers = []
@@ -323,12 +337,6 @@ def detect_headers_footers(doc: fitz.Document, threshold_ratio: float = 0.6) ->
for i in pages_to_scan:
page = doc[i]
- rect = page.rect
- h = rect.height
-
- # Define top and bottom regions (15% each)
- top_rect = fitz.Rect(0, 0, rect.width, h * 0.15)
- bottom_rect = fitz.Rect(0, h * 0.85, rect.width, h)
# Extract text blocks
blocks = page.get_text("blocks")
@@ -338,24 +346,25 @@ def detect_headers_footers(doc: fitz.Document, threshold_ratio: float = 0.6) ->
if not text:
continue
- # Simple spatial determination
- if b_rect.intersects(top_rect):
+ band = _header_footer_band(b_rect, page.rect)
+ if band == "header":
headers.append(text)
- elif b_rect.intersects(bottom_rect):
+ elif band == "footer":
footers.append(text)
# Count frequencies
- noise_texts = set()
+ header_noise = set()
+ footer_noise = set()
total_scanned = len(pages_to_scan)
- for collection in [headers, footers]:
+ for collection, noise_texts in [(headers, header_noise), (footers, footer_noise)]:
counter = Counter(collection)
for text, count in counter.items():
# if text appears in > 60% of scanned pages, mark as noise
if count / total_scanned > threshold_ratio:
noise_texts.add(text)
- return noise_texts
+ return header_noise, footer_noise
def _is_hangul(char: str) -> bool:
@@ -416,6 +425,8 @@ def merge_adjacent_headings(elements: list) -> list:
next_el = elements[j]
if next_el.get("type") != 0 or not next_el.get("is_heading"):
break
+ if el.get("is_footer") or next_el.get("is_footer"):
+ break
next_match = re.match(r'^(#{1,6})\s+(.+)$', next_el["content"])
if not next_match or next_match.group(1) != level:
@@ -478,6 +489,9 @@ NUMBER_RE = re.compile(r'[-+]?\d+(?:\.\d+)?')
MODEL_COLUMN_RE = re.compile(r'^[((]\d+[))]$')
TABLE_NOTE_PREFIX = '\u6ce8'
TABLE_CONTINUATION_Y_RATIO = 0.88
+# A printed page number left between split table parts; trusted only next to a
+# ```` marker, never on its own.
+PRINTED_PAGE_NUMBER_RE = re.compile(r'\d{1,4}')
TABLE_SCAN_BOTTOM_RATIO = 0.92
@@ -914,14 +928,6 @@ def _looks_like_outcome_row(row: list[str]) -> bool:
return len(short_values) == len(values)
-def _regression_group_labels(row: list[str], data_cols: int) -> list[str]:
- """Infer repeated group labels for common regression-table headings."""
- compact = "".join(row)
- if "总样本" in compact and "国有" in compact and "非国有" in compact and data_cols == 7:
- return ["总样本"] * 3 + ["国有企业"] * 2 + ["非国有企业"] * 2
- return [""] * data_cols
-
-
def _flatten_regression_header(rows: list[list[str]]) -> list[list[str]]:
"""Flatten multi-line regression headings into one Markdown header row."""
if len(rows) < 3:
@@ -942,10 +948,8 @@ def _flatten_regression_header(rows: list[list[str]]) -> list[list[str]]:
return [header] + rows[2:]
header_offset = 0
- groups = [""] * (len(rows[0]) - 1)
if not _looks_like_model_row(rows[0]) and _looks_like_model_row(rows[1]):
header_offset = 1
- groups = _regression_group_labels(rows[0], len(rows[1]) - 1)
if not _looks_like_model_row(rows[header_offset]):
return rows
@@ -957,8 +961,6 @@ def _flatten_regression_header(rows: list[list[str]]) -> list[list[str]]:
header = ["变量"]
for idx, model in enumerate(model_row[1:]):
pieces = []
- if idx < len(groups) and groups[idx]:
- pieces.append(groups[idx])
if model:
pieces.append(model)
if idx + 1 < len(outcome_row) and outcome_row[idx + 1]:
@@ -1439,24 +1441,24 @@ def _compatible_table_headers(first: list[str], second: list[str]) -> bool:
return first[0] == second[0] and _markdown_col_count(first[0]) > 2
-def _is_table_continuation_noise(line: str) -> bool:
- """Allow only page/header noise between split table parts."""
- text = line.strip()
- if not text:
- return True
- if text.startswith(""):
- return True
- if re.fullmatch(r'\d+', text):
- return True
- if "重庆大学硕士学位论文" in text:
- return True
- continuation_labels = [
- "营改增",
- "深化增值税改革",
- "国有企业",
- "非国有企业",
- ]
- return any(label in text for label in continuation_labels)
+def _is_page_marker(text: str) -> bool:
+ """Return whether a stripped line is the converter's page-break marker."""
+ return text.startswith("")
+
+
+def _is_table_continuation_gap(lines: list[str]) -> bool:
+ """Return whether only page-break furniture separates two table parts.
+
+ A bare number is a printed page number only when the gap crosses a
+ ```` marker; anywhere else it is content (a quantity, a year).
+ """
+ texts = [line.strip() for line in lines if line.strip()]
+ if not any(_is_page_marker(text) for text in texts):
+ return not texts
+ return all(
+ _is_page_marker(text) or PRINTED_PAGE_NUMBER_RE.fullmatch(text)
+ for text in texts
+ )
def _read_markdown_table_block(lines: list[str], start: int) -> tuple[list[str], int]:
@@ -1479,32 +1481,22 @@ def merge_markdown_continuation_tables(markdown: str) -> str:
index += 1
continue
- table, table_end = _read_markdown_table_block(lines, index)
- search = table_end
+ table, index = _read_markdown_table_block(lines, index)
while True:
- between_start = search
- while search < len(lines) and not _is_markdown_table_line(lines[search]):
- if not _is_table_continuation_noise(lines[search]):
- break
- search += 1
-
- if search >= len(lines) or not _is_markdown_table_line(lines[search]):
- break
- if any(
- not _is_table_continuation_noise(line)
- for line in lines[between_start:search]
- ):
+ next_start = index
+ while next_start < len(lines) and not _is_markdown_table_line(lines[next_start]):
+ next_start += 1
+ if next_start >= len(lines) or not _is_table_continuation_gap(lines[index:next_start]):
break
- next_table, next_end = _read_markdown_table_block(lines, search)
+ next_table, next_end = _read_markdown_table_block(lines, next_start)
if not _compatible_table_headers(table, next_table):
break
table.extend(next_table[2:])
- search = next_end
+ index = next_end
result.extend(table)
- index = search if search != table_end else table_end
return "\n".join(result)
@@ -1589,10 +1581,12 @@ def extract_pdf_to_markdown(
f"H1={size_map.get('h1', 'N/A')}, H2={size_map.get('h2', 'N/A')}, H3={size_map.get('h3', 'N/A')}")
print(f"[INFO] Detecting repeated headers/footers...")
- noise_texts = detect_headers_footers(doc)
- if noise_texts:
- print(f" Found {len(noise_texts)} repeated noise texts (will be removed):")
- for t in list(noise_texts)[:3]:
+ header_noise, footer_noise = detect_headers_footers(doc)
+ noise_by_band = {"header": header_noise, "footer": footer_noise, None: set()}
+ repeated_texts = header_noise | footer_noise
+ if repeated_texts:
+ print(f" Found {len(repeated_texts)} repeated noise texts (will be removed):")
+ for t in list(repeated_texts)[:3]:
print(f" - {t[:30]}...")
markdown_content = f"# {title}\n\n"
@@ -1665,7 +1659,8 @@ def extract_pdf_to_markdown(
if block["type"] == 0:
# Check if this is noise text to be filtered (whole block match)
block_text_full = "".join([span["text"] for line in block["lines"] for span in line["spans"]]).strip()
- if block_text_full in noise_texts:
+ block_band = _header_footer_band(block_rect, page.rect)
+ if block_text_full in noise_by_band[block_band]:
continue
for line in block["lines"]:
@@ -1713,9 +1708,15 @@ def extract_pdf_to_markdown(
continue
# Secondary check: line-level noise match (sometimes blocks are split)
- if line_text in noise_texts:
+ band = _header_footer_band(fitz.Rect(line["bbox"]), page.rect)
+ if line_text in noise_by_band[band]:
continue
+ if band == "footer":
+ line_text = remove_page_footer(line_text)
+ if not line_text:
+ continue
+
line_text = merge_adjacent_formatting(line_text)
heading_level = get_heading_level(line_size, size_map, line_text, line_flags)
@@ -1739,7 +1740,8 @@ def extract_pdf_to_markdown(
"content": final_text,
"is_heading": heading_level > 0,
"is_list": is_list,
- "is_code": is_code_line
+ "is_code": is_code_line,
+ "is_footer": band == "footer",
})
elif block["type"] == 1:
@@ -1771,13 +1773,15 @@ def extract_pdf_to_markdown(
next_el = page_elements[j]
if next_el["type"] != 0:
break
+ if el.get("is_footer") or next_el.get("is_footer"):
+ break
if not should_merge_lines({"content": merged_content, "is_heading": False, "is_list": False}, next_el):
break
merged_content = join_wrapped_text(merged_content, next_el["content"])
j += 1
merged_elements.append({
"type": 0,
- "content": remove_page_footer(merged_content),
+ "content": merged_content,
"is_heading": False,
"is_list": False
})
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/ppt_to_md.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/ppt_to_md.py
index 1a5f0f50..5db41127 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/ppt_to_md.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/ppt_to_md.py
@@ -168,7 +168,7 @@ def escape_table_cell(value: str) -> str:
"""Escape Markdown table syntax inside a cell."""
normalized = value.replace("\r\n", "\n").replace("\r", "\n")
lines = [re.sub(r"\s+", " ", line).strip() for line in normalized.split("\n")]
- with_breaks = "
".join(lines)
+ with_breaks = "
".join(line for line in lines if line)
return with_breaks.replace("|", r"\|") or " "
@@ -309,7 +309,7 @@ def _paragraph_to_markdown(
# A soft line break inside the paragraph; python-pptx's runs skip it.
current_text += "\n"
continue
- if tag != "r":
+ if tag not in {"r", "fld"}:
continue
run = _Run(child, paragraph)
url = _run_url(run, shape)
@@ -319,7 +319,7 @@ def _paragraph_to_markdown(
flush()
current_text = ""
current_url = url
- current_text += run.text or ""
+ current_text += (child.findtext(qn("a:t")) or "") if tag == "fld" else (run.text or "")
flush()
text = normalize_text("".join(parts))
@@ -363,7 +363,10 @@ def _shape_click_target(shape: object) -> str | None:
def _paragraph_has_hyperlink(paragraph: object) -> bool:
"""True if any run carries an external URL or an internal slide jump."""
- for run in paragraph.runs:
+ for child in paragraph._p:
+ if child.tag not in {qn("a:r"), qn("a:fld")}:
+ continue
+ run = _Run(child, paragraph)
try:
if run.hyperlink.address:
return True
@@ -378,6 +381,95 @@ def _paragraph_has_hyperlink(paragraph: object) -> bool:
return False
+def _paragraph_bullet(paragraph: object, shape: object) -> tuple[object | None, bool]:
+ """Resolve bullet declarations from paragraph, shape, layout, and master."""
+ level = f"a:lvl{paragraph.level + 1}pPr"
+ properties = [(paragraph._p.find(qn("a:pPr")), True)]
+
+ def add_shape_styles(source):
+ if source is None or not getattr(source, "has_text_frame", False):
+ return
+ body = source.text_frame._txBody
+ style = body.find(qn("a:lstStyle"))
+ if style is not None:
+ properties.extend((style.find(qn(name)), False) for name in (level, "a:defPPr"))
+
+ add_shape_styles(shape)
+ if shape is not None and hasattr(shape.part, "slide"):
+ slide = shape.part.slide
+ master = slide.slide_layout.slide_master
+ if getattr(shape, "is_placeholder", False):
+ idx = shape.placeholder_format.idx
+ layout_shape = slide.slide_layout.placeholders.get(idx)
+ if layout_shape is not None and layout_shape.has_text_frame:
+ inherited_paragraph = layout_shape.text_frame.paragraphs[0]
+ if inherited_paragraph.level == paragraph.level:
+ properties.append((inherited_paragraph._p.find(qn("a:pPr")), False))
+ add_shape_styles(layout_shape)
+ # Master placeholders inherit by type, not by layout idx.
+ master_shape = next((p for p in master.placeholders
+ if p.placeholder_format.type == layout_shape.placeholder_format.type), None)
+ if master_shape is not None and master_shape.has_text_frame:
+ inherited_paragraph = master_shape.text_frame.paragraphs[0]
+ if inherited_paragraph.level == paragraph.level:
+ properties.append((inherited_paragraph._p.find(qn("a:pPr")), False))
+ add_shape_styles(master_shape)
+ placeholder_type = str(shape.placeholder_format.type).split()[0]
+ style_name = "titleStyle" if placeholder_type in {"TITLE", "CENTER_TITLE"} else "bodyStyle"
+ else:
+ style_name = "otherStyle"
+ styles = master._element.find(qn("p:txStyles"))
+ if styles is not None:
+ style = styles.find(qn(f"p:{style_name}"))
+ if style is not None:
+ properties.extend((style.find(qn(name)), False) for name in (level, "a:defPPr"))
+ presentation = shape.part.package.presentation_part._element
+ style = presentation.find(qn("p:defaultTextStyle"))
+ if style is not None:
+ properties.extend((style.find(qn(name)), False) for name in (level, "a:defPPr"))
+
+ for props, direct in properties:
+ if props is None:
+ continue
+ for name in ("buNone", "buAutoNum", "buChar", "buBlip"):
+ bullet = props.find(qn(f"a:{name}"))
+ if bullet is not None:
+ return bullet, direct
+ return None, False
+
+
+def _numbered_marker(number: int, scheme: str) -> str:
+ """Keep the source numbering style; unfamiliar schemes carry their id."""
+ label = str(number)
+ if scheme.startswith("alpha") and number > 0:
+ label = ""
+ remaining = number
+ while remaining:
+ remaining, digit = divmod(remaining - 1, 26)
+ label = chr(ord("a") + digit) + label
+ if scheme.startswith("alphaUc"):
+ label = label.upper()
+ elif scheme.startswith("roman") and 0 < number < 4000:
+ label = ""
+ remaining = number
+ for value, digits in ((1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
+ (100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
+ (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")):
+ count, remaining = divmod(remaining, value)
+ label += digits * count
+ if scheme.startswith("romanLc"):
+ label = label.lower()
+ elif scheme not in {"arabicPeriod", "arabicPlain", "arabicParenR", "arabicParenBoth"}:
+ return f"{number}. [numbering: {scheme}]"
+ if scheme.endswith("ParenBoth"):
+ return f"({label})"
+ if scheme.endswith("ParenR"):
+ return f"{label})"
+ if scheme.endswith("Plain"):
+ return label
+ return f"{label}."
+
+
def text_frame_to_markdown(
text_frame: object,
shape: object = None,
@@ -399,11 +491,11 @@ def text_frame_to_markdown(
if not visible_paragraphs:
return ""
- list_like = any(paragraph.level > 0 for paragraph in visible_paragraphs)
- if not list_like:
- list_like = len(visible_paragraphs) > 1
-
paragraphs = []
+ counters = {}
+ previous_list = False
+ number_jump = False
+ style_shape = shape if shape is not None else getattr(text_frame, "_parent", None)
for paragraph in visible_paragraphs:
text = _escape_readback_control_lines(
_paragraph_to_markdown(
@@ -414,15 +506,47 @@ def text_frame_to_markdown(
)
if not text:
continue
+ bullet, direct = _paragraph_bullet(paragraph, style_shape)
+ kind = bullet.tag.rsplit("}", 1)[-1] if bullet is not None else "buNone"
+ list_like = kind != "buNone"
+ if kind == "buAutoNum":
+ scheme = bullet.get("type", "arabicPeriod")
+ level = paragraph.level
+ for deeper in list(counters):
+ if deeper > level:
+ del counters[deeper]
+ previous = counters.get(level)
+ start = bullet.get("startAt")
+ if start is not None and (direct or previous is None or previous[0] != scheme):
+ number = int(start)
+ else:
+ number = previous[1] + 1 if previous and previous[0] == scheme else 1
+ if previous and previous[0] == scheme and number != previous[1] + 1:
+ number_jump = True
+ counters[level] = (scheme, number)
+ marker = _numbered_marker(number, scheme)
+ else:
+ marker = "-"
+ if kind == "buNone":
+ counters.clear()
+ if paragraphs:
+ literal_numbered = bool(re.match(r"^\d+[.)]\s", text))
+ previous_numbered = bool(re.match(r"^\d+[.)]\s", paragraphs[-1]))
+ paragraphs.append("\n" if (list_like and previous_list) or
+ (literal_numbered and previous_numbered) else "\n\n")
if list_like:
indent = " " * max(paragraph.level, 0)
- paragraphs.append(f"{indent}- {text}")
+ paragraphs.append(f"{indent}{marker} {text}")
else:
paragraphs.append(text)
+ previous_list = list_like
- if list_like:
- return "\n".join(paragraphs)
- return "\n\n".join(paragraphs)
+ markdown = "".join(paragraphs)
+ if number_jump:
+ # Markdown renderers renumber jumps/restarts. Literal labels retain
+ # each source number even when native list semantics cannot map.
+ markdown = re.sub(r"^(\s*)(\d+)\. ", r"\1\2\\. ", markdown, flags=re.M)
+ return markdown
def table_to_markdown(table: object, shape: object = None) -> str:
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/web_to_md.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/web_to_md.py
index 2e589e7f..a63b9409 100644
--- a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/web_to_md.py
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/source_to_md/web_to_md.py
@@ -539,13 +539,13 @@ def fetch_url(url: str) -> tuple[str, str]:
return _decode_response_text(response), response.url
-def clean_title(title: str) -> str:
- """Remove common site suffixes from a title."""
+def clean_title(title: str, site_name: str = "") -> str:
+ """Remove only a trailing site name verified by page metadata."""
if not title:
return ""
- # Remove site name suffixes often found in Chinese titles
- clean = re.sub(r"[-_|].*?(政府|门户|网站|委员会).*$", "", title)
- return clean.strip()
+ if site_name.strip():
+ title = re.sub(r"\s*[-_|]\s*" + re.escape(site_name.strip()) + r"\s*$", "", title)
+ return title.strip()
_FILENAME_TRANSLITERATIONS = str.maketrans({
@@ -798,7 +798,7 @@ def extract_metadata(soup: BeautifulSoup, url: str) -> dict[str, str]:
# 1. Title
title_tag = soup.title
- title = clean_title(title_tag.string if title_tag else "")
+ title = title_tag.get_text() if title_tag else ""
# 2. Meta tags
metas = {}
@@ -807,6 +807,7 @@ def extract_metadata(soup: BeautifulSoup, url: str) -> dict[str, str]:
content = meta.get("content")
if name and content:
metas[name.lower()] = content.strip()
+ title = clean_title(title, metas.get("og:site_name", ""))
# 3. Date Extraction Strategies
date = (
@@ -878,37 +879,46 @@ def extract_metadata(soup: BeautifulSoup, url: str) -> dict[str, str]:
def find_main_content(soup: BeautifulSoup) -> Tag | None:
"""Find the most likely main content container in a page."""
- # 1. Clean up first (remove known clutter)
- for tag in soup(["script", "style", "nav", "header", "footer", "aside", "noscript", "iframe"]):
+ # Identify explicit body containers before removing page-shell semantics.
+ candidates = []
+ for selector in CONFIG["content_selectors"]:
+ elements = soup.find_all(selector["name"]) if "name" in selector else soup.find_all(**selector)
+ for element in elements:
+ if not any(element is existing for existing in candidates):
+ candidates.append(element)
+ for tag in soup(["script", "style", "nav", "noscript", "iframe"]):
tag.decompose()
+ for tag in soup(["header", "footer", "aside"]):
+ if not any(tag is body or body in tag.parents for body in candidates):
+ tag.decompose()
+
+ # Keep the original length/score gates: selector matches can be breadcrumbs
+ # or related-story cards. Merge only independently qualified body roots.
+ qualified = []
+ for element in candidates:
+ if element.parent is None:
+ continue
+ text = element.get_text(strip=True)
+ score = len(text) + 2 * len(re.findall(r'[\u4e00-\u9fa5]', text))
+ if len(text) >= 100 and score >= 200:
+ qualified.append(element)
+ candidates = qualified
+ roots = [element for element in candidates if element.parent is not None
+ and not any(parent is other for parent in element.parents for other in candidates)]
+ if roots:
+ if len(roots) == 1:
+ return roots[0]
+ combined = soup.new_tag("div")
+ # find_all gives document order, regardless of selector priority.
+ for element in list(soup.find_all(True)):
+ if any(element is root for root in roots):
+ combined.append(element.extract())
+ return combined
best_element = None
max_score = 0
- # 2. Strategy A: Check specific classes/ids
- for selector in CONFIG["content_selectors"]:
- if "name" in selector:
- # Tag name match (article, main)
- elements = soup.find_all(selector["name"])
- else:
- # Class or ID match
- elements = soup.find_all(attrs=selector)
-
- for el in elements:
- # Score based on text length and chinese character count
- text = el.get_text(strip=True)
- length = len(text)
- if length < 100:
- continue
-
- chinese_count = len(re.findall(r'[\u4e00-\u9fa5]', text))
- score = length + (chinese_count * 2)
-
- if score > max_score:
- max_score = score
- best_element = el
-
- # 3. Strategy B: If no specific container found, look for dense text areas with paragraphs
+ # Without explicit containers, retain the existing dense-text fallback.
if not best_element or max_score < 200:
for div in soup.find_all("div"):
p_count = len(div.find_all("p", recursive=False))
@@ -1090,6 +1100,43 @@ def simple_html_to_markdown_traversal(
if node.name in ['script', 'style', 'comment', 'meta', 'link']:
return ""
+ if node.name in {'ol', 'ul'}:
+ items = node.find_all('li', recursive=False)
+ ordered = node.name == 'ol'
+ reversed_list = ordered and node.has_attr('reversed')
+ try:
+ counter = int(node.get('start', len(items) if reversed_list else 1))
+ except (ValueError, TypeError):
+ counter = 1
+ step = -1 if reversed_list else 1
+ numbers = []
+ for item in items:
+ try:
+ counter = int(item.get('value', counter))
+ except (ValueError, TypeError):
+ pass
+ numbers.append(counter)
+ counter += step
+ if ordered and (reversed_list or node.get('type', '1') != '1'
+ or any(b != a + 1 for a, b in zip(numbers, numbers[1:]))):
+ # Markdown cannot represent jumps, reversal, or letter styles.
+ # Resolve links/images first, then retain HTML list semantics.
+ for link in node.find_all('a', href=True):
+ href = link['href']
+ if href.lower().startswith('javascript:'):
+ del link['href']
+ elif not href.startswith('#') and urlparse(href).scheme.lower() not in {'mailto', 'tel'}:
+ link['href'] = urljoin(page_url, href)
+ return '\n\n' + str(node) + '\n\n'
+ rendered = []
+ for number, item in zip(numbers, items):
+ body = ''.join(traverse(child) for child in item.children).strip()
+ body_lines = body.splitlines()
+ marker = f'{number}. ' if ordered else '- '
+ indent = ' ' * len(marker)
+ rendered.append(marker + ('\n' + indent).join(body_lines))
+ return '\n\n' + '\n'.join(rendered) + '\n\n'
+
# Handle Block Elements
is_block = node.name in ['p', 'div', 'h1', 'h2', 'h3', 'h4',
'h5', 'h6', 'li', 'blockquote', 'pre', 'hr', 'table', 'tr',
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_pdf_header_footer_position.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_pdf_header_footer_position.py
new file mode 100644
index 00000000..8f938496
--- /dev/null
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_pdf_header_footer_position.py
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+"""Regression tests for position-aware PDF header and footer removal.
+
+Repeated edge text must not erase signatures, and footer lines must not join
+body dates before date/page-number cleanup.
+"""
+
+from __future__ import annotations
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+SCRIPTS_DIR = Path(__file__).resolve().parents[1]
+BACKEND_DIR = SCRIPTS_DIR / "source_to_md"
+if str(BACKEND_DIR) not in sys.path:
+ sys.path.insert(0, str(BACKEND_DIR))
+
+import fitz # noqa: E402
+import pdf_to_md # noqa: E402
+
+
+class HeaderFooterPositionTests(unittest.TestCase):
+ def _convert(self, last_page_lines: list[tuple[float, str]]) -> str:
+ with tempfile.TemporaryDirectory() as tmp:
+ source = Path(tmp) / "signature.pdf"
+ with fitz.open() as doc:
+ for number in range(1, 6):
+ page = doc.new_page(width=600, height=800)
+ page.insert_text((72, 40), "Example Company", fontname="helv", fontsize=12)
+ page.insert_text((72, 200), f"Body of section {number}.", fontname="helv", fontsize=12)
+ page.insert_text((72, 700), "Example Footer", fontname="helv", fontsize=12)
+ if number == 5:
+ for y, text in last_page_lines:
+ page.insert_text((72, y), text, fontname="helv", fontsize=12)
+ page.insert_text((290, 770), str(number), fontname="helv", fontsize=12)
+ doc.save(source)
+ return pdf_to_md.extract_pdf_to_markdown(str(source), images="none")
+
+ def test_signature_equal_to_header_is_kept_and_header_removed(self) -> None:
+ markdown = self._convert([(400, "Example Company"), (430, "October 2026")])
+ self.assertEqual(markdown.count("Example Company"), 1)
+ self.assertIn("October 2026", markdown)
+ self.assertIn("Example Company", markdown.split("")[1])
+
+ def test_footer_page_number_does_not_swallow_body_date(self) -> None:
+ markdown = self._convert([(430, "October 2026")])
+ self.assertIn("October 2026", markdown)
+ self.assertNotIn("October 2026 5", markdown)
+ self.assertNotIn("Example Company", markdown)
+
+ def test_signature_equal_to_header_inside_footer_is_kept(self) -> None:
+ markdown = self._convert([(730, "Example Company"), (750, "October 2026")])
+ self.assertEqual(markdown.count("Example Company"), 1)
+ self.assertIn("October 2026", markdown)
+ self.assertIn("Example Company", markdown.split("")[1])
+ self.assertNotIn("Example Footer", markdown)
+
+ def test_date_and_page_number_inside_footer_are_removed(self) -> None:
+ markdown = self._convert([(730, "November 2025 8")])
+ self.assertNotIn("November 2025", markdown)
+ self.assertIn("Body of section 5.", markdown)
+
+ def test_date_and_page_number_inside_body_are_kept(self) -> None:
+ markdown = self._convert([(430, "November 2025 8")])
+ self.assertIn("November 2025 8", markdown)
+
+ def test_line_crossing_header_boundary_is_kept(self) -> None:
+ markdown = self._convert([(125, "Example Company")])
+ self.assertEqual(markdown.count("Example Company"), 1)
+
+ def test_repeated_footer_text_inside_body_is_kept(self) -> None:
+ markdown = self._convert([(400, "Example Footer")])
+ self.assertEqual(markdown.count("Example Footer"), 1)
+
+ def test_body_line_in_block_crossing_header_boundary_is_kept(self) -> None:
+ markdown = self._convert([(110, "Example Company"), (125, "Example Company")])
+ self.assertEqual(markdown.count("Example Company"), 1)
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_pdf_table_continuation.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_pdf_table_continuation.py
new file mode 100644
index 00000000..4a5d9c5f
--- /dev/null
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_pdf_table_continuation.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+"""Regression tests for merging PDF tables split across a page break.
+
+Only page-break furniture may separate two halves of one table; a numeric or
+labelled paragraph between same-header tables is content and must survive.
+"""
+
+from __future__ import annotations
+
+import sys
+import unittest
+from pathlib import Path
+
+SCRIPTS_DIR = Path(__file__).resolve().parents[1]
+BACKEND_DIR = SCRIPTS_DIR / "source_to_md"
+if str(BACKEND_DIR) not in sys.path:
+ sys.path.insert(0, str(BACKEND_DIR))
+
+import pdf_to_md # noqa: E402
+
+FIRST = "| A | B | C |\n|---|---|---|\n| one | two | three |"
+SECOND = "| A | B | C |\n|---|---|---|\n| four | five | six |"
+MERGED = "| A | B | C |\n|---|---|---|\n| one | two | three |\n| four | five | six |"
+
+
+def _merge(markdown: str) -> str:
+ return pdf_to_md.merge_markdown_continuation_tables(markdown)
+
+
+class ContinuationMergeTests(unittest.TestCase):
+ def test_page_break_with_printed_page_number_merges(self) -> None:
+ source = f"{FIRST}\n\n12\n\n\n\n{SECOND}"
+ self.assertEqual(_merge(source), MERGED)
+
+ def test_blank_gap_merges(self) -> None:
+ self.assertEqual(_merge(f"{FIRST}\n\n{SECOND}"), MERGED)
+
+ def test_numeric_paragraph_without_page_break_is_kept(self) -> None:
+ for number in ("3000000", "2026", "42"):
+ with self.subTest(number=number):
+ source = f"{FIRST}\n{number}\n{SECOND}"
+ self.assertEqual(_merge(source), source)
+
+ def test_long_number_at_page_break_is_kept(self) -> None:
+ source = f"{FIRST}\n\n3000000\n\n\n\n{SECOND}"
+ self.assertEqual(_merge(source), source)
+
+ def test_label_paragraph_between_tables_is_kept(self) -> None:
+ source = f"{FIRST}\n\n国有企业\n\n\n\n{SECOND}"
+ self.assertEqual(_merge(source), source)
+
+ def test_lines_after_a_lone_table_are_kept(self) -> None:
+ for tail in ("\n\nSome text", "\n\n2026\n\nSome text", "\n\n\n\n7"):
+ with self.subTest(tail=tail):
+ source = FIRST + tail
+ self.assertEqual(_merge(source), source)
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_source_to_md_content_loss_docx.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_source_to_md_content_loss_docx.py
new file mode 100644
index 00000000..bd61f3b0
--- /dev/null
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_source_to_md_content_loss_docx.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""PPT Master - DOCX content preservation regressions.
+
+Usage: python3 -m unittest tests.test_source_to_md_content_loss_docx
+Dependencies: python-docx, mammoth
+"""
+
+import io
+import json
+import sys
+import tempfile
+import unittest
+import zipfile
+from contextlib import redirect_stdout
+from pathlib import Path
+from xml.etree import ElementTree as ET
+
+from docx import Document
+from docx.oxml import parse_xml
+from docx.oxml.ns import nsdecls
+from docx.opc.constants import RELATIONSHIP_TYPE as RT
+
+SCRIPTS_DIR = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(SCRIPTS_DIR))
+sys.path.insert(0, str(SCRIPTS_DIR / "source_to_md"))
+import doc_to_md # noqa: E402
+
+
+class DocxContentLossTests(unittest.TestCase):
+ def _convert(self, document, patch_archive=None):
+ with tempfile.TemporaryDirectory() as tmp:
+ source = Path(tmp) / "source.docx"
+ if patch_archive:
+ buf = io.BytesIO()
+ document.save(buf)
+ patch_archive(buf, source)
+ else:
+ document.save(source)
+ with redirect_stdout(io.StringIO()):
+ markdown = doc_to_md.convert_to_markdown(str(source))
+ profile = json.loads(source.with_suffix(".conversion_profile.json").read_text(encoding="utf-8"))
+ return markdown, profile
+
+ def test_f01_nested_table_body_is_preserved(self) -> None:
+ doc = Document()
+ cell = doc.add_table(rows=1, cols=1).cell(0, 0)
+ cell.text = "Outer body"
+ cell.add_table(rows=1, cols=1).cell(0, 0).text = "NESTED_SENTINEL_3000000"
+ md, _ = self._convert(doc)
+ self.assertIn("Outer body", md)
+ self.assertIn("NESTED_SENTINEL_3000000", md)
+
+ def test_f02_content_control_display_text_is_preserved(self) -> None:
+ doc = Document()
+ cell = doc.add_table(rows=1, cols=1).cell(0, 0)
+ cell.text = "Before"
+ cell._tc.append(parse_xml('
Second |', md)
+ self.assertNotIn('
', md)
diff --git a/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_source_to_md_content_loss_web.py b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_source_to_md_content_loss_web.py
new file mode 100644
index 00000000..73b3403b
--- /dev/null
+++ b/plugins/codex/plugins/ppt-master/skills/ppt-master/scripts/tests/test_source_to_md_content_loss_web.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+"""PPT Master - Offline web content preservation regressions.
+
+Usage: python3 -m unittest tests.test_source_to_md_content_loss_web
+Dependencies: beautifulsoup4, requests
+"""
+
+import io
+import sys
+import tempfile
+import unittest
+from contextlib import redirect_stdout
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import patch
+
+SCRIPTS_DIR = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(SCRIPTS_DIR))
+sys.path.insert(0, str(SCRIPTS_DIR / 'source_to_md'))
+import web_to_md # noqa: E402
+
+
+class WebContentLossTests(unittest.TestCase):
+ def _convert(self, body, head='
' + 'Body text. ' * 30 + '
' + '' + tag + '>' + '') + for text in ('ARTICLE_TITLE', 'BODY_NOTE', 'AUTHOR_SIGNATURE'): + self.assertIn(text, md) + for text in ('SITE_HEADER', 'SITE_NAV', 'SITE_FOOTER', 'SITE_AD', 'ARTICLE_NAV'): + self.assertNotIn(text, md) + + def test_f07_parallel_articles_are_retained_in_source_order(self) -> None: + md = self._convert('' + 'First body. ' * 40 + '
' + 'SECOND_BODY ' * 30 + '
' + 'First body. ' * 40 + '
' + 'SECOND_BODY ' * 30 + '
Body
', 'Body
', 'Paragraph {i}: ' + 'Project investment details. ' * 12 + '
' for i in range(8)) + for clutter in ('' + 'x' * 150 + '
' + 'DENSE_BODY ' * 40 + '
' + 'NESTED_BODY ' * 40 + '