mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-19 20:16:34 +00:00
chore: publish from main
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
---
|
||||
name: convert-word-to-md
|
||||
description: 'Converts Word (.docx) documents into Markdown so their contents can be accurately analyzed, summarized, searched, or extracted from. Use this skill whenever the user shares, references, or asks about a .docx file — even if they don''t say "convert" or "markdown" explicitly. This includes requests to "read", "summarize", "review", "extract data from", "compare", or "analyze" a Word document, resume, report, contract, or proposal. Always run the bundled conversion script to produce Markdown first; do not attempt to parse .docx content directly or write ad-hoc conversion code. Also use this skill for batch requests involving a whole folder of Word documents. IMPORTANT: When the user references a folder or set of documents containing multiple file types (.pdf, .docx, .xlsx), invoke ALL three sibling skills — convert-pdf-to-md, convert-word-to-md, and convert-excel-to-md — so no file type is silently skipped.'
|
||||
---
|
||||
|
||||
# Convert Word to Markdown
|
||||
|
||||
## When to use this skill
|
||||
|
||||
Trigger this skill any time there is a `.docx` file that needs to be
|
||||
understood or processed — for example, a user attaches a Word document and
|
||||
asks questions about it, wants a summary, wants specific data pulled out, or
|
||||
wants multiple Word documents in a folder processed together. Word's native
|
||||
`.docx` format is a zipped XML bundle that is not reliably readable as plain
|
||||
text, so always convert it to Markdown first using the script in this
|
||||
skill rather than trying to open or parse the file directly.
|
||||
|
||||
This skill only supports `.docx`. If asked to convert a legacy `.doc` file,
|
||||
tell the user it isn't supported and ask them to re-save it as `.docx`
|
||||
(Word: File > Save As > Word Document (.docx)) first.
|
||||
|
||||
**Mixed file types:** When the user references a folder or set of documents
|
||||
containing multiple supported file types (`.pdf`, `.docx`, `.xlsx`), this
|
||||
skill handles only `.docx` files. The agent MUST also invoke the sibling
|
||||
skills in parallel:
|
||||
- `convert-pdf-to-md` for any `.pdf` files
|
||||
- `convert-excel-to-md` for any `.xlsx` files
|
||||
|
||||
Never process a folder and silently skip a supported file type. All three
|
||||
skills must be invoked together when mixed types are present.
|
||||
|
||||
## Setup (once per environment)
|
||||
|
||||
Before the first conversion in a given environment, follow
|
||||
[`references/setup.md`](references/setup.md) step by step to ensure Python,
|
||||
pip, and the `markitdown` package are installed. Do this proactively rather
|
||||
than guessing whether the environment is ready — the script itself will
|
||||
also fail with a clear pointer back to that file if `markitdown` turns out
|
||||
to be missing, so it's safe to just try the conversion first if you're
|
||||
reasonably confident setup was already done.
|
||||
|
||||
## Usage
|
||||
|
||||
The conversion script lives at `scripts/convert_word_to_md.py`.
|
||||
|
||||
**Output structure:** MarkItDown embeds images as a truncated `data:image/png;base64...` URI
|
||||
placeholder (not real image data), so the script
|
||||
extracts real images directly from the `.docx` and writes a self-contained
|
||||
folder per document instead of a single loose `.md` file:
|
||||
|
||||
```
|
||||
<name>/
|
||||
img/
|
||||
img001.<ext>
|
||||
img002.<ext>
|
||||
...
|
||||
<name>.md (image references are relative: img/imgNNN.ext)
|
||||
```
|
||||
|
||||
If the document has no embedded images, no `img/` folder is created.
|
||||
|
||||
**Single file:**
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
python scripts\convert_word_to_md.py "C:\path\to\document.docx"
|
||||
```
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
python scripts/convert_word_to_md.py "/path/to/document.docx"
|
||||
```
|
||||
|
||||
This creates a `document\` folder next to the source file (containing
|
||||
`document.md` and, if present, `document\img\`). To control the destination
|
||||
folder explicitly:
|
||||
|
||||
```powershell
|
||||
python scripts\convert_word_to_md.py "C:\path\to\document.docx" -o "C:\path\to\output_folder"
|
||||
```
|
||||
|
||||
**A folder of Word documents (batch mode):**
|
||||
|
||||
```powershell
|
||||
python scripts\convert_word_to_md.py "C:\path\to\folder"
|
||||
```
|
||||
|
||||
Add `--recursive` to also include subfolders:
|
||||
|
||||
```powershell
|
||||
python scripts\convert_word_to_md.py "C:\path\to\folder" --recursive
|
||||
```
|
||||
|
||||
Each `.docx` found gets its own `<name>\` output folder next to it by
|
||||
default. Pass `-o "C:\path\to\output_parent"` to collect all the generated
|
||||
`<name>\` folders under a separate parent directory instead (subfolder
|
||||
structure is preserved when combined with `--recursive`).
|
||||
|
||||
After conversion, read the resulting `.md` file(s) to perform the actual
|
||||
analysis the user asked for — the script's job is only to produce accurate
|
||||
Markdown (and images), not to interpret the content.
|
||||
|
||||
## Deciding where output goes
|
||||
|
||||
**Default — always output next to the source file.** The `<name>/` folder
|
||||
is created in the same directory as the source `.docx`. This is the required
|
||||
default for every case. Do NOT override it unless the user explicitly asks
|
||||
for a different location.
|
||||
|
||||
**Only use `-o` when** the user explicitly provides an output path (e.g.,
|
||||
"save the output to `C:\output`", "put the results in `D:\work`"). Do NOT
|
||||
pass `-o` based on the agent's current working directory, the session state
|
||||
folder, or any implied location.
|
||||
|
||||
**If the source file path cannot be fully resolved** — for example, the
|
||||
user provides only a filename with no directory, or the path is ambiguous —
|
||||
use `ask_user` to confirm the full absolute path before running the
|
||||
conversion. Never guess or assume the directory.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `ModuleNotFoundError: No module named 'markitdown'` / exit code 2 | MarkItDown not installed | Follow `references/setup.md` |
|
||||
| `ERROR: Unsupported file type '.doc'` / exit code 3 | Legacy `.doc`, not `.docx` | Ask the user to re-save as `.docx` |
|
||||
| `ERROR: Input path not found` / exit code 3 | Wrong path, or file moved | Confirm the correct path with the user |
|
||||
| `FAILED <file> -> ...` in batch output | That specific file is corrupt, password-protected, or otherwise unreadable | Report which file(s) failed; other files in the batch still succeed |
|
||||
| `NOTE: skipped N non-.docx file(s)` | Folder contains non-Word files | Expected — those files are intentionally ignored |
|
||||
| `WARNING: found N image placeholder(s) ... but extracted M image file(s)` | Mismatch between MarkItDown's placeholder count and images found in `word/media/` (unusual/malformed docx) | Placeholders are left unreplaced rather than risk wrong images; inspect the source file's media manually if images are needed |
|
||||
@@ -0,0 +1,66 @@
|
||||
# Environment Setup for convert-word-to-md
|
||||
|
||||
Follow these steps exactly, in order, before running `scripts/convert_word_to_md.py`
|
||||
for the first time in a given environment. Don't skip steps or improvise
|
||||
alternatives — they're written to be deterministic and safe to re-run.
|
||||
|
||||
## 1. Check Python is available (3.10+)
|
||||
|
||||
```powershell
|
||||
python --version
|
||||
```
|
||||
|
||||
- If this fails (command not found), install Python 3.10 or newer:
|
||||
- Windows: `winget install --id Python.Python.3.12 -e`
|
||||
- macOS: `brew install python@3.12`
|
||||
- Linux (Debian/Ubuntu): `sudo apt-get update && sudo apt-get install -y python3 python3-pip python-is-python3`
|
||||
- If the reported version is older than 3.10, install a newer Python using
|
||||
the same command above (MarkItDown requires 3.10+).
|
||||
|
||||
## 2. Check pip is available
|
||||
|
||||
```powershell
|
||||
python -m pip --version
|
||||
```
|
||||
|
||||
- If this fails, bootstrap pip:
|
||||
|
||||
```powershell
|
||||
python -m ensurepip --upgrade
|
||||
```
|
||||
|
||||
## 3. Install MarkItDown with Word (.docx) support
|
||||
|
||||
Use the `scripts/requirements.txt` file bundled with this skill to install a pinned,
|
||||
known-good version of the dependency:
|
||||
|
||||
```powershell
|
||||
python -m pip install -r scripts/requirements.txt
|
||||
```
|
||||
|
||||
This pulls in `markitdown[docx]` (MarkItDown's Word conversion dependency, which
|
||||
includes `mammoth` for `.docx` file parsing). No extra package is needed — this
|
||||
skill's script uses MarkItDown's built-in Word converter.
|
||||
|
||||
## 4. Verify the install
|
||||
|
||||
```powershell
|
||||
python -c "from markitdown import MarkItDown; print('markitdown OK')"
|
||||
```
|
||||
|
||||
Expect to see `markitdown OK` printed with no errors. If you see
|
||||
`ModuleNotFoundError: No module named 'markitdown'`, repeat step 3 — pip may
|
||||
be installing into a different Python environment than the one being
|
||||
invoked (check `python -m pip --version` shows the same path as `python
|
||||
--version`'s interpreter).
|
||||
|
||||
## Notes
|
||||
|
||||
- This setup only needs to be done once per environment/virtual environment,
|
||||
not once per conversion.
|
||||
- `convert_word_to_md.py` itself also checks for `markitdown` at startup and
|
||||
prints a pointer back to this file if it's missing, so re-running setup is
|
||||
safe and idempotent.
|
||||
- Only `.docx` is supported by this skill. Legacy binary `.doc` files are
|
||||
out of scope — ask the user to re-save the file as `.docx` (e.g., via
|
||||
Word's "Save As") if one is encountered.
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert Word (.docx) documents to Markdown using Microsoft's MarkItDown,
|
||||
with embedded images extracted to real files (MarkItDown only emits a
|
||||
truncated `data:image/...;base64...` placeholder, not real image data).
|
||||
|
||||
Usage:
|
||||
python convert_word_to_md.py <input> [-o OUTPUT] [--recursive]
|
||||
|
||||
<input> may be either:
|
||||
- a path to a single .docx file, or
|
||||
- a path to a directory (batch mode: every .docx file directly inside it
|
||||
is converted; pass --recursive to also descend into subdirectories).
|
||||
|
||||
Output:
|
||||
For each source .docx (named "<name>.docx"), a folder is created
|
||||
containing the Markdown and its images, in this layout:
|
||||
|
||||
<name>/
|
||||
img/
|
||||
img001.<ext>
|
||||
img002.<ext>
|
||||
...
|
||||
<name>.md (image references are relative: img/imgNNN.ext)
|
||||
|
||||
- Single file mode: the "<name>/" folder is created next to the source
|
||||
file, or at -o/--output (treated as the exact destination folder) if
|
||||
given.
|
||||
- Batch/directory mode: a "<name>/" folder is created next to each source
|
||||
file, or under -o/--output (treated as a parent directory, created if
|
||||
missing) if given, preserving relative subfolder structure when
|
||||
--recursive is used.
|
||||
- If a document has no embedded images, no "img/" folder is created.
|
||||
|
||||
Exit codes:
|
||||
0 - all requested conversions succeeded
|
||||
1 - one or more conversions failed (partial success in batch mode)
|
||||
2 - required dependency ("markitdown") is not installed
|
||||
3 - invalid input (path not found, or single-file input is not .docx)
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
EXIT_OK = 0
|
||||
EXIT_CONVERSION_FAILED = 1
|
||||
EXIT_MISSING_DEPENDENCY = 2
|
||||
EXIT_INVALID_INPUT = 3
|
||||
|
||||
_W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
_R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
|
||||
# MarkItDown embeds images as a literal truncated placeholder, e.g.
|
||||
#  -- NOT real base64 data. This pattern
|
||||
# matches that placeholder so it can be swapped for a real relative path.
|
||||
_PLACEHOLDER_IMAGE_RE = re.compile(
|
||||
r'!\[([^\]]*)\]\(data:image/[a-zA-Z0-9.+-]+;base64[^)]*\)'
|
||||
)
|
||||
|
||||
|
||||
def _import_markitdown():
|
||||
"""Import MarkItDown, failing with a clear, actionable message if absent."""
|
||||
try:
|
||||
from markitdown import MarkItDown
|
||||
return MarkItDown
|
||||
except ImportError:
|
||||
print(
|
||||
"ERROR: The 'markitdown' package is not installed.\n"
|
||||
"See references/setup.md for this skill, or run:\n"
|
||||
' pip install "markitdown[docx]"',
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(EXIT_MISSING_DEPENDENCY)
|
||||
|
||||
|
||||
def _document_order_media(docx_path: Path):
|
||||
"""Return [(rel_id, media_zip_path), ...] in the order images appear in
|
||||
word/document.xml (via r:embed / r:id), resolved through
|
||||
word/_rels/document.xml.rels. Returns [] if the document has no body
|
||||
part or no images (e.g. malformed docx falls back gracefully)."""
|
||||
try:
|
||||
with zipfile.ZipFile(docx_path) as z:
|
||||
if "word/document.xml" not in z.namelist() or \
|
||||
"word/_rels/document.xml.rels" not in z.namelist():
|
||||
return []
|
||||
rels_xml = z.read("word/_rels/document.xml.rels")
|
||||
doc_xml = z.read("word/document.xml")
|
||||
except (zipfile.BadZipFile, KeyError, OSError):
|
||||
return []
|
||||
|
||||
try:
|
||||
rels_root = ET.fromstring(rels_xml)
|
||||
doc_root = ET.fromstring(doc_xml)
|
||||
except ET.ParseError:
|
||||
return []
|
||||
|
||||
rel_map = {}
|
||||
for rel in rels_root.findall(f"{{{_REL_NS}}}Relationship"):
|
||||
rel_map[rel.get("Id")] = rel.get("Target")
|
||||
|
||||
ordered_rel_ids = []
|
||||
for elem in doc_root.iter():
|
||||
tag = elem.tag.rsplit("}", 1)[-1]
|
||||
if tag == "blip":
|
||||
rid = elem.get(f"{{{_R_NS}}}embed")
|
||||
elif tag == "imagedata":
|
||||
rid = elem.get(f"{{{_R_NS}}}id")
|
||||
else:
|
||||
rid = None
|
||||
if rid:
|
||||
ordered_rel_ids.append(rid)
|
||||
ordered_media = []
|
||||
for rid in ordered_rel_ids:
|
||||
target = rel_map.get(rid)
|
||||
if not target or "media/" not in target:
|
||||
continue
|
||||
import posixpath
|
||||
media_path = (
|
||||
target.lstrip("/")
|
||||
if target.startswith("/")
|
||||
else posixpath.normpath(
|
||||
target if target.startswith("word/") else posixpath.join("word", target)
|
||||
)
|
||||
)
|
||||
ordered_media.append((rid, media_path))
|
||||
return ordered_media
|
||||
|
||||
|
||||
def _extract_images(docx_path: Path, img_dir: Path):
|
||||
"""Extract embedded images from docx_path into img_dir as img001.ext,
|
||||
img002.ext, ... in document order. Returns the list of written filenames
|
||||
(relative to img_dir), in that same order."""
|
||||
ordered_media = _document_order_media(docx_path)
|
||||
if not ordered_media:
|
||||
return []
|
||||
|
||||
written = []
|
||||
with zipfile.ZipFile(docx_path) as z:
|
||||
names_in_zip = set(z.namelist())
|
||||
for idx, (rid, media_path) in enumerate(ordered_media, start=1):
|
||||
if media_path not in names_in_zip:
|
||||
print(f"WARNING: {media_path} (rel {rid}) not found in {docx_path}", file=sys.stderr)
|
||||
continue
|
||||
ext = Path(media_path).suffix.lstrip(".").lower() or "bin"
|
||||
if ext == "jpg":
|
||||
ext = "jpeg"
|
||||
out_name = f"img{idx:03d}.{ext}"
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
(img_dir / out_name).write_bytes(z.read(media_path))
|
||||
written.append(out_name)
|
||||
return written
|
||||
|
||||
|
||||
def _rewrite_image_refs(markdown_text: str, image_files) -> str:
|
||||
"""Replace MarkItDown's truncated base64 image placeholders with real
|
||||
relative img/imgNNN.ext references, in left-to-right order. If the
|
||||
counts don't match (unexpected), the placeholders are left as-is rather
|
||||
than risk mismatched references."""
|
||||
matches = list(_PLACEHOLDER_IMAGE_RE.finditer(markdown_text))
|
||||
if not matches:
|
||||
return markdown_text
|
||||
if len(matches) != len(image_files):
|
||||
print(
|
||||
f"WARNING: found {len(matches)} image placeholder(s) in markdown but "
|
||||
f"extracted {len(image_files)} image file(s); leaving placeholders "
|
||||
"unreplaced to avoid mismatched references.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return markdown_text
|
||||
|
||||
counter = {"i": 0}
|
||||
|
||||
def _replace(m):
|
||||
name = image_files[counter["i"]]
|
||||
counter["i"] += 1
|
||||
return f""
|
||||
|
||||
return _PLACEHOLDER_IMAGE_RE.sub(_replace, markdown_text)
|
||||
|
||||
|
||||
def convert_one(md, source: Path, dest_dir: Path) -> bool:
|
||||
"""Convert a single .docx file to a "<name>/" folder containing the
|
||||
Markdown file and an "img/" folder of extracted images. Returns True on
|
||||
success."""
|
||||
try:
|
||||
result = md.convert(str(source))
|
||||
except ImportError as exc:
|
||||
print(
|
||||
f"ERROR: A required dependency for converting '{source.name}' is not installed.\n"
|
||||
f" {exc}\n"
|
||||
"See references/setup.md for this skill, or run:\n"
|
||||
' pip install "markitdown[docx]"',
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(EXIT_MISSING_DEPENDENCY)
|
||||
except Exception as exc: # noqa: BLE001 - surface any conversion error
|
||||
print(f"FAILED {source} -> {exc}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
try:
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
image_files = _extract_images(source, dest_dir / "img")
|
||||
text = _rewrite_image_refs(result.text_content, image_files)
|
||||
md_path = dest_dir / f"{source.stem}.md"
|
||||
md_path.write_text(text, encoding="utf-8")
|
||||
except OSError as exc:
|
||||
print(f"FAILED {source} -> could not write output in {dest_dir}: {exc}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
img_note = f", {len(image_files)} image(s)" if image_files else ""
|
||||
print(f"OK {source} -> {md_path}{img_note}")
|
||||
return True
|
||||
|
||||
|
||||
def find_docx_files(root: Path, recursive: bool):
|
||||
"""Return (docx_files, skipped_count) for files directly/recursively under root."""
|
||||
pattern_iter = root.rglob("*") if recursive else root.iterdir()
|
||||
docx_files = []
|
||||
skipped = 0
|
||||
for entry in pattern_iter:
|
||||
if entry.is_dir():
|
||||
continue
|
||||
if entry.suffix.lower() == ".docx":
|
||||
docx_files.append(entry)
|
||||
else:
|
||||
skipped += 1
|
||||
return sorted(docx_files), skipped
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("input", help="Path to a .docx file or a directory of .docx files")
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
help=(
|
||||
"Destination folder for the '<name>/' output (single-file mode), "
|
||||
"or parent directory under which each '<name>/' output folder is "
|
||||
"created (batch mode)"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--recursive", action="store_true",
|
||||
help="When input is a directory, also search subdirectories",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
#MarkItDown = _import_markitdown()
|
||||
#md = MarkItDown()
|
||||
|
||||
source = Path(args.input)
|
||||
if not source.exists():
|
||||
print(f"ERROR: Input path not found: {source}", file=sys.stderr)
|
||||
return EXIT_INVALID_INPUT
|
||||
|
||||
if source.is_file() and source.suffix.lower() != ".docx":
|
||||
print(
|
||||
f"ERROR: Unsupported file type '{source.suffix}'. "
|
||||
"This skill only converts .docx files.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return EXIT_INVALID_INPUT
|
||||
|
||||
MarkItDown = _import_markitdown()
|
||||
md = MarkItDown()
|
||||
|
||||
if source.is_file():
|
||||
dest_dir = Path(args.output) if args.output else source.parent / source.stem
|
||||
return EXIT_OK if convert_one(md, source, dest_dir) else EXIT_CONVERSION_FAILED
|
||||
|
||||
# Directory / batch mode
|
||||
docx_files, skipped = find_docx_files(source, args.recursive)
|
||||
if skipped:
|
||||
print(f"NOTE: skipped {skipped} non-.docx file(s) in {source}")
|
||||
if not docx_files:
|
||||
print(f"ERROR: No .docx files found under {source}", file=sys.stderr)
|
||||
return EXIT_INVALID_INPUT
|
||||
|
||||
out_dir = Path(args.output) if args.output else None
|
||||
success_count = 0
|
||||
for docx_path in docx_files:
|
||||
if out_dir is not None:
|
||||
rel = docx_path.relative_to(source)
|
||||
dest_dir = out_dir / rel.parent / docx_path.stem
|
||||
else:
|
||||
dest_dir = docx_path.parent / docx_path.stem
|
||||
if convert_one(md, docx_path, dest_dir):
|
||||
success_count += 1
|
||||
|
||||
total = len(docx_files)
|
||||
print(f"\nConverted {success_count}/{total} file(s).")
|
||||
return EXIT_OK if success_count == total else EXIT_CONVERSION_FAILED
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1 @@
|
||||
markitdown[docx]>=0.1.0
|
||||
Reference in New Issue
Block a user