Office Transform
Convertir une sélection structurelle d'un document Office/PDF en un nouveau fichier dérivé.
Deux invariants s'appliquent à chaque opération :
- Le fichier source est en lecture seule. Chaque résultat est un nouveau fichier ; les deux scripts refusent d'écrire sur le chemin source ou de remplacer un fichier existant. Ne contournez jamais cela par des éditions shell ad-hoc sur l'original.
- Les ancres sont structurelles. Les sélections addressent les coordonnées propres du document — feuille de calcul + plage A1, ordinal de paragraphe au niveau du corps, numéro de page en base 1 — jamais des positions d'écran ou DOM.
Quand utiliser quel outil
- Vous ne devez que lire un document pour le résumer ou répondre à des questions → utilisez
mcp__cherry-tools__to_markdownà la place (voir le skill cherry-tool-guide) ; c'est une conversion avec perte mais conçue pour la lecture. - L'utilisateur veut un fichier en résultat — une plage extraite, un fragment converti, une copie éditée → ce skill.
Entrée : références de sélection
Les interfaces de chat peuvent vous remettre un bloc selection-ref entre crochets :
{"path": "/abs/report.xlsx", "anchor": {"format": "xlsx", "sheet": "Sheet1", "range": "A1:C10"}, "excerpt": "…", "fileStamp": {"size": 1024, "mtimeMs": 1700000000000}}
L'objet anchor est exactement ce que les scripts prennent via --anchor.
Vérification de fraîcheur. fileStamp.mtimeMs est en millisecondes depuis l'époque Unix, tronqué à
des millisecondes entières. Ne le comparez pas à la sortie par défaut de stat : stat -f %m (macOS) et
stat -c %Y (GNU) donnent des secondes entières, donc multiplier par 1 000 perd la partie sub-seconde et
ne correspond jamais. Lisez les millisecondes entières comme l'app les a écrites :
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'
Traitez le fichier comme modifié si la taille diffère, ou si les mtimes diffèrent de plus de 2 ms.
La tolérance est volontairement petite : les deux côtés tronquent le même timestamp nanoseconde à
des millisecondes, donc ils s'accordent exactement, et les quelques millisecondes couvrent seulement l'arrondi
d'un double qui ne peut plus représenter exactement l'époque actuelle en millisecondes. Une fenêtre plus large
cacherait les vraies éditions — un changement de même taille fait dans cette fenêtre apparaîtrait inchangé, ce qui est
pourquoi la vérification d'ancre ci-dessous n'est pas optionnelle. En cas de changement, dites à l'utilisateur que le fichier a changé depuis la sélection et demandez-lui de resélectionner — ne réancrez jamais silencieusement.
Vérification d'ancre. Avant une édition patch-copy, vérifiez aussi que l'ancre pointe toujours où
l'utilisateur le pense : extrayez la région ancrée et comparez son texte avec celui de la référence excerpt,
en normalisant les deux côtés de la même manière (NFC, réduire chaque suite d'espaces blancs à un seul espace, trimmer).
Les deux ne sont pas égaux et ne sont pas censés l'être — l'excerpt est ce que l'utilisateur a sélectionné, tandis que
l'extraction est la région entière qu'une édition remplacerait. Une sélection à l'intérieur d'une région
rend l'excerpt le côté plus court ; une sélection dépassant cette région s'ancre à où elle
commence, ce qui le rend le plus long. Le test est donc une inclusion, quelle que soit la manière qui s'ajuste :
l'excerpt apparaît dans l'extraction, ou la queue de l'extraction est où l'excerpt commence.
Seulement quand ni l'un ni l'autre ne tient l'ancre a cessé de pointer sur la sélection — arrêtez et dites à
l'utilisateur, ne modifiez jamais sur une ancre mal alignée et ne cherchez jamais une localisation
« suffisamment proche ». Deux cas nécessitent attention car les deux côtés ne sont pas directement comparables tels quels :
- xlsx : l'
excerptest des cellules séparées par des tabulations et des rangées séparées par des sauts de ligne, tandis que l'extraction écrit du csv ou md. Comparez les valeurs de cellules, pas le fichier brut — lisez le csv avec un lecteur csv et joignez les champs avec des espaces simples avant de normaliser, donc,et|ne comptent jamais comme une différence. Cette jointure supprime les limites de cellules des deux côtés, donc une correspondance prouve que le texte est inchangé, pas la grille — les mêmes mots re-divisés sur les cellules se lisent toujours comme une correspondance. La vérification de fraîcheur ci-dessus est ce qui capture cela ; ne la sautez jamais sur un passage de vérification d'ancre. Les formats de nombre sont la deuxième asymétrie : l'excerptcontient ce qu'Excel affiche, parce que c'est ce que l'utilisateur a pointé (1 234,50,45,67 %), tandis que l'extraction écrit la valeur stockée (1234,5,0,4567). Aucun côté n'a tort, donc une cellule formatée ne correspond jamais littéralement. Comparez ces cellules par valeur, et laissez-les en dehors des chaînes jointes afin que le test d'inclusion s'exécute sur les cellules texte seules. Les dates, heures et durées sont la même asymétrie vue de l'autre côté : l'extraction écrit une forme fixe unique (2024-01-03,2024-01-03 15:04:05,26:30:00) plutôt que le format de la cellule, donc traitez une cellule qui diffère seulement dans la présentation de la date ou l'heure comme une différence de format aussi. Une différence qui n'est que le format du nombre n'est pas une ancre déplacée, et s'arrêter sur une en renvoie l'utilisateur pour resélectionner une sélection qui n'a jamais bougé. - docx avec
charRange: la tranche n'est qu'une partie de ce que patch-copy compare et remplace — il réécrit le paragraphe entier. Extrayez le paragraphe sanscharRangeaussi, et lisez « Edit docx » ci-dessous avant d'écrire.
Les utilisateurs peuvent aussi décrire la région en mots (« sheet 2, colonnes A à C ») ; construisez le JSON d'ancre vous-même, en confirmant le nom de la feuille de calcul ou le paragraphe si ambigü.
Formes d'ancre :
| Format | Ancre |
|---|---|
| xlsx | {"format":"xlsx","sheet":"Sheet1","range":"A1:C10"} (la plage peut être une seule cellule) |
| docx | {"format":"docx","paragraph":3,"paraId":"502E8D33","charRange":[0,12]} (paraId optionnel = le w14:paraId du paragraphe, résolu en premier si présent ; charRange optionnel ; l'ordinal compte seulement les paragraphes au niveau du corps, les tableaux exclus) |
{"format":"pdf","page":3,"charRange":[0,120]} (charRange optionnel, s'applique au texte extrait) |
|
| pptx | {"format":"pptx","slide":2,"nodeId":"4","paragraph":0} ou {"format":"pptx","slide":2,"nodeId":"7","tableCell":{"row":1,"col":0}} (slide est en base 1 ; nodeId est l'id de forme OOXML — omis pour la diapositive entière ; paragraph et tableCell sont optionnels, mutuellement exclusifs, et valides seulement ensemble avec nodeId) |
Opérations
Les scripts se trouvent dans le répertoire scripts/ de ce skill ; résolvez les chemins par rapport à ce
dossier skill. Les recettes library-edit routées ci-dessous se trouvent dans references/ à côté d'eux.
Les dépendances Python sont par format et fournies à l'invocation via
uv run --with <pkg> (l'idiome shell groupé — ne faites pas pip install globalement).
La route qu'une édition emprunte découle de la librairie qui peut écrire le format : openpyxl
perd les graphiques et les dessins sur un aller-retour, ce qui est pourquoi les édits xlsx passent par patch-copy,
tandis que python-pptx préserve le XML qu'elle ne comprend pas, ce qui est pourquoi les édits pptx passent par
la librairie.
Extract — tirer la région ancrée dans un nouveau fichier
Mettez toujours les chemins entre guillemets simples — les vrais documents ont des espaces dans leurs noms (Q1 report.xlsx).
Si un chemin contient lui-même un guillemet simple, fermez et rouvrez le guillemage autour :
'/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'
Le format de sortie est déduit de l'extension de --out :
| Source | Dépendance (--with) |
Formats de sortie |
|---|---|---|
| xlsx | openpyxl |
xlsx, csv, md |
| docx | 'python-docx>=1.1,<2' |
docx, txt, md |
pypdf |
pdf (copie de page), txt, md |
|
| pptx | python-pptx |
txt, md (diapositive, forme, paragraphe, ou texte de cellule de tableau) |
L'épinglage docx n'est pas optionnel. La gate expectText de patch-copy compare un paragraphe lu
avec python-docx contre le même paragraphe lu par la propre paragraph_text du script,
qui reproduit l'élément Paragraph.text de python-docx par élément. Cette équivalence a été
vérifiée contre 1.x ; une version qui change ce que .text épelle ferait refuser la gate
les paragraphes que personne n'a modifiés. Citez le spécificateur — > et < sont des redirections vers un shell.
L'extraction xlsx lit les valeurs calculées (data_only), donc les cellules formule donnent leur dernier
résultat enregistré. L'extraction docx vers docx porte le texte uniquement, pas le style de passage.
Patch-copy — dériver une copie éditée, standard library uniquement
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'
Les paquets OOXML sont des ZIPs de parties XML. Patch-copy copie chaque partie octet par octet et
resérialise seulement ce qu'une édition atteint — la feuille de calcul cible ou word/document.xml, plus
la comptabilité du classeur notée ci-dessous pour xlsx — donc les styles, graphiques, images et macros dans
les parties intactes survivent exactement. Formes d'édition :
{"format":"xlsx","sheet":"S","cells":{"B2":42,"C3":"text","D4":true}}— nombres, chaînes et booléens ; une formule existante dans une cellule éditée est remplacée par la valeur. Patch-copy ne recalcule pas, donc une formule lisant une cellule éditée garde la valeur qu'elle a en dernier mis en cache ; chaque écriture définitfullCalcOnLoaddansxl/workbook.xmlafin qu'Excel recalcule celles-ci à l'ouverture. Remplacer une formule supprime aussixl/calcChain.xml(un cache de recalcul qu'Excel reconstruit), avec les entrées de type de contenu et de relation qui pointaient autrement sur une partie plus là ; garder une entrée de chaîne pour une cellule qui n'a plus de formule fait signaler à Excel le fichier dérivé comme endommagé. Les cellules dans un groupe de formule partagée, tableau ou formule de données sont refusées — l'expression vit dans un membre et les autres ne la référencent que, donc écraser un membre aurait dépouillé la formule des cellules que vous n'avez jamais nommées. Réécrivez une telle plage avecopenpyxl. Les coordonnées en dehors de la grille de feuille de calcul (au-delà de XFD ou ligne 1048576) sont aussi refusées.{"format":"docx","replacements":[{"paragraph":3,"text":"new text","paraId":"502E8D33","expectText":"old text"}]}— le paragraphe garde son style de paragraphe et le style de caractère du premier passage ; le style de niveau passage supplémentaire dans ce seul paragraphe est aplati dans le nouveau texte.textdoit être le paragraphe nouveau complet. Le paragraphe entier au niveau du corps est remplacé, etcharRangen'affine pas cela — renvoyer une tranchecharRangecommetextsupprime silencieusement le reste de la phrase. La sortie est exactementw:p > [w:pPr] + w:r > [w:rPr] + w:t, donc un paragraphe tenant quelque chose que cette forme ne peut pas porter est refusé, pas silencieusement dépouillé. Cela couvre les signets, les ancrages de commentaires et les champs (leur début/fin peuvent s'appairer entre paragraphes, et réécrire une moitié déséquilibre le document), les images, les objets incorporés, les références de note de bas de page/endnote, les hyperliens, les changements et mouvements suivis, les contrôles de contenu, les équations — et tout autre chose ne figurant pas sur la courte liste d'autorisation, incluant les éléments d'espaces de noms qui n'existaient pas quand ceci a été écrit. Unew:delsupprimée accepterait même une suppression en attente au nom de l'utilisateur. Un saut de page ou de colonne (<w:br w:type="page"/>) est refusé pour une raison plus calme : il ne porte aucun caractère, donc l'extraction lit le texte de chaque côté comme une chaîne et la vérification d'ancre ne peut pas voir que la réécriture le supprimerait. Un simple<w:br/>saut de ligne passe toujours. Pour éditer ce paragraphe, voir « Edit docx » ci-dessous — ne vous tournez pas versParagraph.text, qui détruit exactement le même contenu, silencieusement seulement.paraId(optionnel) est résolu avant l'ordinal ; un désaccord entre les deux est une erreur, jamais un choix silencieux.expectText(optionnel mais fortement recommandé) est une hard gate : le texte actuel du paragraphe cible doit le correspondre après normalisation d'espaces blancs ou l'édition est refusée. Prenez sa valeur d'une extraction du même paragraphe prise sanscharRange— la gate compare le paragraphe entier, et unexcerptde référence de sélection est tronqué à 2000 caractères et peut couvrir plus que la cible d'édition.- Tout texte écrit dans une cellule ou un paragraphe doit être stockable en XML : les caractères de contrôle
autre que tabulation, saut de ligne et retour à la ligne sont refusés. Le texte extrait d'un deck peut
les porter (
python-pptxmappe un saut de ligne en douceur à\x0B), donc nettoyez-les avant de renvoyer le texte extrait comme valeur d'édition. Un paragraphe docx refuse aussi tabulation, saut de ligne et retour à la ligne : WordprocessingML épelle ceux-ci<w:tab/>et<w:br/>, et le uniquew:tque cette réécriture émet ne peut pas les porter. Extraire un paragraphe qui les tient vous donne\t/\n, donc éditer cette chaîne et la réécrire supprimerait les éléments tout en se lisant identiquement — divisez le contenu sur des paragraphes au niveau du corps séparés, ou voir « Edit docx » ci-dessous.
Les comparaisons de texte des deux côtés de ce skill utilisent une règle de normalisation, identique
à celui du renderer normalizeSelectionText : normaliser NFC, réduire chaque
suite d'espaces blancs à un seul espace, trimmer les fins.
Generate — écrire du code library ad-hoc pour les nouveaux documents
La génération (un nouveau deck, classeur ou document — du début ou à partir de données que vous
venez d'extraire) n'a pas de fichier source à protéger, donc pas de script fixe : écrivez un
court programme Python contre la librairie correspondante (python-pptx, openpyxl,
python-docx) et lancez-le via uv run --with <pkg> python. Les deux invariants skill
s'appliquent toujours : écrivez sur un chemin qu'aucun rien occupe — ouvrez-le avec "xb"
plutôt que de remettre un nom à la librairie, puisque chacune de ces méthodes save() écrase — et
vérifiez la sortie en la rouvrant avant de signaler le succès.
Edit docx — éditer les passages, jamais Paragraph.text
Quand patch-copy refuse un paragraphe, python-docx peut quand même l'éditer — mais seulement passage par passage.
N'affectez jamais paragraph.text = "..." : cela inflige exactement la perte que patch-copy a refusée, moins
le refus. save(path) écrase, donc ouvrez la destination avec "xb".
Lisez references/docx-edit.md avant d'écrire tout édition python-docx.
Edit pptx — utiliser python-pptx, en enregistrant vers un nouveau chemin
Les éditions pptx ne passent pas par office_patch_copy.py. N'affectez jamais .text au niveau paragraphe ou
forme : cela reconstruit ce sous-arbre comme un passage non formaté unique. Éditez les passages, et ouvrez la
destination avec "xb".
Lisez references/pptx-edit.md avant d'écrire tout édition python-pptx.
Conventions de sortie
- Nommez les fichiers dérivés d'après la source avec un suffixe d'opération :
report.xlsx→report-updated.xlsx,report-q1-range.csv,spec-p3.txt. - Écrivez dans l'espace de travail de la session (ou où l'utilisateur a demandé). Les deux scripts affichent le chemin écrit en cas de succès — rapportez-le à l'utilisateur.
--fileet--outdoivent tous deux être absolus ; un chemin relatif est refusé plutôt que résolu contre quel que soit le répertoire de travail où le shell se trouve.- La sortie est mise en scène et renommée en cas de succès, donc une exécution échouée ne laisse rien derrière et la même commande peut être retentée au même chemin.
Vérifier avant de signaler le succès
Rouvrez toujours le fichier dérivé avec le lecteur correspondant et vérifiez le résultat, par ex. :
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 lit les valeurs mises en cache et patch-copy ne recalcule pas, donc une formule lisant une
cellule que vous venez d'écrire lit toujours son ancien résultat. Vérifiez les cellules que vous avez écrites, et dites à
l'utilisateur que les totaux dépendants sont recalculés quand Excel ouvre le fichier.
Une cellule chaîne xlsx écrite qui tenait un littéral _xHHHH_ ou un retour à la ligne se relit via
openpyxl sous sa forme échappée (_x005F_xHHHH_, _x000D_) — voir la note ST_Xstring sous
« Limits ». C'est le lecteur, pas un échec de vérification.
Si la vérification échoue, dites-le et montrez l'erreur — ne présentez pas un fichier non vérifié.
Limites
- Les éditions pptx passent par python-pptx (voir « Edit pptx »), pas patch-copy ; la copie de diapositive
dans un nouveau deck n'est pas supportée (
python-pptxne peut pas cloner les diapositives) — dites-le quand on vous le demande. - Les cellules chaîne xlsx patchées deviennent des chaînes en ligne (OOXML valide ; Excel les lit bien). Leur
texte est écrit comme ST_Xstring, donc un littéral
_xHHHH_dans la valeur est échappé à_x005F_xHHHH_et un retour à la ligne est stocké comme_x000D_— c'est ce qui rend Excel afficher les caractères qui ont été écrits. openpyxl ne décode ni l'un ni l'autre, donc une lecture de vérification d'une telle cellule montre la forme échappée ; c'est le lecteur, pas le fichier. - Les parties XML éditées peuvent perdre les détails de sérialisation insignifiants (guillemets d'attribut, forme d'élément vide) ; les préfixes d'espace de noms et le contenu intouché sont préservés.
- Un paragraphe docx patché garde ses caractères mais pas chaque opportunité de saut :
w:softHyphenlaisse aucune marque dans l'extraction et est supprimé, etw:noBreakHyphenrevient comme un plain-, donc ce que les deux perdent est seulement où une ligne peut se casser.w:symest refusé à la place — son glyphe est un caractère que le lecteur peut voir, pas un indice de tiret. - L'extraction xlsx refuse les plages sur 1 000 000 de cellules ; les deux scripts refusent les paquets avec plus de 10 000 entrées, une entrée sur 256 MiB décompressée, ou plus de 1 GiB total décompressé — demandez à l'utilisateur une sélection ou un fichier plus petit au lieu de réessayer.
- L'extraction xlsx ne masque pas les followers de fusion — le lecteur en continu ne peut pas voir les plages de fusion. Excel et openpyxl effacent les cellules couvertes quand une fusion est faite, donc les followers s'extraient normalement comme vides, correspondant à ce que l'utilisateur voit ; un fichier qui gardait du texte caché sous une fusion (LibreOffice l'offre) extrait ce texte comme sa propre cellule. Si la vérification d'ancre échoue dans une plage fusionnée, regardez les cellules avant de dire à l'utilisateur de resélectionner.
- csv et md portent le texte de cellule verbatim, texte ressemblant à une formule inclus (
=SUM(A1),+1,-) ; une feuille de calcul ouvrant le csv l'évaluerait, ce qui importe seulement pour une source que l'utilisateur ne fait pas confiance — dites-le s'ils veulent ouvrir le csv dans une. Dans le xlsx dérivé, le texte qu'openpyxl retyperait — un=de début en formule live, un code erreur comme#N/Aen valeur erreur — est écrit en retour comme texte préfixé de guillemet ;+1et-sont déjà stockés comme texte et restent ainsi, et une cellule qui tient vraiment une erreur reste une erreur. - Les PDF numérisées/image uniquement ne donnent aucun texte (pas d'OCR ici).