01iter_inner_content() で本文と表を文書の順に読み、表記ルールに当てる確認済
Document.iter_inner_content() は段落と表を文書に現れる順に返す。表のセルも同じ関数で再帰的に読み、表記ルールの CSV に当たった箇所を、何番目のブロックか・どの表のどのセルか付きで一覧にする。段落内のハイパーリンクの表示文字列と URL も拾う。
- style_terms.csv(term, category, action)を読み込む。
- Document.iter_inner_content() で段落(Paragraph)と表(Table)を文書の順に受け取る。doc.paragraphs と doc.tables を別々に回すと、表が本文のどこにあったかが分からなくなる。
- 表は行とセルを回し、セルごとに iter_inner_content() を再帰で呼ぶ。セルの中の入れ子の表もこれで読める。結合セルは row.cells に繰り返し現れるので、一度読んだセルは飛ばす。
- 段落の iter_inner_content() で Run と Hyperlink を分け、Hyperlink の text と address を記録する。
- 当たった箇所を out_term_hits.csv に書き出し、担当者が本文で前後の文脈を確かめる。
"""Check .docx text against a style list in document order, including tables and hyperlinks."""
import csv
from pathlib import Path
from docx import Document
from docx.table import Table
from docx.text.hyperlink import Hyperlink
TERMS = Path("samples/style_terms.csv")
DOCS = sorted(Path("samples").glob("*.docx"))
def load_terms(path):
with open(path, encoding="utf-8-sig", newline="") as f:
return list(csv.DictReader(f))
def iter_paragraphs(container, where=""):
"""Yield (location, paragraph) for body paragraphs and every table cell, in document order."""
for i, block in enumerate(container.iter_inner_content(), 1):
if isinstance(block, Table):
seen = []
for r, row in enumerate(block.rows, 1):
for c, cell in enumerate(row.cells, 1):
if cell in seen: # merged cells repeat across grid positions
continue
seen.append(cell)
yield from iter_paragraphs(cell, "%sT%d-r%dc%d/" % (where, i, r, c))
else:
yield "%sB%d(%s)" % (where, i, block.style.name), block
def main():
terms = load_terms(TERMS)
hits, links = [], []
for path in DOCS:
for where, para in iter_paragraphs(Document(path)):
for item in para.iter_inner_content():
if isinstance(item, Hyperlink):
links.append((path.name, where, item.text, item.address))
for t in terms:
if t["term"] in para.text:
hits.append((path.name, where, t["term"], t["action"], para.text.strip()))
with open("out_term_hits.csv", "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
w.writerow(["file", "where", "term", "action", "text"])
w.writerows(hits)
print("hits:", len(hits), "links:", len(links))
for h in hits[:6]:
print(" | ".join(h[:4]))
for link in links:
print("link:", " | ".join(link))
if __name__ == "__main__":
main()実行結果(2026-09-12)
hits: 9 links: 1
doc_a.docx | B5(Normal) | 最高 | 根拠を確認する
doc_a.docx | B5(Normal) | 業界初 | 根拠を確認する
doc_a.docx | B5(Normal) | 絶対 | 言い換える
doc_a.docx | B6(Normal) | No.1 | 調査の出典・時点・範囲を併記する
doc_a.docx | B7(Normal) | 下さい | 「ください」に統一
doc_a.docx | B7(Normal) | 問合せ | 「問い合わせ」に統一
link: docs_doc_c.docx | B4(Normal) | 社内ポータル | https://example.com/portal/expensepython-docx 1.2.0 で、フォルダ内の .docx を Document.iter_inner_content() で文書の順に読み、表のセル(入れ子の表を含む)も再帰でたどって、style_terms.csv の term を含む段落を、ブロック番号・表の位置・スタイル名つきで CSV に出すコードを書いて。結合セルは重複して数えないこと。ハイパーリンクの URL も一覧にすること。
判定は語の単純な包含なので、否定文や引用の中の語も当たる。直すかどうかは人が決める。doc.paragraphs は変更履歴(挿入・削除)の中の段落を含まないと公式に書かれているので、変更履歴の残る文書は Word で承諾か却下を済ませてから読む。ヘッダー・フッターの文字はこの走査に入らない(セクションから別に読む)。
出典の該当箇所
Generate each Paragraph or Table in this document in document order.
Generate the runs and hyperlinks in this paragraph, in the order they appear.
Add BlockItemContainer.iter_inner_content()
Note that paragraphs within revision marks such as <w:ins> or <w:del> do not appear in this list.
02結合セルと入れ子の表を落とさずに Word の表を CSV にする確認済
doc.tables は本文の最上位の表だけを返し、セルの中の入れ子の表は含まない。row.cells は結合セルを、それが占める桁の数だけ繰り返して返す。この 2 点を踏まえ、表を桁の位置ごとに 1 行の CSV にし、入れ子の表はセルの位置つきでたどる。
- doc.tables で最上位の表を取る。入れ子の表は cell.tables で別に取る。
- 行ごとに row.grid_cols_before から桁番号を数え始める。行の先頭のセルが省かれた表でも列がずれない。
- row.cells を順に読み、一度出たセルと等しいセル(結合セルの繰り返し)に merged_repeat の印を付ける。cell.grid_span で横の結合幅も記録する。
- 繰り返しでないセルは cell.tables を再帰で読み、表の経路(t1>r1c2.t1 など)を付ける。
- out_tables.csv で merged_repeat の行を除けば、元の表の見た目に近い一覧になる。
"""Export Word tables to CSV, keeping merged and nested cells visible."""
import csv
from pathlib import Path
from docx import Document
DOCS = sorted(Path("samples").glob("*.docx"))
def table_rows(table, path):
"""Yield one record per grid position; nested tables are walked too."""
seen = []
for r, row in enumerate(table.rows):
col = row.grid_cols_before # skip grid columns omitted at the start of the row
for cell in row.cells:
repeat = cell in seen # a merged cell appears once per grid column it covers
if not repeat:
seen.append(cell)
yield {"table": path, "row": r, "col": col, "span": cell.grid_span,
"merged_repeat": repeat, "text": cell.text.strip().replace("\n", " / ")}
if not repeat:
for k, inner in enumerate(cell.tables, 1):
yield from table_rows(inner, "%s>r%dc%d.t%d" % (path, r, col, k))
col += 1
def main():
records = []
for path in DOCS:
doc = Document(path)
for i, table in enumerate(doc.tables, 1):
for rec in table_rows(table, "t%d" % i):
records.append(dict(rec, file=path.name))
print("%s: top-level tables %d" % (path.name, len(doc.tables)))
fields = ["file", "table", "row", "col", "span", "merged_repeat", "text"]
with open("out_tables.csv", "w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
w.writerows(records)
nested = sorted({r["file"] + ":" + r["table"] for r in records if ">" in r["table"]})
print("cells:", len(records), "merged repeats:", sum(r["merged_repeat"] for r in records))
print("nested tables:", nested)
if __name__ == "__main__":
main()実行結果(2026-09-12)
doc_a.docx: top-level tables 1
docs_doc_c.docx: top-level tables 1
cells: 22 merged repeats: 1
nested tables: ['docs_doc_c.docx:t1>r1c2.t1']python-docx 1.2.0 で、.docx のすべての表を CSV にするコードを書いて。行の先頭が省かれた桁は row.grid_cols_before で数え、結合セルの繰り返しには印を付け、セルの中の入れ子の表は cell.tables で再帰的にたどり、どの表のどのセルかが分かる経路を列に入れること。
row.cells は行の末尾で省かれた桁(grid_cols_after)を返さないので、行によってセルの数が違うことがある。この見本では、入れ子の表を持つセルの cell.text に入れ子の表の文字は含まれなかった。縦に結合したセルも下の行で繰り返し現れる。数字の列を集計する前に、繰り返しを除いたかを人が確かめる。
出典の該当箇所
Note that only tables appearing at the top level of the document appear in this list; a table nested inside a table cell does not appear.
This is what _Row.cells does by default.
Count of unpopulated grid-columns before the first cell in this row.
03見出しスタイルから文書の骨組みを抜き出し、段の飛びを見つける確認済
組み込みの見出しスタイルは、日本語版の Word で作った文書でも、ファイルの中では英語名(Heading 1 など)で保存される。この名前で段落を拾い、文書ごとの見出しの一覧を作る。見出し 2 の次に見出し 4 が来るような段の飛びと、見出し 1 の無い文書に印を付ける。
- doc.paragraphs を順に読み、paragraph.style.name が「Heading 1」〜「Heading 9」のものを見出しとする。
- 直前の見出しより 2 段以上深い見出しに skipped level の印を付ける。
- 見出し 1 が 1 つも無い文書にも印を付ける。
- 段に応じて字下げした見出しを out_outline.csv に書き、画面にも出す。
- 印の付いた箇所は Word で見出しスタイルを直す。Word 文書からスライドの下書きを作る前の点検にも使える。
"""Build a heading outline from Word's built-in Heading styles and flag skipped levels."""
import csv
import re
from pathlib import Path
from docx import Document
DOCS = sorted(Path("samples").glob("*.docx"))
HEADING = re.compile(r"^Heading ([1-9])$") # built-in names are stored in English
def outline(doc):
rows, prev = [], 0
for i, p in enumerate(doc.paragraphs, 1):
m = HEADING.match(p.style.name or "")
if not m:
continue
level = int(m.group(1))
issue = "skipped level" if level > prev + 1 else ""
rows.append({"paragraph": i, "level": level, "heading": p.text.strip(), "issue": issue})
prev = level
return rows
def main():
with open("out_outline.csv", "w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=["file", "paragraph", "level", "heading", "issue"])
w.writeheader()
for path in DOCS:
rows = outline(Document(path))
if not any(r["level"] == 1 for r in rows):
rows.insert(0, {"paragraph": 0, "level": 0, "heading": "", "issue": "no Heading 1"})
print("==", path.name)
for r in rows:
w.writerow(dict(r, file=path.name))
mark = " <-- " + r["issue"] if r["issue"] else ""
print(" " * max(r["level"] - 1, 0) + r["heading"] + mark)
if __name__ == "__main__":
main()実行結果(2026-09-12)
== doc_a.docx
クラウド会計「サンプル会計」製品案内(架空)
1. 特長
2. 料金と条件
参考
== docs_doc_c.docx
経費精算の新しい手順(架空)
1. 背景
1.1.1 旧手順との違い <-- skipped level
2. 手順
付録 A. 用語python-docx で、フォルダ内の .docx から段落スタイル名が Heading 1〜9 の段落を拾い、段に応じて字下げした見出しの一覧を CSV にするコードを書いて。直前より 2 段以上深い見出しと、Heading 1 の無い文書に印を付けること。
スタイル名で判定するので、太字や文字の大きさだけで見出しに見せた段落は拾えない。独自に作ったスタイルは「Heading N」という名前でなければ拾えない。表の中の段落は doc.paragraphs に入らない(試験で確認)。
出典の該当箇所
Built-in styles are stored in a WordprocessingML file using their English name, e.g. ‘Heading 1’, even though users working on a localized version of Word will see native language names in the UI
ParagraphStyle object representing the style assigned to this paragraph.
04文書のプロパティとヘッダー・フッターをフォルダ単位で一覧にする確認済
core_properties から題名・作成者・最終更新者・版・更新日時を読み、セクションごとのヘッダーとフッターの文字と合わせて CSV にする。社外に出す前に、作成者名が残っていないか、フッターの文書番号が抜けていないかを一度に点検できる。
- Document(path).core_properties で title・author・last_modified_by・revision・modified を読む。日時はタイムゾーンの無い UTC で返る。
- doc.sections を順に読み、section.header と section.footer を取る。
- 先に is_linked_to_previous を見る。True なら前のセクションと同じなので、paragraphs には触れずに前のセクションの文字を使う。
- 題名が空、フッターに文字が無い、などの条件で issues 列に印を付け、out_doc_inventory.csv に書く。
"""Inventory .docx files: core properties plus header/footer text per section."""
import csv
from pathlib import Path
from docx import Document
DOCS = sorted(Path("samples").glob("*.docx"))
FIELDS = ["file", "section", "title", "author", "last_modified_by", "revision", "modified_utc",
"header", "header_source", "footer", "footer_source", "issues"]
def story_text(part):
"""Text of a header/footer, or None when it is linked (reading .paragraphs would add one)."""
if part.is_linked_to_previous:
return None
return " / ".join(p.text for p in part.paragraphs if p.text.strip())
def inventory(path):
doc = Document(path)
cp = doc.core_properties
rows, last = [], {"header": "", "footer": ""}
for i, section in enumerate(doc.sections, 1):
row = {"file": path.name, "section": i, "title": cp.title, "author": cp.author,
"last_modified_by": cp.last_modified_by, "revision": cp.revision,
"modified_utc": cp.modified.isoformat() if cp.modified else ""}
for kind in ("header", "footer"):
text = story_text(getattr(section, kind))
if text is None:
row[kind + "_source"] = "previous section" if i > 1 else "none"
text = last[kind]
else:
row[kind + "_source"] = "own"
row[kind] = last[kind] = text
issues = []
if not cp.title:
issues.append("no title")
if not row["footer"]:
issues.append("no footer text")
row["issues"] = "; ".join(issues)
rows.append(row)
return rows
def main():
rows = [r for path in DOCS for r in inventory(path)]
with open("out_doc_inventory.csv", "w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=FIELDS)
w.writeheader()
w.writerows(rows)
print("files:", len(DOCS), "sections:", len(rows))
for r in rows:
print("%s s%d | author=%s | footer=%s (%s) | %s" % (
r["file"], r["section"], r["author"], r["footer"], r["footer_source"], r["issues"] or "ok"))
if __name__ == "__main__":
main()実行結果(2026-09-12)
files: 3 sections: 4
doc_a.docx s1 | author=python-docx | footer= (none) | no title; no footer text
doc_b.docx s1 | author=python-docx | footer= (none) | no title; no footer text
docs_doc_c.docx s1 | author=企画部 担当A | footer=文書番号 N-2026-014 (own) | ok
docs_doc_c.docx s2 | author=企画部 担当A | footer=付録 文書番号 N-2026-014 (own) | okpython-docx で、フォルダ内の .docx の core_properties(title, author, last_modified_by, revision, modified)と、セクションごとのヘッダー・フッターの文字を CSV にするコードを書いて。is_linked_to_previous が True のときは paragraphs を読まずに前のセクションの文字を使い、文書は保存しないこと。
リンクされたヘッダーの paragraphs を読むだけで、ヘッダーの定義が足され is_linked_to_previous が False に変わる、と公式に書かれている。点検では先に is_linked_to_previous を見て、保存もしない。python-docx は保存しても revision を上げない。最初のページ用(first_page_header)と偶数ページ用(even_page_header)のヘッダーはこの例では読まない。作成者などの個人名を CSV に書き出すので、出力の共有範囲に注意する。
出典の該当箇所
Note also that the act of adding content (or even just accessing header.paragraphs) added a header definition and changed the state of .is_linked_to_previous
Date properties are assigned and returned as datetime.datetime objects without timezone, i.e. in UTC.
Note however python-docx does not automatically increment the revision number when it saves a document.
True if this header/footer uses the definition from the prior section.
05表記ルールに当たった段落に Word のコメントを付けた写しを作る確認済
python-docx 1.2.0 でコメントを扱えるようになった。Document.add_comment() で、表記ルールに当たった段落の runs にコメントを付け、元の文書とは別名で保存する。書いた人は Word のコメント欄で指摘を読み、そのまま返信や修正ができる。
- style_terms.csv を読み、本文の段落と、表のセルの段落を順に見る。
- 当たった段落の runs を Document.add_comment(runs=..., text=..., author=...) に渡す。範囲は最初と最後の run で決まるので、段落全体にコメントが付く。
- コメントの本文には、当たった語と対応(action)を改行で並べる。
- out_<元の名前>_commented.docx として別名で保存する。入力と同じパスなら止める。
- 保存したファイルを開き直し、len(doc.comments) で付いた数を確かめる。
"""Add style-list hits as Word comments on a copy of each document (python-docx 1.2.0+)."""
import csv
from pathlib import Path
from docx import Document
TERMS = Path("samples/style_terms.csv")
DOCS = sorted(Path("samples").glob("doc_*.docx"))
AUTHOR = "style-check"
def load_terms(path):
with open(path, encoding="utf-8-sig", newline="") as f:
return list(csv.DictReader(f))
def paragraphs(doc):
"""Body paragraphs, then paragraphs in table cells (merged cells visited once)."""
yield from doc.paragraphs
for table in doc.tables:
seen = []
for row in table.rows:
for cell in row.cells:
if cell in seen:
continue
seen.append(cell)
yield from cell.paragraphs
def annotate(path, terms):
doc = Document(path)
added = []
for p in paragraphs(doc):
found = [t for t in terms if t["term"] in p.text]
if not found or not p.runs:
continue
note = "\n".join("%s: %s" % (t["term"], t["action"]) for t in found)
comment = doc.add_comment(runs=p.runs, text=note, author=AUTHOR)
added.append((comment.comment_id, p.text.strip()[:30]))
out = Path("out_%s_commented.docx" % path.stem)
if out.resolve() == path.resolve():
raise SystemExit("refusing to overwrite the input")
doc.save(out)
return out, added
def main():
terms = load_terms(TERMS)
for path in DOCS:
out, added = annotate(path, terms)
reopened = Document(out)
print("%s -> %s comments: %d (reopened: %d)" % (path.name, out.name, len(added), len(reopened.comments)))
for cid, text in added[:3]:
print(" #%s %s" % (cid, text))
if __name__ == "__main__":
main()実行結果(2026-09-12)
doc_a.docx -> out_doc_a_commented.docx comments: 4 (reopened: 4)
#0 業界初の自動仕訳で、最高の使いやすさを実現しました。導入は絶
#1 利用者満足度 No.1(参考 1)。
#2 詳しくは営業担当までお問合せ下さい。
doc_b.docx -> out_doc_b_commented.docx comments: 0 (reopened: 0)python-docx 1.2.0 の Document.add_comment() を使い、.docx の段落と表のセルの段落のうち style_terms.csv の語を含むものに、語と対応を書いたコメントを付けて、別名の out_*.docx に保存するコードを書いて。元のファイルは上書きしないこと。保存後に開き直してコメントの数を表示すること。
コメントは run の境目にしか付けられない。語の部分だけに付けたいときは run を分ける必要があり、この例では段落全体に付ける。python-docx はコメントの解決(resolved)と返信のスレッドを扱わない。ヘッダー・フッターにはコメントを付けられない。利用者向けの説明ページの例には comment.date とあるが、1.2.0 の API 文書と実物の属性は timestamp である。同じファイル名で保存すると元の文書を黙って上書きする、と公式に書かれている。
出典の該当箇所
Add a comment to the document, anchored to the specified runs.
Add support for comments
Neither of these features is supported by the initial implementation of comments in python-docx.
The date and time this comment was authored.
If you use the same filename to open and save the file, python-docx will obediently overwrite the original file without a peep.
062 つの版の差分を段落単位で取り、新しい Word の報告書に書く確認済
旧版と新版の段落と表の行を文字列の並びにし、difflib で置換・削除・挿入の箇所を出す。結果は Document() で作った新しい文書に、見出し・件数・3 列の表として書く。元の 2 つの文書には手を付けない。
- 旧版と新版をそれぞれ開き、空でない段落の文字列と、表の行をつないだ文字列を並べる。
- difflib.SequenceMatcher の get_opcodes() で、equal 以外(replace・delete・insert)を拾う。
- Document() で新しい文書を作り、add_heading・add_paragraph・add_table で見出し・件数・差分の表を書く。
- 別名(out_diff_report.docx)で保存する。入力と同じ名前では保存しない。
"""Compare two versions paragraph by paragraph and write the result to a new Word report."""
import difflib
from pathlib import Path
from docx import Document
from docx.shared import Pt
OLD = Path("samples/doc_a.docx")
NEW = Path("samples/doc_b.docx")
OUT = Path("out_diff_report.docx")
def texts(path):
doc = Document(path)
lines = [p.text.strip() for p in doc.paragraphs if p.text.strip()]
for table in doc.tables:
for row in table.rows:
lines.append(" | ".join(c.text.strip() for c in row.cells))
return lines
def diff(old, new):
sm = difflib.SequenceMatcher(a=old, b=new, autojunk=False)
return [(tag, "\n".join(old[i1:i2]), "\n".join(new[j1:j2]))
for tag, i1, i2, j1, j2 in sm.get_opcodes() if tag != "equal"]
def write_report(rows, out):
doc = Document() # new document from the built-in default template
doc.add_heading("Version comparison", level=1)
doc.add_paragraph("Old: %s / New: %s / Changes: %d" % (OLD.name, NEW.name, len(rows)))
table = doc.add_table(rows=1, cols=3, style="Table Grid")
for cell, label in zip(table.rows[0].cells, ("change", "old", "new")):
cell.text = label
for tag, a, b in rows:
cells = table.add_row().cells
cells[0].text, cells[1].text, cells[2].text = tag, a, b
for row in table.rows:
for cell in row.cells:
for p in cell.paragraphs:
for run in p.runs:
run.font.size = Pt(9)
doc.save(out)
def main():
if OUT.resolve() in (OLD.resolve(), NEW.resolve()):
raise SystemExit("output must not overwrite an input")
rows = diff(texts(OLD), texts(NEW))
write_report(rows, OUT)
print("changes:", len(rows))
for tag, a, b in rows[:4]:
print("%-7s | %s | %s" % (tag, a.replace("\n", " / ")[:40], b.replace("\n", " / ")[:40]))
print("saved", OUT, "tables in report:", len(Document(OUT).tables))
if __name__ == "__main__":
main()実行結果(2026-09-12)
changes: 3
replace | 文書番号: DOC_A 版: 1.0 作成: 営業企画部(架空) | 文書番号: DOC_B 版: 1.0 作成: 営業企画部(架空)
replace | 業界初の自動仕訳で、最高の使いやすさを実現しました。導入は絶対に失敗しません。 | 利用者アンケートでは 312 件の回答のうち 78% が「使いやすい」と答えまし
delete | 初期費用 | 完全無料 | 料金表 |
saved out_diff_report.docx tables in report: 1python-docx と difflib で、2 つの .docx の段落と表の行を比べ、replace・delete・insert の箇所を、新しく作った Word 文書に「種類・旧・新」の 3 列の表で書き出すコードを書いて。元の文書は保存し直さないこと。
段落の文字列だけを比べるので、書式(太字・色)やコメント、ヘッダーの違いは出ない。1 段落の中の 1 語の違いも、段落ごとの置換として出る。語の単位の差分や変更履歴が要るときは Word の[比較]を使う。表のスタイル名 Table Grid は python-docx の既定のテンプレートにある(試験で確認)。独自のテンプレートを使うときは、その中にあるスタイル名を使う。
出典の該当箇所
python-docx allows you to create new documents as well as make changes to existing ones.
Return list of 5-tuples describing how to turn a into b.
If style is None, the table inherits the default table style of the document.
07Graph で OneDrive/SharePoint の .docx を取得し、ディスクに置かずに読む確認済
Microsoft Graph で driveItem の content を GET すると、ファイルの本体がバイト列で返る。これを io.BytesIO に包んで Document() に渡せば、手元に保存せずに段落・表・見出しを数えられる。format=pdf を付けると PDF に変換した写しを受け取れる。
- アプリを Microsoft Entra ID に登録し、委任のアクセス許可 Files.Read を付ける。CLIENT_ID・TENANT_ID・DRIVE_ID・ITEM_ID を環境変数に置く。
- DeviceCodeCredential と GraphServiceClient でサインインする。
- client.drives.by_drive_id(...).items.by_drive_item_id(...).content.get() でバイト列を受け取る。
- Document(io.BytesIO(data)) で開き、段落・表・見出しを数える。
- PDF が要るときは DOWNLOAD_FORMAT=pdf を設定する。ContentRequestBuilderGetQueryParameters(format="pdf") を付けて同じ content を呼び、out_item.pdf に保存する。
"""Download a .docx from OneDrive/SharePoint with Microsoft Graph and summarize it in memory."""
import asyncio
import io
import os
from pathlib import Path
from azure.identity import DeviceCodeCredential
from docx import Document
from kiota_abstractions.base_request_configuration import RequestConfiguration
from msgraph import GraphServiceClient
from msgraph.generated.drives.item.items.item.content.content_request_builder import (
ContentRequestBuilder,
)
SCOPES = ["Files.Read"]
FORMAT = os.environ.get("DOWNLOAD_FORMAT", "") # "" = original file, "pdf" = converted copy
def summarize(data):
"""Count paragraphs, tables and headings in .docx bytes without touching the disk."""
doc = Document(io.BytesIO(data))
heads = [p.text for p in doc.paragraphs if p.style.name.startswith("Heading")]
return {"paragraphs": len(doc.paragraphs), "tables": len(doc.tables),
"headings": len(heads), "first_heading": heads[0] if heads else ""}
async def main():
if FORMAT not in ("", "pdf"):
raise SystemExit("DOWNLOAD_FORMAT must be empty or pdf")
cred = DeviceCodeCredential(client_id=os.environ["CLIENT_ID"], tenant_id=os.environ["TENANT_ID"])
client = GraphServiceClient(credentials=cred, scopes=SCOPES)
item = client.drives.by_drive_id(os.environ["DRIVE_ID"]).items.by_drive_item_id(os.environ["ITEM_ID"])
if FORMAT:
query = ContentRequestBuilder.ContentRequestBuilderGetQueryParameters(format=FORMAT)
data = await item.content.get(request_configuration=RequestConfiguration(query_parameters=query))
out = Path("out_item.%s" % FORMAT)
out.write_bytes(data)
print("saved", out, len(data), "bytes")
return
data = await item.content.get()
print(summarize(data))
if __name__ == "__main__":
asyncio.run(main())実行結果(2026-09-12)
{'paragraphs': 10, 'tables': 1, 'headings': 4, 'first_heading': 'クラウド会計「サンプル会計」製品案内(架空)'}msgraph-sdk と azure-identity の DeviceCodeCredential で、DRIVE_ID と ITEM_ID(環境変数)の .docx を Files.Read の権限だけで取得し、io.BytesIO 経由で python-docx に渡して段落・表・見出しの数を表示するコードを書いて。環境変数 DOWNLOAD_FORMAT が pdf のときは format=pdf で変換した写しを保存すること。ID はコードに書かないこと。
content の応答は、事前認証済みのダウンロード URL への 302 転送で、その URL は数分で切れることがあると公式に書かれている。受け取ったらすぐ読む。アプリの権限(application)で PDF 変換を呼ぶには、公式の表では Files.ReadWrite.All が要る。この例は委任の Files.Read に留める。DRIVE_ID と ITEM_ID を探すための一覧や差分の取得は SharePoint のページで扱う。試験はサインインの手前までと、見本の .docx を渡した summarize() だけで、実際のテナントからの取得は動かしていない。
出典の該当箇所
Download the contents of the primary stream (file) of a driveItem. Only driveItem objects with the file property can be downloaded.
Converts the item into PDF format.
Preauthenticated download URLs are valid for a limited time. Use them immediately, as they might expire within minutes.
python-docx can open a document from a so-called file-like object.