Macros
TDF LibreOffice Document Liberation Project Blogs communautaires Weblate Nextcloud Redmine Ask LibreOffice Donner
⇐ Ma première macro |Table des matières| Introduction au développement de macros Python ⇒
Principes de base
Comment essayer des scripts de macros et comment intégrer des macros à un fichier LibreOffice ?
Python et le mode interactif avec LibreOffice
Avant toute création d'une macro python, vous pouvez tester vos macros avec un shell python interactif.
Voyons comment faire avec notre macro bonjour.py :
Vous devez d'abord lancer LibreOffice en mode serveur pour pouvoir communiquer avec votre shell python.
Sous Linux :
libreoffice --accept="socket,host=localhost,port=2002;urp;StarOffice.ServiceManager" &
Sous macOS :
/Applications/LibreOffice.app/Contents/MacOS/soffice --accept="socket,host=localhost,port=2002;urp;StarOffice.ServiceManager" &
Sous Windows :
"C:\\Program Files (x86)\LibreOffice 5\program\soffice.exe" --accept="socket,host=localhost,port=2002;urp;"
Après lancez l'interpréteur python dans une console terminal bash, pour Linux et macOS, ou DOS avec cmd.exe pour Windows.
Sous Linux :
python3
Sous macOS :
/Applications/LibreOffice.app/Contents/MacOS/python
Sous Windows :
"C:\\Program Files (x86)\LibreOffice 5\program\python.exe"
Puis saisissez ces lignes dans l'interpréteur Python :
import uno
from com.sun.star.awt.MessageBoxType import MESSAGEBOX
# Récupération du contexte d'exécution PyUNO
ctxlocal = uno.getComponentContext()
# Récupération du gestionnaire de services du contexte local PyUNO
gslocal = ctxlocal.ServiceManager
# création d'un connecteur logiciel UnoUrlResolver pour dialoguer en appel distant de procédures RPC
connecteur = gslocal.createInstanceWithContext( "com.sun.star.bridge.UnoUrlResolver", ctxlocal )
# Récupération du contexte de LibreOffice
ctx = connecteur.resolve( "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext" )
# Récupération du gestionnaire de services du contexte de Libreoffice
gestionaireadministration = ctx.ServiceManager
# Récupérer l'objet principal du bureau LibreOffice
bureau = gestionaireadministration.createInstanceWithContext( "com.sun.star.frame.Desktop",ctx)
# Récupérer l'affichage actuel
cadre = bureau.getCurrentFrame()
# Créer la fenêtre de la boite de dialogue
fenêtre = cadre.getContainerWindow()
# Récupérer les outils de gestion de la fenêtre
boîteàoutils = fenêtre.getToolkit()
# Affectation du titre de la boîte de dialogue à une variable
titre = "Python"
# Affectution du message à afficher dans la boîte de dialogue à une variable
message = "Bonjour à tous !"
# Création du modèle de boite de dialogue
boîtemessage = boîteàoutils.createMessageBox(fenêtre, MESSAGEBOX, 1, titre, message)
# Création de l'objet boîte de dialogue
boîtemessage.execute()
Voici deux schémas d'explication du fonctionnement du mode interactif et macro :
Emplacement des fichiers macro
Vous avez trois possibilités d'emplacements pour vos fichiers de macro.
Ajout à la bibliothèque de LibreOffice
Copier votre macro dans :
Linux :
/usr/lib/libreoffice/share/Scripts/python/
macOS :
/Applications/LibreOffice.app/Contents/Resources/Scripts/python/
Windows :
C:\Program Files (x86)\LibreOffice 5\share\Scripts\python\
Ajout à la bibliothèque de l'utilisateur
Copier votre macro dans :
Linux :
~/.config/libreoffice/4/user/Scripts/python/
macOS :
Windows :
C:\Users\«User»\AppData\Roaming\LibreOffice\4\user\Scripts\python\
Ajouter dans un document LibreOffice
Les fichiers LibreOffice sont des documents compressés de structure de répertoires. Si vous décompressé un fichier ODT par exemple :
unzip test.odt
Vous obtenez l'arborrescence :
. ├── Configurations2 │ ├── accelerator │ ├── floater │ ├── images │ │ └── Bitmaps │ ├── menubar │ ├── popupmenu │ ├── progressbar │ ├── statusbar │ ├── toolbar │ └── toolpanel ├── content.xml ├── manifest.rdf ├── META-INF │ └── manifest.xml ├── meta.xml ├── mimetype ├── settings.xml ├── styles.xml └── Thumbnails └── thumbnail.png
Il suffit de créér les répertoires :
Scripts/python
et de copier votre script bonjour.py pour avoir cette arborescence
. ├── Configurations2 │ ├── accelerator │ ├── floater │ ├── images │ │ └── Bitmaps │ ├── menubar │ ├── popupmenu │ ├── progressbar │ ├── statusbar │ ├── toolbar │ └── toolpanel ├── content.xml ├── manifest.rdf ├── META-INF │ └── manifest.xml ├── meta.xml ├── mimetype ├── Scripts │ └── python │ └── bonjour.py ├── settings.xml ├── styles.xml ├── test.odt └── Thumbnails └── thumbnail.png
Après il faut modifier le fichier META-INF/manifest.xml et ajouter :
<manifest:file-entry manifest:media-type="application/binary" manifest:full-path="Scripts/"/>
<manifest:file-entry manifest:media-type="application/binary" manifest:full-path="Scripts/python/"/>
<manifest:file-entry manifest:media-type="application/binary" manifest:full-path="Scripts/python/bonjour.py"/>
soit le fichier complet :
<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
<manifest:file-entry manifest:full-path="/" manifest:version="1.2" manifest:media-type="application/vnd.oasis.opendocument.text"/>
<manifest:file-entry manifest:full-path="Thumbnails/thumbnail.png" manifest:media-type="image/png"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="settings.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="Configurations2/" manifest:media-type="application/vnd.sun.xml.ui.configuration"/>
<manifest:file-entry manifest:full-path="manifest.rdf" manifest:media-type="application/rdf+xml"/>
<manifest:file-entry manifest:media-type="application/binary" manifest:full-path="Scripts/"/>
<manifest:file-entry manifest:media-type="application/binary" manifest:full-path="Scripts/python/"/>
<manifest:file-entry manifest:media-type="application/binary" manifest:full-path="Scripts/python/bonjour.py"/>
</manifest:manifest>
Puis vous devez recompresser les fichiers :
zip -r test.odt *
Script python d'intégration de macros dans un document LibreOffice
#!/usr/bin/env python3
#-*-coding: utf-8 -*-
#
#Application : ajout_macro.py
#Objectif : Utilitaire de gestion de la présence de macros python
# dans un document LibreOffice.
#Auteur : Franc SERRES
#Licence : https://www.gnu.org/licenses/gpl-3.0.fr.html
#
#Ajout des librairies de gestion des paramètres passés au script
from optparse import OptionParser
#Ajout des librairies de gestion des fichiers du système
import os
import shutil
#Ajout des librairies de gestion de detection des types de fichiers
import magic
#Ajout des librairies de gestion de la compression zip
import zipfile
#Ajout des librairies de gestion des fichiers XML
import xml.etree.ElementTree as xml
#Ajout des librairies de gestion des fichiers temporaires
import tempfile
def supprime_du_zip(nomfzip, *nomsfichiers):
"""
Supprime des fichiers d'un fichier archive zip
"""
#Création d'un répertoire temporaire pour le traitement du zip
reptemp = tempfile.mkdtemp()
#Test de la suppression
drapeau = False
try:
#Création d'un zip temporaire de mise à jour
nomtemp = os.path.join(reptemp, 'nouveau.zip')
#Ouverture du fichier zip d'origine
with zipfile.ZipFile(nomfzip, 'r') as lecturezip:
#Ouverture du fichier de destination
with zipfile.ZipFile(nomtemp, 'w') as majzip:
#Lecture des élément du fichier d'origine
for element in lecturezip.infolist():
#L'élément est-il a supprimer ?
if element.filename not in nomsfichiers:
if options.verbose:
print('\033[1;37;40m Garde l\'élément \033[1;36;40m%s\033[0m'%(element.filename))
#Décompression depuis le fichier d'origine
donnees = lecturezip.read(element.filename)
#Écriture du fichier à garder dans le fichier zip de mise à jour
majzip.writestr(element, donnees)
else:
drapeau = True
if options.verbose:
print('\033[1;37;40m Supprime l\'élément \033[1;36;40m%s\033[0m'%(element.filename))
#Remplacement du zip d'origine avec le nouveau mis à jour
shutil.move(nomtemp, nomfzip)
finally:
#supprime le répertoire temporaire
shutil.rmtree(reptemp)
if drapeau:
#Le fichier a été trouvé et suppriné
print('\033[1;31;40m Fichier %s supprimé du zip\033[0m'%(nomsfichiers))
return True
else:
#Le fichier n'était par présent
print('\033[1;31;40m Le fichier %s n\'était pas dans le zip\033[0m'%(nomsfichiers))
return False
def affiche_structure_fichier_libreoffice(fichierlibreoffice, options):
"""
Affichage formaté du contenu d'un fichier LibreOffice
"""
#Affiche le contenu du fichier LibreOffice
if options.verbose:
print('\033[1;34;40mOuvre le fichier %s\033[0m'%(fichierlibreoffice))
print('\033[1;34;40m----------------\033[0m')
#Ouvre le fichier compressé LibreOffice
fichierLOo = zipfile.ZipFile(fichierlibreoffice, 'r')
#Test de la lecture
try:
#Parcour son contenu
for contenu in fichierLOo.infolist():
#Affiche le fichier avec sa taille et sa compression
print('\033[1;36;40m %s \033[1;33;40m(Taille %s --> Taille compressée %s)\033[0m'%(
contenu.filename, contenu.file_size, contenu.compress_size))
if options.verbose:
if 'Scripts/python/' in contenu.filename:
if '.py' in contenu.filename:
print('\033[1;31;40m Script python\033[0m')
#Affiche le contenu du fichier LibreOffice manifest
if contenu.filename == 'META-INF/manifest.xml':
#Récupère le fichier manifest.xml
fichier_namifest = fichierLOo.read(contenu.filename)
if options.verbose:
if 'application/binary' in str(fichier_namifest):
print('\033[1;31;40m Fichier manifest : %s\033[0m'%(contenu.filename))
#Transforme le fichier manifest.xml en objet utilisable par python
arbre = xml.fromstring(fichier_namifest)
#Parcour le fichier manifest.xml pour voir ses macros enregistrées
for entree_fichier_manifest in arbre:
#Test les données de type media-type où se trouve les macros
if entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}media-type'
) == 'application/binary':
if options.verbose:
print('\033[1;37;40m Element de macros : \033[1;36;40m%s %s\033[0m'%(
entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}media-type'),
entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path')))
finally:
if options.verbose:
print('\033[1;34;40m----------------\033[0m')
print('\033[1;34;40mFerme le fichier %s\033[0m'%(fichierlibreoffice))
fichierLOo.close()
def maj_macro_libreoffice(fichierlibreoffice, options):
#Suppression de la macro python dans le fichier LibreOffice
if options.verbose:
print('\033[1;34;40mSuppression en cours de la macro %s du fichier LibreOffice %s\033[0m'%(options.macro, fichierlibreoffice))
if supprime_du_zip(fichierlibreoffice, 'Scripts/python/' + options.macro):
if options.verbose:
print('\033[1;31;40m Macro %s supprimée du fichier %s\033[0m' % (options.macro, fichierlibreoffice))
else:
if options.verbose:
print('\033[1;31;40m La macro %s n\'était pas présente dans le fichier %s\033[0m' % (options.macro, fichierlibreoffice))
#Ajout de la macro python mise à jour dans le fichier LibreOffice
if options.verbose:
print('\033[1;34;40mMise à jour de la macro %s dans le fichier LibreOffice %s en cours\033[0m'%(options.macro, fichierlibreoffice))
#Mise à jour de la macro
with zipfile.ZipFile(fichierlibreoffice, 'a') as majlibreoffice:
majlibreoffice.write(options.macro, 'Scripts/python/' + options.macro)
if options.verbose:
print('\033[1;31;40m Macro %s à jour\033[0m'%(options.macro))
def ajoute_macro_libreoffice(fichierlibreoffice, options):
print('\033[1;34;40mAjout de la macro %s dans le fichier LibreOffice %s\033[0m'%(options.macro, fichierlibreoffice))
maj_macro_libreoffice(fichierlibreoffice, options)
print('\033[1;34;40mModification du fichier manifest du document LibreOffice\033[0m')
arbre = None
drapeau_macros = True
drapeau_python = True
drapeau_macro_python = True
try:
#Ouvre le fichier compressé LibreOffice
fichierLOo = zipfile.ZipFile(fichierlibreoffice, 'a')
for contenu in fichierLOo.infolist():
if contenu.filename == 'META-INF/manifest.xml':
fichier_namifest = fichierLOo.read(contenu.filename)
#Transforme le fichier manifest.xml en objet utilisable par python
arbre = xml.fromstring(fichier_namifest)
#Parcour le fichier manifest.xml pour trouver les éléments de macros à créer dans le fichier manifest.xml
for entree_fichier_manifest in arbre:
#Teste si l'entrée est un script
if 'Scripts/' in entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path'):
#Élément de macros présent dans le fichier LibreOffice
drapeau_macros = False
#Teste si l'entrée est un script python
if 'Scripts/python/' in entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path'):
#Élément de macros python présent dans le fichier LibreOffice
drapeau_python = False
if entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path') == 'Scripts/python/' + options.macro:
#Macro déjà présente dans le fichier manifest.xml
drapeau_macro_python = False
#Création de la référence 'Scripts/' des macros
if drapeau_macros:
element = xml.Element('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}file-entry')
element.text = ''
element.set('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path', 'Scripts/')
element.set('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}media-type', 'application/binary')
#ajout de l'élément dans l'arborescence XML
arbre.append(element)
#Création de la référence 'Scripts/python/' des macros python
if drapeau_python:
element = xml.Element('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}file-entry')
element.text = ''
element.set('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path', 'Scripts/python/')
element.set('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}media-type', 'application/binary')
#ajout de l'élément dans l'arborescence XML
arbre.append(element)
#Création de la référence à la macro python
if drapeau_macro_python:
element = xml.Element('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}file-entry')
element.text = ''
element.set('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path', 'Scripts/python/' + options.macro)
element.set('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}media-type', 'application/binary')
#ajout de l'élément dans l'arborescence XML
arbre.append(element)
finally:
fichierLOo.close()
#Mise à jour du fichier manifest.xml dans le document LibreOffice
if options.verbose:
print('\033[1;37;40m manifest.xml :\n\033[1;36;40m%s\033[0m'%(xml.tostring(arbre, encoding='unicode').replace('><', '>\n<')))
if drapeau_macro_python:
if arbre:
if options.verbose:
print('\033[1;34;40mMise à jour de META-INF/manifest.xml dans %s\033[0m'%(fichierlibreoffice))
supprime_du_zip(fichierlibreoffice, 'META-INF/manifest.xml')
with zipfile.ZipFile(fichierlibreoffice, 'a') as fichierLOo:
fichierLOo.writestr('META-INF/manifest.xml', xml.tostring(arbre, encoding='unicode'), zipfile.ZIP_DEFLATED)
print('\033[1;31;40m META-INF/manifest.xml mis à jour dans %s\033[0m'%(fichierlibreoffice))
else:
print('\033[1;31;40m META-INF/manifest.xml déjà à jour dans %s\033[0m'%(fichierlibreoffice))
def supprime_macro_libreoffice(fichierlibreoffice, options):
print('\033[1;34;40mSuppression de la macro %s du fichier LibreOffice %s\033[0m'%(options.macro, fichierlibreoffice))
if supprime_du_zip(fichierlibreoffice, 'Scripts/python/' + options.macro):
if options.verbose:
print('\033[1;31;40m La macro %s a été supprimée du fichier %s\033[0m' % (options.macro, fichierlibreoffice))
else:
if options.verbose:
print('\033[1;31;40m La macro %s n\'était pas présente dans le fichier %s\033[0m' % (options.macro, fichierlibreoffice))
print('\033[1;34;40mModification du fichier manifest.xml du document LibreOffice\033[0m')
arbre = None
drapeau_macros = True
drapeau_python = True
try:
#Ouvre le fichier compressé LibreOffice
fichierLOo = zipfile.ZipFile(fichierlibreoffice, 'a')
for contenu in fichierLOo.infolist():
if contenu.filename == 'META-INF/manifest.xml':
fichier_namifest = fichierLOo.read(contenu.filename)
#Transforme le fichier manifest.xml en objet utilisable par python
arbre = xml.fromstring(fichier_namifest)
#Parcour le fichier manifest.xml pour supprimer la macro python enregistrées
for entree_fichier_manifest in arbre:
#Teste si l'entrée est un script
if 'Scripts/' in entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path'):
#Teste si l'entrée est un script python
if 'Scripts/python/' in entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path'):
#Teste si c'est la macro python dont on demande la suppression
if entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path') == 'Scripts/python/' + options.macro:
#Supprime la référence à la macro python du fichier manifest.xml
arbre.remove(entree_fichier_manifest)
if options.verbose:
print('\033[1;37;40m Supprime la référence à la macro python \033[1;36;40m%s\033[0m'%(
entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path')))
else:
#Teste si cela n'est pas l'entrée chemin du manifest
if entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path') != 'Scripts/python/':
#présence d'une autre macro python
drapeau_macros = False
drapeau_python = False
if options.verbose:
print('\033[1;37;40m Garde la référence à la macro python \033[1;36;40m%s\033[0m'%(
entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path')))
else:
#Teste si cela n'est pas l'entrée chemin du manifest
if entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path') != 'Scripts/':
#présence d'autres macros non python LibreOffice
drapeau_macros = False
if options.verbose:
print('\033[1;37;40m Garde la référence à la macro LibreOffice \033[1;36;40m%s\033[0m'%(
entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path')))
#Suppression de la référence arborescente des macros python
if drapeau_python:
for entree_fichier_manifest in arbre:
if entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path') == 'Scripts/python/':
#Supprime l'entrée du fichier manifest.xml
arbre.remove(entree_fichier_manifest)
#Suppression de la référence arborescente des macros
if drapeau_macros:
for entree_fichier_manifest in arbre:
if entree_fichier_manifest.get('{urn:oasis:names:tc:opendocument:xmlns:manifest:1.0}full-path') == 'Scripts/':
#Supprime l'entrée du fichier manifest.xml
arbre.remove(entree_fichier_manifest)
finally:
fichierLOo.close()
#Mise à jour du fichier manifest.xml dans le document LibreOffice
if options.verbose:
print('\033[1;37;40m manifest.xml :\n\033[1;36;40m%s\033[0m'%(xml.tostring(arbre, encoding='unicode').replace('><', '>\n<')))
if arbre:
if options.verbose:
print('\033[1;34;40mMise à jour de META-INF/manifest.xml dans %s\033[0m'%(fichierlibreoffice))
supprime_du_zip(fichierlibreoffice, 'META-INF/manifest.xml')
with zipfile.ZipFile(fichierlibreoffice, 'a') as fichierLOo:
fichierLOo.writestr('META-INF/manifest.xml', xml.tostring(arbre, encoding='unicode'), zipfile.ZIP_DEFLATED)
print('\033[1;31;40m META-INF/manifest.xml mis à jour dans %s\033[0m'%(fichierlibreoffice))
###########################################################
# Programme principal #
###########################################################
#
# Initialisation de l'environnement
#
#Message par defaut d'aide à la commande pour le script
utilisation = 'usage: \033[1;37;40m%prog\033[0m [\033[1;31;40m-d\033[0m] [\033[1;33;40m-m macro_LibreOffice.py\033[0m] \033[1;37;40mfichier_LibreOffice\033[0m'
#Message d'aide de version de ce scipt
version = '%prog 1.0'
arguments = OptionParser(usage=utilisation, version=version)
#Ajout de l'option ligne de commande du mode verbeux
arguments.add_option('-v', '--verbose', action='store_true', dest='verbose', help='La commande est en mode bavard')
#Ajout de l'option ligne de commande de passage le la macro
arguments.add_option('-m', '--macro', dest='macro', help='fichier macro python', metavar='FILE')
#Ajout de l'option ligne de commande de suppression de la macro du fichier LibreOffice
arguments.add_option('-d', '--delete', action='store_true', dest='delete_macro', help='supression de la macro python du fichier LibreOffice', metavar='FILE')
#Mode passage de paramatres de la ligne de commande juste après l'option
arguments.disable_interspersed_args()
#
#Création de l'objet de traitement de la ligne de commande
#
(options, args) = arguments.parse_args()
#
# Gestion du programme
#
#Teste si il y a un argument passé au script
if len(args) == 1:
#Teste l'existance du fichier
if os.path.exists(args[0]):
#Teste que l'argument est un fichier LibreOffice
if 'opendocument' in magic.detect_from_filename(args[0]).mime_type:
#Teste si une macro est passée en paramètre
if options.macro:
#Teste si le fichier existe
if os.path.exists(options.macro):
#Teste si le fichier est un fichier python
if '.py' in options.macro:
if options.verbose:
print('\033[1;34;40mModification du fichier LibreOffice %s\033[0m'%(args[0]))
if options.delete_macro:
#Demande de suppression de la macro
supprime_macro_libreoffice(args[0], options)
else:
#Demande d'ajout de la macro
ajoute_macro_libreoffice(args[0], options)
else:
print('\033[1;31;40mAttention votre fichier %s n\'a pas une extension python «.py»\033[0m'%(options.macro))
else:
print('\033[1;31;40mLe script ne trouve pas le fichier macro %s\033[0m'%(options.macro))
else:
affiche_structure_fichier_libreoffice(args[0], options)
else:
print('\033[1;31;40mVotre fichier n\'est pas un document LibreOffice\033[0m')
else:
print('\033[1;31;40mLe fichier %s n\'a pas été trouvé\033[0m'%(args[0]))
else:
arguments.print_help()
⇐ Ma première macro | Introduction au développement en Python ⇒