Derive new Office files from structural selections without touching the original. Use when the user points at part of a spreadsheet, Word document, PDF, or Powe
复制下面这句话,粘贴给 Claude Code、Codex、Cursor 等 AI 编程工具,它会读取安装说明并在你确认后完成安装。
请阅读 https://ai.atlankj.com/install/asset/gh-office-transform-7eee660dbc08 ,按照其中的说明把「office-transform」安装到你(当前 AI 工具)中。执行前先告诉我将运行的命令和写入的位置,等我确认。
查看 AI 将读取的安装说明正在读取 GitHub 原文…
内容来自 GitHub 原始文件,由原作者维护。在 GitHub 查看
Turn a structural selection of an Office/PDF document into a new derived file.
Two invariants hold for every operation:
mcp__cherry-tools__to_markdown instead (see the cherry-tool-guide skill); it is
lossy but built for reading.Chat surfaces may hand you a fenced selection-ref block:
{"path": "/abs/report.xlsx", "anchor": {"format": "xlsx", "sheet": "Sheet1", "range": "A1:C10"}, "excerpt": "…", "fileStamp": {"size": 1024, "mtimeMs": 1700000000000}}
The anchor object is exactly what the scripts take via --anchor.
Freshness check. fileStamp.mtimeMs is milliseconds since the Unix epoch, floored to
whole milliseconds. Do not compare it to stat's default output: stat -f %m (macOS) and
stat -c %Y (GNU) give whole seconds, so multiplying by 1000 loses the sub-second part and
never matches. Read whole milliseconds the way the app wrote them:
uv run python -c "import os,sys;st=os.stat(sys.argv[1]);print(st.st_size, st.st_mtime_ns//1_000_000)" '/abs/report.xlsx'
Treat the file as changed when the size differs, or when the mtimes differ by more than 2 ms.
The tolerance is small on purpose: both sides floor the same nanosecond timestamp to
milliseconds, so they agree exactly, and the couple of milliseconds only covers the rounding
of a double that can no longer represent current epoch milliseconds exactly. A wider window
would hide real edits — a same-size change made within it would read as unchanged, which is
why the anchor check below is not optional. On a change, tell the user the file changed since
they selected and ask them to re-select — never silently re-anchor.
Anchor check. Before a patch-copy edit, also verify the anchor still points where the
user thinks: extract the anchored region and compare its text with the reference's excerpt,
normalizing both sides the same way (NFC, collapse each whitespace run to one space, trim).
The two are not equal and are not meant to be — the excerpt is what the user selected, while
the extract is the whole region an edit would replace. A selection sitting inside one region
makes the excerpt the shorter side; a selection running past that region anchors to where it
starts, which makes it the longer one. So the test is containment, whichever way round fits:
the excerpt appears inside the extract, or the extract's tail is where the excerpt begins.
Only when neither holds has the anchor stopped pointing at the selection — then stop and tell
the user, never edit at a mismatched anchor and never go searching for a "close enough"
location. Two cases need care because the two sides are not directly comparable as-is:
excerpt is tab-separated cells and newline-separated rows, while extraction
writes csv or md. Compare cell values, not the raw file — read the csv with a csv reader and
join the fields with single spaces before normalizing, so , and | never count as a
difference. That join drops the cell boundaries on both sides, so a match proves the text is
unchanged, not the grid — the same words re-split across cells still reads as a match. The
freshness check above is what catches that; never skip it on a passing anchor check.
Number formats are the second asymmetry: the excerpt holds what Excel displays, because
that is what the user pointed at (1,234.50, 45.67%), while extraction writes the stored
value (1234.5, 0.4567). Neither side is wrong, so a formatted cell never matches
literally. Compare those cells by value, and leave them out of the joined strings so the
containment test runs on the text cells alone. Dates, times and durations are the same
asymmetry seen from the other side: extraction writes one fixed shape (2024-01-03,
2024-01-03 15:04:05, 26:30:00) rather than the cell's format, so treat a cell that differs
only in date or time presentation as a format difference too. A difference that is only the
number format is not a moved anchor, and stopping on one sends the user back to re-select a
selection that never moved.charRange: the slice is only part of what patch-copy compares and replaces —
it rewrites the whole paragraph. Extract the paragraph without charRange as well, and
read "Edit docx" below before writing.Users may also describe the region in words ("sheet 2, columns A through C"); build the anchor JSON yourself, confirming the worksheet name or paragraph if ambiguous.
Anchor shapes:
| Format | Anchor |
|---|---|
| xlsx | {"format":"xlsx","sheet":"Sheet1","range":"A1:C10"} (range may be one cell) |
| docx | {"format":"docx","paragraph":3,"paraId":"502E8D33","charRange":[0,12]} (paraId optional = the paragraph's w14:paraId, resolved first when present; charRange optional; ordinal counts body-level paragraphs only, tables excluded) |
{"format":"pdf","page":3,"charRange":[0,120]} (charRange optional, applies to extracted text) | |
| pptx | {"format":"pptx","slide":2,"nodeId":"4","paragraph":0} or {"format":"pptx","slide":2,"nodeId":"7","tableCell":{"row":1,"col":0}} (slide is one-based; nodeId is the OOXML shape id — omit for the whole slide; paragraph and tableCell are optional, mutually exclusive, and only valid together with nodeId) |
Scripts live in this skill's scripts/ directory; resolve paths relative to this
skill folder. The library-edit recipes routed to below live in references/ beside them.
Python dependencies are per-format and provided at invocation time via
uv run --with <pkg> (the bundled-shell idiom — do not pip install globally).
Which route an edit takes follows from the library that can write the format: openpyxl
drops charts and drawings on a round-trip, which is why xlsx edits go through patch-copy,
while python-pptx keeps XML it does not understand, which is why pptx edits go through
the library.
Always single-quote paths — real documents have spaces in their names (Q1 report.xlsx).
If a path itself contains a single quote, close and reopen the quoting around it:
'/abs/Bob'\''s deck.pptx'.
uv run --with openpyxl python scripts/office_extract.py \
--file '/abs/report.xlsx' \
--anchor '{"format":"xlsx","sheet":"Sheet1","range":"A1:C10"}' \
--out '/abs/report-q1-range.csv'
The output format is inferred from --out's extension:
| Source | Dependency (--with) | Output formats |
|---|---|---|
| xlsx | openpyxl | xlsx, csv, md |
| docx | 'python-docx>=1.1,<2' | docx, txt, md |
pypdf | pdf (page copy), txt, md | |
| pptx | python-pptx | txt, md (slide, shape, paragraph, or table-cell text) |
The docx pin is not optional. Patch-copy's expectText gate compares a paragraph read
with python-docx against the same paragraph read by the script's own paragraph_text,
which reproduces python-docx's Paragraph.text element for element. That equivalence was
checked against 1.x; a release that changes what .text spells would make the gate refuse
paragraphs nobody edited. Quote the specifier — > and < are redirects to a shell.
xlsx extraction reads computed values (data_only), so formula cells yield their last
saved result. docx extraction to docx carries text only, not run styling.
uv run python scripts/office_patch_copy.py \
--file '/abs/report.xlsx' \
--edits '{"format":"xlsx","sheet":"Sheet1","cells":{"B2":42,"C3":"hello"}}' \
--out '/abs/report-updated.xlsx'
OOXML packages are ZIPs of XML parts. Patch-copy copies every part byte-for-byte and
re-serializes only what an edit reaches — the target worksheet or word/document.xml, plus
the workbook bookkeeping noted below for xlsx — so styles, charts, images, and macros in
untouched parts survive exactly. Edit shapes:
{"format":"xlsx","sheet":"S","cells":{"B2":42,"C3":"text","D4":true}} — numbers,
strings, and booleans; an existing formula in an edited cell is replaced by the value.
Patch-copy does not recalculate, so a formula reading an edited cell keeps the value it
last cached; every write sets fullCalcOnLoad in xl/workbook.xml so Excel recomputes
those on open.
Replacing a formula also drops xl/calcChain.xml (a recalculation cache Excel rebuilds),
along with the content-type and relationship entries that would otherwise point at a part
no longer there; keeping a chain entry for a cell that no longer has a formula makes Excel
report the derived file as damaged. Cells in a shared, array, or data-table formula group are refused — the
expression lives in one member and the others only reference it, so overwriting a member
would strip the formula from cells you never named. Rewrite such a range with openpyxl.
Coordinates outside the worksheet grid (past XFD or row 1048576) are refused too.{"format":"docx","replacements":[{"paragraph":3,"text":"new text","paraId":"502E8D33","expectText":"old text"}]} —
the paragraph keeps its paragraph style and the first run's character style; extra run-level
styling within that one paragraph is flattened into the new text.
text must be the complete new paragraph. The whole body paragraph is replaced, and
charRange does not narrow that — feeding back a charRange slice as text silently
discards the rest of the sentence.
The output is exactly w:p > [w:pPr] + w:r > [w:rPr] + w:t, so a paragraph holding anything
that shape cannot carry is refused, not silently stripped. That covers bookmarks, comment
anchors and fields (their start/end can pair across paragraphs, and rewriting one half
unbalances the document), images, embedded objects, footnote/endnote references, hyperlinks,
tracked changes and moves, content controls, equations — and anything else not on the short
allow-list, including elements from namespaces that did not exist when this was written.
A dropped w:del would even accept a pending deletion on the user's behalf.
A page or column break (<w:br w:type="page"/>) is refused for a quieter reason: it carries no
characters, so the extract reads the text on either side of it as one string and the anchor check
cannot see that the rewrite would delete it. A bare <w:br/> line break still passes.
To edit such a paragraph, see "Edit docx" below — do not reach for
Paragraph.text, which destroys exactly the same content, only silently.
paraId (optional) is resolved before the ordinal; a disagreement between the two is an
error, never a silent pick. expectText (optional but strongly recommended) is a hard
gate: the target paragraph's current text must match it after whitespace normalization or
the edit is refused. — the gate compares the whole paragraph, and a selection-ref is
truncated at 2000 chars and may span more than the edit target.Text comparisons on both sides of this skill use one normalization rule, identical
to the renderer's normalizeSelectionText: NFC-normalize, collapse every whitespace
run to a single space, trim the ends.
Generation (a fresh deck, workbook, or document — from scratch or from data you
just extracted) has no source file to protect, so there is no fixed script: write a
short Python program against the matching library (python-pptx, openpyxl,
python-docx) and run it via uv run --with <pkg> python. The two skill
invariants still apply: write to a path nothing occupies yet — open it with "xb"
rather than handing the library a name, since every one of these save() methods
overwrites — and verify the output by reopening it before reporting success.
Paragraph.textWhen patch-copy refuses a paragraph, python-docx can still edit it — but only run by run.
Never assign paragraph.text = "...": it inflicts exactly the loss patch-copy refused, minus
the refusal. save(path) overwrites, so open the destination with "xb".
Read references/docx-edit.md before writing any python-docx edit.
pptx edits do not go through office_patch_copy.py. Never assign .text at paragraph or
shape level: it rebuilds that subtree as one unformatted run. Edit runs, and open the
destination with "xb".
Read references/pptx-edit.md before writing any python-pptx edit.
report.xlsx → report-updated.xlsx, report-q1-range.csv, spec-p3.txt.--file and --out must both be absolute; a relative path is refused rather than
resolved against whatever working directory the shell happens to be in.Always reopen the derived file with the matching reader and check the result, e.g.:
uv run --with openpyxl python -c "
from openpyxl import load_workbook
ws = load_workbook('/abs/report-updated.xlsx', data_only=True)['Sheet1']
print(ws['B2'].value)"
data_only reads cached values and patch-copy does not recalculate, so a formula reading a
cell you just wrote still reads back its old result. Check the cells you wrote, and tell the
user the dependent totals are recomputed when Excel opens the file.
A written xlsx string cell that held a literal _xHHHH_ or a carriage return reads back through
openpyxl in its escaped form (_x005F_xHHHH_, _x000D_) — see the ST_Xstring note under
"Limits". That is the reader, not a verification failure.
If verification fails, say so and show the error — do not present an unverified file.
_xHHHH_ in the value is escaped to
_x005F_xHHHH_ and a carriage return is stored as _x000D_ — that is what makes Excel show
the characters that were written. openpyxl decodes neither, so a verification read of such a
cell shows the escaped form; that is the reader, not the file.w:softHyphen
leaves no mark in the extract and is dropped, and w:noBreakHyphen comes back as a plain -, so
what both lose is only where a line may break. w:sym is refused instead — its glyph is a
character the reader can see, not a hyphenation hint.=SUM(A1), +1,
-); a spreadsheet opening the csv would evaluate it, which matters only for a source
the user does not trust — say so if they mean to open the csv in one. In the derived
xlsx, text that openpyxl would re-type — a leading = into a live formula, an error code
such as #N/A into an error value — is written back as quote-prefixed text; +1 and -
are already stored as text and stay that way, and a cell that really holds an error stays
an error.charRangeexcerpt\x0B), so strip them before feeding
extracted text back in as an edit value. A docx paragraph refuses tab, newline and
carriage return as well: WordprocessingML spells those <w:tab/> and <w:br/>, and the
single w:t this rewrite emits cannot carry them. Extracting a paragraph that holds them
gives you \t / \n, so editing that string and writing it back would delete the
elements while reading identically — split the content across separate body paragraphs,
or see "Edit docx" below.