01Scan body text and tables in document order with iter_inner_content()Verified
Document.iter_inner_content() yields paragraphs and tables in the order they appear. Calling the same method on each table cell lets you walk nested tables too, so every hit from a style list comes with its exact location (block number, table, row and column). Hyperlink text and addresses are collected along the way.
- Load style_terms.csv (term, category, action).
- Use Document.iter_inner_content() to receive Paragraph and Table objects in document order. Looping over doc.paragraphs and doc.tables separately loses where each table sat in the text.
- For a table, loop over rows and cells and call iter_inner_content() on each cell recursively; this also reaches tables nested inside cells. A merged cell shows up repeatedly in row.cells, so skip cells you have already read.
- Call iter_inner_content() on each paragraph to separate Run and Hyperlink items, and record each hyperlink's text and address.
- Write the hits to out_term_hits.csv so a reviewer can check the surrounding context in the document.
"""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()Output(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/expenseUsing python-docx 1.2.0, write code that reads every .docx in a folder in document order with Document.iter_inner_content(), recurses into table cells (including nested tables), and writes paragraphs containing any term from style_terms.csv to a CSV with block number, table position and style name. Do not count merged cells twice, and also list hyperlink URLs.
Matching is plain substring search, so negated or quoted uses of a term are also reported; a person decides what to change. The docs note that doc.paragraphs omits paragraphs inside revision marks, so accept or reject tracked changes in Word before scanning. Header and footer text is not part of this walk; read it from the sections.
Supporting passages from the sources
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.
02Export Word tables to CSV without losing merged or nested cellsVerified
doc.tables returns only top-level tables, not tables nested inside cells, and row.cells repeats a merged cell once for every grid column it covers. This recipe writes one CSV row per grid position, marks the repeats, and follows nested tables with a path that shows which cell they sit in.
- Take top-level tables from doc.tables; nested tables must be read separately from cell.tables.
- Start the column counter of each row at row.grid_cols_before so rows that begin late stay aligned.
- Walk row.cells and flag any cell equal to one already seen as merged_repeat (the repeat of a merged cell). Record cell.grid_span for the horizontal span.
- For cells that are not repeats, recurse into cell.tables and label each nested table with a path such as t1>r1c2.t1.
- Filter out merged_repeat rows in out_tables.csv to get a list close to how the table looks on the page.
"""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()Output(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']Using python-docx 1.2.0, write code that exports every table in a .docx to CSV. Offset columns with row.grid_cols_before, flag repeated merged cells, recurse into nested tables via cell.tables, and add a column with the path to each nested table.
row.cells does not return grid positions omitted at the end of a row (grid_cols_after), so rows can have different cell counts. In the sample, cell.text of a cell holding a nested table did not include the nested table's text. Vertically merged cells also repeat in the rows below. Check that repeats are removed before summing any numeric column.
Supporting passages from the sources
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.
03Extract a heading outline and flag skipped heading levelsVerified
Word stores built-in styles under their English names (Heading 1 and so on) even when the UI shows a localized name. Matching on those names gives a per-document heading outline, and the script flags skipped levels (for example Heading 2 followed by Heading 4) and documents with no Heading 1.
- Walk doc.paragraphs and treat a paragraph as a heading when paragraph.style.name is Heading 1 to Heading 9.
- Flag a heading as skipped level when it is two or more levels deeper than the previous heading.
- Flag documents that contain no Heading 1 at all.
- Write the indented outline to out_outline.csv and print it.
- Fix the flagged headings in Word. The check is also useful before turning a Word document into a slide draft.
"""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()Output(2026-09-12)
== doc_a.docx
クラウド会計「サンプル会計」製品案内(架空)
1. 特長
2. 料金と条件
参考
== docs_doc_c.docx
経費精算の新しい手順(架空)
1. 背景
1.1.1 旧手順との違い <-- skipped level
2. 手順
付録 A. 用語Using python-docx, write code that collects paragraphs whose style name is Heading 1 to Heading 9 from every .docx in a folder and writes an indented outline to CSV. Flag headings that jump two or more levels deeper than the previous one and documents without any Heading 1.
Detection relies on style names, so paragraphs made to look like headings with bold or a larger font are missed, as are custom styles not named Heading N. Paragraphs inside tables are not part of doc.paragraphs (confirmed in testing).
Supporting passages from the sources
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.
04List core properties and header/footer text for a folder of .docx filesVerified
Read title, author, last_modified_by, revision and modified from core_properties and combine them with the header and footer text of every section in one CSV. Before a document leaves the team you can see at a glance whether personal names remain or a footer document number is missing.
- Read title, author, last_modified_by, revision and modified from Document(path).core_properties. Dates come back as naive datetimes in UTC.
- Walk doc.sections and take section.header and section.footer.
- Check is_linked_to_previous first. If it is True the section reuses the previous definition, so take the previous section's text and do not touch paragraphs.
- Flag rows in an issues column (empty title, no footer text) and write 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()Output(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) | okUsing python-docx, write code that lists core_properties (title, author, last_modified_by, revision, modified) and per-section header and footer text for every .docx in a folder as CSV. When is_linked_to_previous is True, reuse the previous section's text instead of reading paragraphs, and never save the documents.
The docs state that merely accessing header.paragraphs on a linked header adds a header definition and flips is_linked_to_previous to False, so an audit should check that flag first and never save. python-docx does not increment revision when it saves. First-page and even-page headers (first_page_header, even_page_header) are not read here. The CSV contains personal names, so limit who can see it.
Supporting passages from the sources
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.
05Write style-check findings as Word comments on a copy of the documentVerified
python-docx 1.2.0 added comment support. Document.add_comment() anchors a comment to the runs of each paragraph that matches the style list, and the result is saved under a new name. Authors then read the findings in Word's comment pane and reply or fix them there.
- Load style_terms.csv and go through body paragraphs and the paragraphs inside table cells.
- Pass the matching paragraph's runs to Document.add_comment(runs=..., text=..., author=...). Only the first and last run define the range, so the comment covers the whole paragraph.
- Put each matched term and its action on its own line in the comment text.
- Save as out_<original name>_commented.docx, and stop if the output path equals the input.
- Reopen the saved file and confirm the count with 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()Output(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)Using Document.add_comment() in python-docx 1.2.0, write code that adds a comment listing the matched term and action to every paragraph (including table-cell paragraphs) that contains a term from style_terms.csv, and saves the result as a separate out_*.docx. Never overwrite the input; reopen the output and print the comment count.
Comments can only be anchored on run boundaries; commenting on a single word would require splitting runs, so this example comments on whole paragraphs. python-docx does not support resolving comments or reply threads, and comments cannot be placed in headers or footers. The user guide example shows comment.date, but the 1.2.0 API reference and the installed code use timestamp. The docs warn that saving under the same filename silently overwrites the original.
Supporting passages from the sources
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.
06Diff two versions by paragraph and write a new Word reportVerified
Turn the paragraphs and table rows of an old and a new version into lists of strings, let difflib find replaced, deleted and inserted blocks, and write the result into a brand-new document created with Document(): a heading, a count and a three-column table. The two source files are left untouched.
- Open both versions and list the non-empty paragraph texts plus one joined string per table row.
- Use difflib.SequenceMatcher.get_opcodes() and keep everything that is not equal (replace, delete, insert).
- Create a new document with Document() and write a heading, the count and a table of changes with add_heading, add_paragraph and add_table.
- Save under a new name (out_diff_report.docx), never under an input's name.
"""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()Output(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: 1Using python-docx and difflib, write code that compares the paragraphs and table rows of two .docx files and writes every replace, delete and insert into a newly created Word document as a three-column table (change, old, new). Do not re-save the source documents.
Only paragraph text is compared, so differences in formatting, comments or headers do not show up, and a one-word change appears as a replaced paragraph. Use Word's Compare feature when you need word-level differences or tracked changes. The table style name Table Grid exists in python-docx's default template (confirmed in testing); with your own template, use a style name that exists there.
Supporting passages from the sources
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.
07Download a .docx from OneDrive or SharePoint with Graph and read it in memoryVerified
A GET on a driveItem's content in Microsoft Graph returns the file as bytes. Wrap them in io.BytesIO and pass them to Document() to count paragraphs, tables and headings without writing the file to disk. Adding format=pdf returns a PDF conversion instead.
- Register an app in Microsoft Entra ID with the delegated permission Files.Read, and put CLIENT_ID, TENANT_ID, DRIVE_ID and ITEM_ID in environment variables.
- Sign in with DeviceCodeCredential and create a GraphServiceClient.
- Call client.drives.by_drive_id(...).items.by_drive_item_id(...).content.get() to receive the bytes.
- Open them with Document(io.BytesIO(data)) and count paragraphs, tables and headings.
- For a PDF, set DOWNLOAD_FORMAT=pdf; the script calls the same content endpoint with ContentRequestBuilderGetQueryParameters(format="pdf") and saves 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())Output(2026-09-12)
{'paragraphs': 10, 'tables': 1, 'headings': 4, 'first_heading': 'クラウド会計「サンプル会計」製品案内(架空)'}Using msgraph-sdk and azure-identity's DeviceCodeCredential, write code that downloads the .docx identified by the DRIVE_ID and ITEM_ID environment variables with only the Files.Read permission, opens it in python-docx through io.BytesIO, and prints paragraph, table and heading counts. When DOWNLOAD_FORMAT is pdf, save a copy converted with format=pdf. Do not hard-code any IDs.
The content call answers with a 302 redirect to a preauthenticated download URL that the docs say may expire within minutes, so read it right away. Per the permission table, calling the PDF conversion with application permissions needs Files.ReadWrite.All; this example stays with delegated Files.Read. Listing drives and items to find DRIVE_ID and ITEM_ID is covered on the SharePoint page. Testing here stopped at sign-in and ran summarize() on a sample .docx; no real tenant download was performed.
Supporting passages from the sources
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.