Αντρω δι Νεξυνω

Posts written by nuzzopippo

view post Posted: 22/4/2024, 12:18 [Python] Tkinter e thread paralleli, un esempio - Appunti di apprendimento
I miei saluti ad eventuali, improbabili, lettori

Questo post nasce, tanto per cambiare, dal voler fornire una possibile risposta alla domanda:
CITAZIONE
il mio problema ora è di far convivere nello stesso script l'interfaccia tkinter (che so essere task monopolizzate) e la routine che devo eseguire ogni 30/60 secondi per vedere se ci sono messaggi nuovi.

richiesta posta in questo post di python.it

Il mainloop di tkinter, come in qualsiasi altro framework grafico, è un thread bloccante, ciò vale a dire che mentre agisce niente al di fuori di esso può essere eseguito ... quasi.
Ovviamente, metodi per soddisfare la richiesta sopra ce ne sono, già da tkinter stesso (p.e., un self.after(...) richiamante ricorsivamente un metodo di una classe appositamente definita) ma, personalmente, nel tempo ho acquisito l'abitudine, per operazioni di una certa consistenza, che potrebbero, quindi, bloccare il refresh della GUI, di utilizzare i thread, magari definendoli quali "daemon" in caso sia necessario occasionalmente e/o a tempi non determinati il loro intervento.
Ho deciso, quindi, di realizzare una piccola applicazione di test, utilizzante detto approccio, da proporre a @trescon, proponente il quesito.
In sostanza una piccola applicazione grafica che legga, da un database SQLite, in situazione di accesso concorrente, dei messaggi che non ha già letto e li esponga all'user della applicazione ... ovviamente, non sono andato ad impelagarmi nella implementazione di una applicazione finita, l'esempio implementato è per la sola lettuea, il database ed i "messaggi" vengono definiti in una sessione di shell aperta in concorrenza con la applicazione grafica, come nella sottostante immagine
png

limitandosi, i processi implementati nello esempio, ad intercettare i messaggi non già letti dal nick-user, comunicarli all'utente e registrare l'avvenuta lettura, in modo che non si ripeta l'indicazione del messaggio in un secondo momento

png

Le immagini sopra spiegano, meglio di mille parole cosa fa l'esempio posto in essere.

Ovviamente, per poter effettuare l'esempio ho dovuto creare manualmente un minimale database così composto

NzP:~$ sqlite3 for_trescon.db
SQLite version 3.37.2 2022-01-06 13:25:41
Enter ".help" for usage hints.
sqlite> .tables
messages messages_read
sqlite> .schema messages
CREATE TABLE messages(
id INTEGER PRIMARY KEY AUTOINCREMENT,
nick VARCHAR(30),
msg TEXT);
sqlite> .mode box --wrap 30
sqlite> SELECT * FROM messages;
┌──┬───────┬────────────────────────┐
│ id │ nick │ msg │
├──┼───────┼────────────────────────┤
│ 1 │ nuzzopippo │ Un primo messaggio per esempio a @trescon │
└──┴───────┴────────────────────────┘
sqlite> .schema messages_read
CREATE TABLE messages_read(
message_id INTEGER,
readernick VARCHAR(30) NOT NULL,
UNIQUE(message_id, readernick),
FOREIGN KEY(message_id) REFERENCES messages(id));
sqlite> SELECT * FROM messages_read;
sqlite>

Scusate la pessima resa della esposizione tabellare dei dati, comunque, nulla di più che una tavola, "messages", con indice auto-incrementante in cui inserire un nick ed il messaggio da comunicare, ed una tavola, "messages_read", ove registrare l'identificativo di un messaggio letto ed il nick di chi lo ha letto, in maniera da poter escludere, in una query, quelli già letti.

Veniamo, ora, alle modalità di funzionamento dell'esempio realizzato, al fine di essere "chiaro" ho suddiviso gli script in tre moduli distinti, ognuno dedicato ad un particolare aspetto delle problematiche in essere ... vediamole un po'

Comunicazione tra i thread


Come accennato all'inizio tkinter agisce in un thread bloccante, ciò vuol dire che avviato il suo mainloop eventuale codice che segue l'istruzione di avvio non viene eseguita fin quando il mainloop non cessa di esistere.
Per loro natura i thread agiscono parallelamente ed indipendentemente l'uno dall'altro, con "ordine" stabilito dal sistema operativo, non condividono niente e non sanno nulla l'uno dell'altro ... tale situazione è spesso scomoda e ci sono diversi modi per affrontarla, nel corso del tempo ho guardato varie metodologie, una di quelle che ha soddisfatto di più le mie limitate necessità è stato il pattern "Publisher/Subscriber", per il quale esiste una specifica libreria python: pypubsub.
In sostanza, si tratta di inserire un "oggetto" condiviso dai vari threads che si incarica di "smistare" messaggi a processi che si sono iscritti (subscribers) per riceverli.
... anche, tale libreria, nel tempo, la trovai ridondante per le mie limitate necessità e da un po' mi diletto ad utilizzare una versione minimale, un "postino", scritta da me, tale classe è contenuta nel modulo "utils.py", il cui codice è :

CODICE
# -*- coding: utf-8 -*-

from functools import wraps

def singleton(o_cls):
  orig_new = o_cls.__new__
  inst_field = "__instance"

  @wraps(o_cls.__new__)
  def __new__(cls, *args, **kwargs):
      if (instance := getattr(cls, inst_field, None)) is None:
          instance = orig_new(cls)
          if hasattr(cls, "__init__"):
              cls.__init__(instance, *args, **kwargs)
              delattr(cls, "__init__")
          setattr(cls, inst_field, instance)
      return instance

  o_cls.__new__ = __new__
  return o_cls


@singleton
class Delivery:
  ''' Un "postino" tra moduli'''
 
  def __init__(self):
      self.subscr = {}
 
  def subscribe(self, group: str, method: any) -> None:
      ''' Aggiunge un subscriber ad un dato gruppo '''
      if not group in self.subscr.keys():
          self.subscr[group] = []
      if method in self.subscr[group]:
          raise RuntimeError('Metodo già acquisito nel gruppo %s' % group)
      self.subscr[group].append(method)

  def unsubscribe(self, group: str, method: any) -> None:
      ''' Rimuove un subscriber ad un dato gruppo, i gruppi vuoti vengono rimossi'''
      if not group in self.subscr.keys():
          raise RuntimeError('Gruppo %s non registrato' % group)
      if method not in self.subscr[group]:
          raise RuntimeError('Metodo non registrato nel gruppo %s' % group)
      self.subscr[group].remove(method)
      if not self.subscr[group]:
          del self.subscr[group]
         
 
  def send_message(self, group:str, message: list) -> None:
      ''' Distribuisce un messaggio ai subscriber di un gruppo '''
      if not group in self.subscr.keys():
          raise RuntimeError('Gruppo %s non registrato' % group)
      for s_func in self.subscr[group]:
          s_func(message)

E consiste nella classe "Delivery" che si occupa di smistare dei messaggi tra gruppi registrati di "utenti", chiamiamoli così ma, in sostanza, sono metodi o funzioni definiti dai vari clients.
Tale classe "Delivery" è un "singleton", vale a dire che malgrado siano presenti più istanze alla classe in vari moduli, funzioni e metodi, esse faranno comunque riferimento allo stesso oggetto che è unico per l'applicazione.
Per far si che un generico oggetto possa ricevere i messaggi smistati da Delivery esso deve iscrivere un suo metodo deputato a riceve il messaggio distribuito, tale messaggio consiste in una lista il cui primo elemento deve essere una stringa definente una operazione/evento, il secondo elemento può mancare oppure essere qualsiasi cosa, oggetti, dati od anche un'altra lista con definite delle ulteriori sub-operazioni/eventi ... ciò permette di definire dei protocolli ad-hoc per ciò che serve alla realizzazione delle proprie specifiche esigenze.
Una iscrizione ad un gruppo non necessariamente deve essere statica, può essere revocata quando non serve più e DEVE essere revocata quando un oggetto con metodo iscritto cessa la sua vita operativa.
Non necessariamente un oggetto che istanza Delivery deve avere un suo metodo iscritto, può emettere messaggi per i vari gruppi registrati senza riceverne.
I metodi principali sono :

  • subscribe(self, group: str, method: any) iscrive il metodo di un oggetto ad un gruppo;

  • unsubscribe(self, group: str, method: any) rimuove il metodo iscritto di un oggetto da un gruppo;

  • send_message(self, group:str, message: list) invia un messaggio a tutti i membri di un gruppo.


È prevista, nella classe Delivery, l'emissione di vari errori di run-time per situazioni improprie, che in una corretta implementazione del codice non dovrebbero accadere, per tali errori dovrebbe essere prevista l'intercettazione, p.e. all'atto di invio di un messaggio, cosa che nel ridotto esempio in essere non è stata fatta per non ridondare il codice.

Lettura messaggi dal database


Nell'esempio implementato, anche per la lettura del database ho dedicato uno specifico modulo, reading.py, 74 righe di codice che, in sostanza, implementano una singola classe "SQLReader", il codice:

CODICE
# -*- coding: utf-8 -*-

import os
import sys
from threading import Thread
import sqlite3
import traceback
import time

from utils import Delivery


class SQLReader:
   def __init__(self, nick: str) -> None:
       self.nick = nick
       self.still = True
       self.conn = False
       self.pub = Delivery()
       self.pub.subscribe('EVENT_OBSERVERS', self.event_observer)

   def event_observer(self, msg: any) -> None:
       event = msg[0]
       match event:
           case 'READ_MESSAGES':
               self._connect_and_read()
           case 'ON_CLOSING':
               if not self.conn:
                   self.pub.send_message('EVENT_OBSERVERS', ['THREAD_END',])
                   self.pub.unsubscribe('EVENT_OBSERVERS', self.event_observer)
                   return
               self.still = False
           case _:
               pass

   def _connect_and_read(self) -> None:
       if self.conn: return
       appdir = os.path.abspath(os.path.dirname(sys.argv[0]))
       db_name = os.path.join(appdir, 'for_trescon.db')
       t = Thread(target=self.read_messages, args=(db_name,))
       t.daemon = True
       t.start()
       
   def read_messages(self, db: str) -> None:
       try:
           conn = sqlite3.connect(db)
           self.conn = True
       except sqlite3.IntegrityError as e:
           txt = traceback.print_exc()
           msg = ['DBERROR', txt]
           self.pub.send_message('EVENT_OBSERVERS', msg)
           return
       while self.still:
           query = '''SELECT * FROM messages A
WHERE NOT EXISTS(SELECT * FROM messages_read B
                WHERE B.message_id = A.id
                AND B.readernick = ?)
ORDER BY a.ID'''
           res = conn.execute(query, (self.nick,))
           for r in res.fetchall():
               text = ' '.join([str(x) for x in r])
               msg = ['MSG_READING', text]
               self.pub.send_message('MESSAGE_OBSERVERS', msg)
               try:
                   query = 'INSERT INTO messages_read VALUES(?, ?)'
                   result = conn.execute(query, (r[0], self.nick))
                   conn.commit()
               except sqlite3.IntegrityError as e:
                   txt = traceback.print_exc()
                   msg = ['DBERROR', txt]
                   self.pub.send_message('EVENT_OBSERVERS', msg)
                   break
           time.sleep(10)
       conn.close()
       self.pub.send_message('EVENT_OBSERVERS', ['THREAD_END',])
       self.pub.unsubscribe('EVENT_OBSERVERS', self.event_observer)


... Qui si incomincia ad intravedere qualcosa circa l'architettura dell'esempio.
Alla sua inizializzazione la classe "SQLReader" DEVE ricevere il nick-name dell'utente lettore dei messaggi registrati nella base dati prima esposta, per poter discernere tra i messaggi registrati a suo nome e gli altri, inoltre, esempre in fase di inizializzazione, provvede ad istanziare un oggetto Delivery memorizzandolo nella variabile di istanza "self.pub" e quindi a registrare il proprio metodo "event_observer" nel gruppo "EVENT_OBSERVERS".
Si noti come SQLReader NON avvii alcuna lettura della base dati al suo instanziamento, sarà un evento proveniente dall'esterno (ossia un messaggio) a dirle di farlo.
Il metodo "def event_observer(self, msg: any) -> None:" iscritto come sopra detto, leggerà il primo elemento della lista "msg" ricevuta (è indicato come "any" per elasticità, ma dovrebbe essere "list" dato l'uso nell'esempio), tale primo elemento viene stabilito quale "evento" che sarà valutato per definire le azioni da intraprendere.
Nel caso l'evento ricevuto sia "READ_MESSAGES", verrà definito il database da aprire (fisso) ed avviato il thread di lettura quale daemon.
IMPORTANTE : è nel thread che viene stabilita la connessione al database SQLite3, tale circostanza è la parte più "delicata" dell'esempio qui riportato, delicata perché la connessione alla base dati deve essere eseguita all'interno del thread di consultazione, non essendo possibile invocare in un thread processi SQLite3 definiti in un altro thread, delicata anche perché la gestione degli errori nell'ambiente di sviluppo (3.10) è cambiata nella versione 3.11 di python.
Comunque, nella prova di test è filato tutto liscio e non ho avuto modo di vedere se le intercettazioni di errore dati predisposte siano buone o meno ... in ogni caso, quesro è solo un esempio.
Il thread avviato punta al metodo "read_messages(self, db: str)", di SQLReader, che si fa carico di stabilire una connessione al database ed ogni 10 secondi leggere i messaggi reicevuti nello stesso database e non letti dal nick associato alla classe, nel caso ne trovi compone un messaggio che invia al gruppo "MESSAGE_OBSERVERS" e registra nel database l'avvenuta lettura dei messaggi da parte del nick associato.
La ripetizione del ciclo di lettura del database è controllata dalla variabile di istanza "self.still", sin quando essa sarà "Vera" il ciclo verrà ripetuto, appena diverrà falsa si chiuderà il ciclo di lettura e verrà comunicato al gruppo EVENT_OBSERVERS un messaggio con "THREAD_END" quale primo (e unico) elemento, il daemon verrà chiuso.
Si noti che, malgrado sia iscritto al gruppo EVENT_OBSERVERS il metodo event_observer di SQLReader non gestisce l'evento THREAD_END, è la finestra utente ad occuparsi di tale evento, viene gestito, invece, l'evento "ON_CLOSING", scatenato dalla finestra utente che "vuole" chiudersi, ricevendo tale "evento" verrà verificata la condizione di connessione alla base dati (self.conn) se vera verrà posto a "False" self.still, altrimenti verrà generato un messaggio di evento THREAD_END ed inviato al gruppo EVENT_OBSERVERS ... si tenga presente questo fatto ;)

... cosa fa La finestra?


In questo esempio la finestra ha un ruolo passivo, come si può evincere dalle figure prima esposte, essa si limita a notificare la volontà di leggere i (nuovi) messaggi ed a riportare quanto ricevuto, è dalla sessione DQLite nella shell che vengono inseriti i messaggi poi letti, ciò per semplificare al massimo l'esempio posto, cercando di renderlo quanto più comprensibile riesca.
Il codice sorgente della finestra (70 righe) io ho denominato il modulo main.py

CODICE
# -*- coding: utf-8 -*-

import tkinter as tk
from tkinter import messagebox as msgb

from utils import Delivery
from reading import SQLReader


class App(tk.Tk):
   def __init__(self):
       super().__init__()
       self.protocol('WM_DELETE_WINDOW', self.__on_close)
       self._populate()
       self.pub = Delivery()
       self.pub.subscribe('EVENT_OBSERVERS', self._messages)
       self.pub.subscribe('MESSAGE_OBSERVERS', self._messages)
       self.reader = SQLReader('trescon')

   def _populate(self) -> None:
       self.title('Esempio per @trescon')
       lbl = tk.Label(self, text='Messaggi rilevati:')
       lbl.grid(row=0, column=0, columnspan=2, padx=5, pady=5, sticky='w')
       self.t_msg = tk.Text(self)
       self.t_msg.grid(row=1, column=0, padx=(5,2), pady=5, sticky='nsew')
       vscroll = tk.Scrollbar(self, orient=tk.VERTICAL,
                              command=self.t_msg.yview)
       vscroll.grid(row=1, column=1, padx=(2,5), pady=5, sticky='ns')
       self.t_msg.configure(yscrollcommand=vscroll.set)
       pn_cmd = tk.Frame(self)
       pn_cmd.grid(row=2, column=0, columnspan=2, padx=5, pady=5, sticky='ew')
       bt_connect = tk.Button(pn_cmd, text='Connetti al database',
                              command=self._on_connect)
       bt_connect.grid(row=0, column=0, padx=2, pady=5, sticky='ew')
       bt_close = tk.Button(pn_cmd, text='Chiudi Applicazione',
                            command=self.__on_close)
       bt_close.grid(row=0, column=1, padx=2, pady=5, sticky='ew')
       pn_cmd.grid_columnconfigure(0, weight=1, uniform='cmd')
       pn_cmd.grid_columnconfigure(1, weight=1, uniform='cmd')
       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(1, weight=1)

   def __on_close(self) -> None:
       msg = ['ON_CLOSING',]
       self.pub.send_message('EVENT_OBSERVERS', msg)

   def _on_connect(self) -> None:
       msg = ['READ_MESSAGES',]
       self.pub.send_message('EVENT_OBSERVERS', msg)

   def _messages(self, msg: list) -> None:
       op = msg[0]
       match op:
           case 'MSG_READING':
               txt = msg[1] + '\n'
               self.t_msg.insert(tk.END, txt)
           case 'DBERROR':
               txt = msg[1]
               msgb.showerror('Errore dati', txt)
           case 'THREAD_END':
               self.pub.unsubscribe('EVENT_OBSERVERS', self._messages)
               self.pub.unsubscribe('MESSAGE_OBSERVERS', self._messages)
               self.destroy()
           case _:
               pass


In primo luogo si noti, nella inizializzazione della finestra (la classe "App") la direttiva
CODICE
self.protocol('WM_DELETE_WINDOW', self.__on_close)

Tale direttiva altera i processi di chiusura della finestra demandandone la gestione al mettodo privato "__on_close(self)" della classe che NON provvederà a chiudere la finestra, bensì ad emettere un messaggio "ON_CLOSING" verso il gruppo EVENT_OBSERVERS.
Per altro, alla sua inizializzazione, la classe App iscriverà il suo metodo "_messages(self, msg: list)" ai gruppi "EVENT_OBSERVERS" e "MESSAGE_OBSERVERS", oltre ad invocare, quale variabile di istanza, un oggetto SQLReader, passandogli "trescon" quale parametro per il nick-name del lettore del database.

Sarà la prima pressione (le successive non avranno più effetto) del pulsante "Connetti al database" a dare avvio alla ciclica lettura dati dsl database, tramite il metodo di callback "_on_connect" che si limiterà ad inviare un messaggio con "READ_MESSAGES" quale primo (e unico) elemento, al gruppo EVENT_OBSERVERS.

Il metodo _messages(self, msg: list) della classe App interpreterà e reagirà ad alcuni dei messaggi emessi per i gruppi EVENT_OBSERVERS ed MESSAGE_OBSERVERS, nello specifico analizzando il primo elemento della lista riscevuta per definire l'operazione da effettuare, provvedendo per:

MSG_READING a leggere il secondo elemento ed inserirlo nella text-box;
DBERROR a presentare una finestra di errore esponendo l'errore ricevuto;
THREAD_END a cancellare le iscrizioni del metodo _messages e distruggere la finestra.

... credo che l'essenziale sia tutto qui.

Concludendo


L'esempio su posto è una metodologia semplice per far interagire tra loro thread che agiscano in parallelo, i messaggi smistati da "Delivery" permettono di definire "al volo" propri protocolli di comunicazione, permettendo di scambiarsi qualsiasi cosa, dato che sono liste, a condizione di equilibrare attentamente tali protocolli e le azioni che loro vengono associate (vedere la chiusura della finesta e sue ripercussioni), utilizzando tale metodologia è anche possibile definire tkinter un thread come un altro, interagente con parti di programma svolte in altri thread separati.

Nel caso specifico, la lettura periodica del database implica una connessione specifica nel thread di lettura, eventuali eventi di scrittura dovranno essere gestiti con connessioni separate e concorrenti, magari nello stesso thread di tkinter.
È comunque possibile definire più finestre o più oggetti in una stessa finestra iscritti in uno stesso "gruppo" che espongono diversificatamente i dati o compiano azioni diverse con i dati ricevuti, basta subclassare elementi in modo idoneo a ciò che si vuole fare.

Fondamentalmente, ciò che serve, come sempre, è una attenta pianificazione di ciò che si vuole ottenere.

Se si vuol provare l'esempio su si copi il codice proposto in tre file denominati come indicato nella stessa directory, poi, sempre in essa si crei un database denominato come esemplificato subito sotto le immagini e ci si diverta ad inserire messaggi da una shell SQLite3.

Alla prox :)
view post Posted: 26/2/2024, 18:14 [Tkinter] una Text-box con "buffering" - Appunti di apprendimento
Un altro dei miei sempre più rari post, anche questa volta per rispondere ad un collega che chiedeva consiglio circa la sostituzione di una label con un "qualcosa" fornito di sbarra di scorrimento e con buffer di righe limitato.
Andando detto oggetto sostitutivo a rimpiazzare una label, naturalmente, a sua volta non doveva essere editabile.

Sfruttando la classe tkinter.Text stato facile realizzare un sub-classamento idoneo allo scopo, qui di seguito il codice del modulo realizzato ("mytktools.py")

CODICE
# -*- coding: utf-8 -*-

import tkinter as tk

class BufferedText(tk.Text):
   '''Una Text-box inibibile alla scrittura senza disabilitazione
      e che espone un numero stabilito di righe di testo.'''
   key_none = ['Alt_L', 'Alt_R', 'Caps_Lock', 'Control_L', 'Control_R', 'Down', 'End',
               'Escape', 'Execute', 'F1', 'F2', 'Fi', 'F12', 'Home', 'Insert', 'Left',
               'Linefeed', 'KP_Add', 'KP_Begin', 'KP_Divide', 'KP_Down', 'KP_End',
               'KP_Home', 'KP_Insert', 'KP_Left', 'KP_Next', 'KP_Prior','KP_Right',
               'KP_Up', 'Next', 'Num_Lock', 'Pause', 'Print', 'Prior', 'Right',
               'Scroll_Lock', 'Shift_L', 'Shift_R', 'Tab', 'Up']
   def __init__(self, parent: callable, active: bool=False, rowsize: int=0, *args, **kwargs) -> None:
       super().__init__(parent, *args, **kwargs)
       self.parent = parent
       self._active = active
       self._rowsize = rowsize if rowsize >=0 else 0
       self._bg = self['background']
       self.bind('<KeyPress>', self._on_key)
       self.bind('<FocusIn>', self._on_focus)
       self.bind('<FocusOut>', self._out_focus)

   def _on_key(self, evt: callable) -> None:
       if  evt.keysym in ['Return', 'KP_Enter'] and self._rowsize:  # valuta il caso si aggiunga una riga
           if not self._active: return 'break'
           self._evaluate_rows()
       elif not self._active and not evt.keysym in self.key_none:
           return 'break'
   
   def _evaluate_rows(self):
       if not self._rowsize: return
       rows = int(self.index('end').split('.')[0]) - 2
       if self._active and self.parent.focus_get() == self:
           rows += 1
       if rows > self._rowsize:  # raggiunto il limite di righe stabilito
           for i in range(rows - self._rowsize):
               self.delete('1.0', '1.end + 1 char')
               self.update()
           
   def _on_focus(self, evt: callable) -> None:
       color = '#ffffc0' if self._active else '#bfe5f1'
       self.configure(bg=color)
   
   def _out_focus(self, evt: callable) -> None:
       self.configure(bg=self._bg)
   
   def is_active(self) -> bool:
       return self._active
   
   def activate(self) -> None:
       self._active = True
   
   def disable(self) -> None:
       self._active = False

   @property
   def buffer(self) -> int:
       return self._rowsize

   @buffer.setter
   def buffer(self, buff: int) -> None:
       self._rowsize = buff if buff >=0 else self._rowsize
       self._evaluate_rows()

   def add_text(self, text: str) -> None:
       self.insert('end', text)
       self._evaluate_rows()
       self.see('end')


Il sub-classamento realizzato (BufferedText) definisce una API con cui l'utilizzatore può il buffer di righe voluto, un metodo interno che provvederà a conteggiare le righe presenti nell'area di testo ed a eliminare le prime righe eccedenti.
Naturalmente, vengono conserveti i metodi e le proprietà proprie della classe madre, inoltre sono state aggiunte due ulteriori proprietà, non pertinenti il problema "righe" : "_active" e "_bg"

Caratteristiche proprie di BufferedText


Si tralasciano i metodi e le proprietà caratteristiche della classe madre tkinter.Text, per le quali si rimanda alla documentazione, fatta salva la proprietà "background" che con BufferedText non funzionerà, venendo sovrascrittà alla acquisizione o perdita del focus da parte di un oggetto di essa istanza.
Un oggetto di istanza "BufferedText" che non possiede il focus presenterà comunque il colore di base previsto per oggetti tkinter.Text nel sistema operativo in uso, quando avrà il focus, invece, assumerà un colore dipendente dallo stato delle proprietà "._active", che è un valore booleano, lo sfondo sarà di un giallino chiaro se "_active" è "Vero", un leggero azzurro-violaceo se "Falso". Dette colorazioni non possono essere modificate "ad voluntatem" essendo intese, nei miei utilizzi, quali "fisse" per i controlli cui applico la tecnica e le considerazioni che descriverò tra poco ... qualora si volessero cambiare le colorazioni, si intervenga sulla riga
CODICE
color = '#ffffc0' if self._active else '#bfe5f1'

contenuta nel metodo "_on_focus" della classe.
Qualora non si volesse tale comportamento si elimino i binding del focus ed i metodi "_on_focus" e "out_focus", oltre, naturalmente, la definizione "self._bg" che diverrebbe inutile.

Il perché di _active


Una delle caratteristiche di tkinter che trovo "molto sgradevole" è la resa grafica di un controllo con stato "disabled", in particolare nei controlli che espongono testo, in tutti i miei desktop linux detta resa è decisamente brutta (non è che in windows sia meglio) ed oltre tutto pochissimo legibile.
Pertanto, quando è possibile (solitamente per Entry e Text) e voglio che il contenuto non venga modificato definisco una proprietà, appunto "_active", che stabilisce la possibilità di modificare i contenuti del widget e procedo ad intercettare la tastiera nella fase iniziale della sua gestione in tkinter ("KeyPress") ed analizzare i singoli tasti premuti, qualora lo stato di "_active" sia "Falso" viene interrotto il processo (con un "return 'break'") per tutti quei tasti che causerebbero una modifica dei contenuti mentre si lasciano filtrare i tasti di navigazione o ininfluenti ... il codice relativo:

CODICE
def _on_key(self, evt: callable) -> None:
       if  evt.keysym in ['Return', 'KP_Enter'] and self._rowsize:  # valuta il caso si aggiunga una riga
           if not self._active: return 'break'
           self._evaluate_rows()
       elif not self._active and not evt.keysym in self.key_none:
           return 'break'

Tenete presente questo stralcio di codice, ci si ritornerà in seguito.

La Api della classe fornisce sue metodi per la definizione dello stato di "_active" : "activate()" che abilita il controllo alle modifiche del contenuto e "disable()" che le inibisce.

Definizione del buffer di righe


Dopo un po' di prove e considerazioni ho deciso di fornire la classe "BufferedText" di due distinte modalità per stabilire quante righe essa debba conservare per la visualizzazione.

La prima modalità è implementata nella inizializzazione dell'istanza alla classe
CODICE
def __init__(self, parent: callable, active: bool=False, rowsize: int=0, *args, **kwargs) -> None:

ed è definibile tramite il parametro "rowsize", se omesso tale parametro assumerà valore "0", con tale valore nessun limite di riga verrà imposto e BufferedText funzionerà come un normale tkinter.Text (a parte i colori), diversamente il valore fornito, se positivo, verrà assunto quale limite delle righe di testo da rappresentare.

la seconda modalità è tramite i metodi/proprietà "buffer" il cui getter (decoratore "property") permetterà di consultare l'oggetto istanziato per conoscere quante righe sono correntemente rappresentabili ed il cui setter ("buffer.setter") permetterà di ridefinirne il numero.
Qualora il numero di righe ridefinito fosse inferiore alle righe già presenti le eccedenze verrebbero immediatamente eliminate, senza richiesta di conferma.

Come operare


BufferedText è pensata essenzialmente per una esposizione passiva di testo proveniente dall'esterno, a tale scopo è stata dotata di un apposito metodo
CODICE
def add_text(self, text: str) -> None:

che provvederà ad inserire il testo così come passato continuando dall'ultima posizione presente, quindi invocherà il metodo "privato" di valutazione delle righe e posizionerà la visione del controllo sull'ultima riga

png


raggiunto il limite imposto, l'inserimento di una nuova riga di testo

png


causerà l'immediata eliminazione della prima riga di testo presente

png


come è evidente nelle soprastanti immagini della finestra di test (che sarà fornita alla fine del post).

L'importanza del new-line ("\n")


Si faccia ben attenzione che il tkinter.Text definisce una riga di testo quale testo terminato da un carattere di new-line ('\n' in python), ciò è significativo perché tkinter.Text è dotato anche della proprietà "wrap" che può avere diversi valori, se tale proprietà è "none" il testo verrà mostrato su di un'unica linea anche se eccede l'area orizzontale di visualizzazione

png


in tal caso un eventuale conteggio del numero di righe visualizzate dovrebbe corrispondere al numero imposto nel buffer ... più una se anche all'ultima riga trasmessa è stato apposto un new-line.
posto "wrap" ad un valore diverso, tipo "word" p.e., una singola riga di testo può occupare diverse linee di visualizzazione

png


il cui eventuale conteggio potrebbe eccedere anche di molto il numero di righe da visualizzare ... potete rendervi edotti visivamente della differenza osservando gli spazi di spostamento delle scroolbar nelle figure sopra, ove all'azzeramento dello spazio della scrollbar orizzontale nella soconda figura corrisponde una abbondande triplicazione dello spazio di spostamento nella verticale ...

Il new-line ha un altro effetto visibile quando si modifichi direttamente il testo, esso viene inserito quando viene premuto il tasto "Return" (o Invio) della tastiera principale, con riposizionamento del punto di inserimento, o del tasierino numerico, senza riposizionamento del punto di inserimento.
Dato che l'intercettazione dei tasti avviene sull'evento "KeyPress" che è antecedente al completamento del processo di rilascio, e che ho ritenuto di effettuare in quel frangente la valutazione delle righe (non considerando una riga vuota influente) vi troverete a scrivere su di una riga eccedente il limite posto, sin quando non darete "Invio"

png



Penso ci sia tutto ciò che serve sapere, il sottostante codice della finestra di test vi mostrerà le modalità di uso previste, qualora occorra, se volete provare il test copiate il codice in un file python e salvatelo nella stessa directory del modulo "mytktools.py" fornito prima (ovviamente con un nume diverso) e lanciatelo con una versione di python >= 3.10

CODICE
# -*- coding: utf-8 -*-

import os
import time
from threading import Thread
import tkinter as tk
import tkinter.filedialog as fd
import tkinter.messagebox as mb

from mytktools import BufferedText

def read_file(fname: str, f: callable, delay: float=0.1) -> None:
   with open(fname) as fn:
       txt = fn.read().splitlines()
   if not txt: return
   for t in txt:
       f(t)
       time.sleep(delay)

class MyApp(tk.Tk):
   def __init__(self) -> None:
       super().__init__()
       self.title('Test di BufferedText')
       self._wrap = False
       self._populate()
       self._lastdir = os.environ['HOME']

   def _populate(self) -> None:
       self.dida = tk.Label(self, text='')
       self.dida.grid(row=0, column=0, columnspan=2, padx=5, pady=5, sticky='w')
       self.tbox = BufferedText(self, wrap='none', font='courier 10')
       self.tbox.grid(row=1, column=0, padx=(5,1), pady=(5,1), sticky='nsew')
       vscroll = tk.Scrollbar(self, orient=tk.VERTICAL,
                              command=self.tbox.yview)
       vscroll.grid(row=1, column=1, padx=(1,5), pady=(5,1), sticky='ns')
       self.tbox.configure(yscrollcommand=vscroll.set)
       hscroll = tk.Scrollbar(self, orient=tk.HORIZONTAL,
                              command=self.tbox.xview)
       hscroll.grid(row=2, column=0, padx=(5,1), pady=(1,5), sticky='ew')
       self.tbox.configure(xscrollcommand=hscroll.set)
       pn_e = tk.Frame(self)
       pn_e.grid(row=3, column=0, columnspan=2, sticky='ew')
       dida = tk.Label(pn_e, text= 'Riga testo : ')
       dida.grid(row=0, column=0, padx=5, pady=5, sticky='w')
       self.e_row = tk.Entry(pn_e)
       self.e_row.grid(row=0, column=1, padx=5, pady=5, sticky='ew')
       bt_line = tk.Button(pn_e, text='Aggiungi riga', command=self._on_row)
       bt_line.grid(row=0, column=2, padx=5, pady=5, sticky='ew')
       pn_e.grid_columnconfigure(1, weight=1)
       pn_c = tk.Frame(self)
       pn_c.grid(row=4, column=0, columnspan=2, sticky='ew')
       dida = tk.Label(pn_c, text= 'Buff : ')
       dida.grid(row=0, column=0, padx=5, pady=5, sticky='w')
       self.e_buff = tk.Entry(pn_c, width=5, justify='right')
       self.e_buff.grid(row=0, column=1, padx=5, pady=5, sticky='ew')
       bt_buff = tk.Button(pn_c, text='Imposta buffer righe',
                           command=self._on_buff)
       bt_buff.grid(row=0, column=2, padx=5, pady=5, sticky='ew')
       self.bt_wrap = tk.Button(pn_c, text='Attiva wrap',
                           command=self._on_wrap)
       self.bt_wrap.grid(row=0, column=3, padx=5, pady=5, sticky='ew')
       self.bt_active = tk.Button(pn_c, text='Attiva area testo',
                                  command=self._on_active)
       self.bt_active.grid(row=0, column=4, padx=5, pady=5, sticky='ew')        
       bt_file = tk.Button(pn_c, text='Carica da file',
                           command=self._on_file)
       bt_file.grid(row=0, column=5, padx=5, pady=5, sticky='ew')
       bt_end = tk.Button(pn_c, text='Chiudi programma',
                           command=self.destroy)
       bt_end.grid(row=0, column=6, padx=5, pady=5, sticky='ew')
       for i in range(2,7):
           pn_c.grid_columnconfigure(i, weight=1, uniform='bt')

       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(1, weight=1)

   def _on_row(self) -> None:
       txt = self.e_row.get()
       if txt:
           self.tbox.add_text(txt + '\n')
           self.e_row.delete(0, 'end')
       self._update_dida()

   def _on_buff(self) -> None:
       value = self.e_buff.get()
       if value.isnumeric():
           self.tbox.buffer = int(value)
       self._update_dida()

   def _on_active(self):
       active = self.tbox.is_active()
       if active:
           self.bt_active.configure(text='Attiva area testo')
           self.tbox.disable()
       else:
           self.bt_active.configure(text='Disattiva area testo')
           self.tbox.activate()
       self._update_dida()

   def _on_wrap(self) -> None:
       self._wrap = not self._wrap
       if self._wrap:
           self.tbox.configure(wrap='word')
           self.bt_wrap.configure(text='Disattiva wrap')
       else:
           self.tbox.configure(wrap='none')
           self.bt_wrap.configure(text='Attiva wrap')
       self._update_dida()
           
   def _on_file(self) -> None:
       types = [('File di testo', ('*.txt', '*.TXT')),
                ('Tutti i files', ('*.*',))]
       fname = fd.askopenfilename(parent=self,
                                  initialdir=self._lastdir,
                                  title='Seleziona file di testo',
                                  defaultextension='.txt',
                                  filetypes=types)
       if not fname: return
       if os.path.isfile(fname):
           self._lastdir = os.path.dirname(fname)
       try:
           t = Thread(target=read_file, args=(fname, self.add_text))
           t.daemon = True
           t.start()
       except OSError as e:
           mb.showerror("Errore file", f'{e}')

   def _update_dida(self) -> None:
       txt = 'Area di testo '
       if self.tbox.is_active():
           txt += 'Attiva - '
       else:
           txt += 'Disattiva - '
       if self.tbox.buffer:
           txt += f'Buffer di {self.tbox.buffer} righe - '
       else:
           txt += 'Nessun buffer di righe - '
       if self._wrap:
           txt += 'Ritorno a capo attivo'
       else:
           txt += 'Ritorno a capo NON attivo'
       self.dida.configure(text=txt)
       
   def add_text(self, txt: str) -> None:
       self.tbox.add_text(txt + '\n')


if __name__ == '__main__':
   app = MyApp()
   app.mainloop()
view post Posted: 2/4/2023, 17:27 [Tkinter] un combo box "interattivo" - Appunti di apprendimento
I miei saluti ad eventuali, improbabili, lettori

Nel corso di una costruzione di prototipi di interfacce utente grafiche per desktop mi son trovato con la necessità di riportare, per selezione da parte degli user, una gran numero di di dati in uno spazio "ristretto", naturalmente, la scelta di un controllo di tipo Combo-Box viene da sola in tale frangente.
Realizzato un primo prototipo della applicazione con python ed il framework tkinter mi son reso conto che le funzionalità di navigazione del widget ttk.Combobox fornito con la versione 8.6 di detto framework mal si presta alla navigazione di grosse quantità di dati, disponendo di sole opzioni "visuali" di scorrimento tra i dati, non disponendo delle funzioni di completamento automatico comunemente presenti in framework più evoluti.

Essendo non difficile realizzare un "qualcosa" che interagisca con l'input utente lo valuti e provveda, in qualche modo, a spostarsi sui primi dati che soddisfano l'inserimento fatto, è stato semplice sub-classare la classe ttk.Combobox e realizzare quello che ho chiamato:

InteractiveComboBox


che ho deciso di esporre qui nel caso fosse utile a qualcuno.
È richiesta una versione python >= 3.10.x per il funzionamento del codice sottostante ma potete facilmente adattarlo a versioni precedenti eliminando di type-hints e sostituendo i costrutti "match-case" con una serie di costrutti "if-elif"

Intanto il codice della classe (98 righe) :

CODICE
#-*- coding: utf-8 -*-

import tkinter as tk
from tkinter import ttk


class InteractiveCombobox(ttk.Combobox):
   '''Una combobox per intercettare e completare l'input utente'''
   key_accepted = 'qwertyuiopasdfghjklzxcvbnmàèéìòù1234567890'
   def __init__(self, parent, *args, **kwargs) -> None:
       super().__init__(parent, *args, **kwargs)
       self.parent = parent
       self._user_val = []
       self.bind('<KeyPress>', self._on_keypress)
       self.bind('<KeyRelease>', self._on_key)
       self.bind('<Button-1>', self._on_mouse)
   
   def _on_keypress(self, evt: callable) -> None:
       if evt.keysym == 'Down':
           self._user_val = []
           self.icursor(0)
           self.select_range(tk.INSERT, tk.INSERT)
       
   def _on_key(self, evt: callable) -> None:
       if not self['values']: return
       match evt.keysym:
           case 'BackSpace':
               if len(self._user_val):
                   self._user_val = self._user_val[:-1]
                   self.icursor(0)
               self._intercept()
               return 'break'
           case 'Left':
               if len(self._user_val):
                   self._user_val = self._user_val[:-1]
               self._intercept()
               return 'break'
           case 'Right':
               value = self.get()
               if len(value) > len(self._user_val):
                   self._user_val.append(value[len(self._user_val)])
               self._intercept()
               return 'break'
           case 'Escape':
               self._user_val = []
               self.icursor(0)
               self.select_range(0, tk.END)
           case 'KP_Enter':
               if len(self._user_val):
                   self._complete()
           case 'Return':
               if len(self._user_val):
                   self._complete()
           case _:
               if evt.keysym.lower() in self.key_accepted:
                   self._user_val.append(evt.keysym.lower())
                   self._intercept()
                   return 'break'
   
   def _on_mouse(self, evt: callable) -> None:
       self._user_val = []

   def _complete(self) -> None:
       tok = ''.join(self._user_val)
       data = self['values']
       index = 0
       if tok:
           for i in range(len(data)):
               if data[i].lower().startswith(tok):
                   index = i
                   self._user_val = []
                   self.icursor(0)
                   self.select_range(0, tk.END)
                   break
           self.current(index)
           self.event_generate('<<ComboboxSelected>>')
   
   def _intercept(self) -> None:
       self.delete(len(self._user_val), tk.END)
       tok = ''.join(self._user_val)
       if not tok:
           self.select_range(tk.INSERT, tk.INSERT)
           return
       data = self['values']
       index = 0
       is_match = False
       for i in range(len(data)):
           if data[i].lower().startswith(tok):
               index = i
               is_match = True
               break
       if is_match:
           self.current(index)
           self.select_range(len(self._user_val), tk.END)
       else:
           self.delete(len(self._user_val)-1, tk.END)
           self._user_val = self._user_val[:-1]

Se si ritiene di voler fare una prova con la finestra di test che proporrò a fine post, copiate il codice, stando attenti alla indentazione dello stesso (l'editor dei post potrebbe creare degli artefatti) e salvatelo in un file denominato "mytktools.py" nella stessa directory del file-applicazione.

Il funzionamento della classe si basa su di una doppia intercettazione di ogni singolo tasto premuto, il motivo di tale doppia intercettazione è dato dalla natura del widget ttk.Combobox che essenzialmente è un sub-classamento del widget "Entry" con la aggiunta di una "area - dati", detta "area" intercetta e gestisce in esclusiva alcuni tasti di navigazione (essenzialmente quelli di movimento verticale ("down". "Up", etc.) la cui pressione non viene rilevata dalla parte "Entry" del widget.

png


L'intercettazione dei tasti di navigazione "verticale" è effettuata all'avvio della pressione di un tasto della tastiera (evento "<keypress>") e leggendo il valore di simulazione del tasto (proprietà "keysym" dell'evento) e viene gestito il solo valore "Down" (preposto all'apertura della area-dati) provvedendo allo azzeramento di un eventuale user-input e rilascio alla gestione propria originaria degli eventi.

Un pochino più articolata è l'intercettazione effettuata al rilascio del pulsante (evento "<keyrelease>") che verifica il caso siano stati premuti alcuni tasti "particolari" e nel caso non sia così, allora, controlla se il keysym del tasto sia contenuto in una variabile di classe di controllo
CODICE
key_accepted = 'qwertyuiopasdfghjklzxcvbnmàèéìòù1234567890'

Che altro non è che una stringa contenente i caratteri che prevedo possano servire, la cui modifica ed adattamento ad altre situazioni è, comunque, facile.
L'intercettazione di un carattere "accettato" memorizzerà lo stesso carattere in una variabile di istanza ("self._user_val"), di tipo lista, ed invocherà il metodo di classe "_intercept(self)" che, magari da una condizione iniziale vuota

png


provvederà a posizionarsi sul primo dato che inizia con i valori inseriti dall'utente

png


e continuerà ad aggiornarsi dinamicamente man mano che l'utente prosegue nel suo inserimento

png


La pressione del tasto return, ordinario o del tastierino numerico (rispettivi keysym "Return" e "KP_Enter") i quali invocheranno il metodo di classe "_complete(self)" che cercherà il primo dato corrispondente all'input utente, si posizionerà su di esso, azzereranno l'user-input immesso e genererà un evento si selezione ("<<comboboxselected>>") che potrà essere elaborato

png


Naturalmente, i metodi di classe "_intercept" e "_complete" agiscono solo in presenza di input utente, non agiscono altrimenti, ed in ogni caso se alla aggiunta di nuovo input non corrisponde alcun dato il nuovo input viene eliminato, il percorso degli eventi viene, quindi, lasciato proseguire normalmente.

Oltre ai tasti Return viene gestito anche il tasto Esc (keysym "Escape") che semplice annulla ogni inserimento utente e riposiziona il cursore, selezionando un eventuale dato corrente ma senza scatenare un evento di selezione.
I tasti backspace e freccia sinistra (keysym "BackSpace" e "Left") decrementano l'user input ed il punto di inserimento di un valore mentre il tasto freccia destra (keysym "Right") incrementa l'input utente aggiungendo un ulteriore carattere da eventuali dati presenti.
Tutti e tre questi ultimi tasti interrompono il ciclo degli eventi di tkinter facendo cessare ulteriori azioni (restituiscono un "break" al ciclo degli eventi)

Per altro, alnche l0azione del tasto sinistro del mouse viene intercettata, azzerando ogni eventuale input dell'utente, nell'ottica che si voglia procedere alla selezione visivamente.

Tutto qua, se si vuol testare con pappa già pronta, il codice sotto produce la finestra in figura sopra ed estrae le famiglie di caratteri registrate nel sistema che vengono presentate nel combo-box "Fonts", oggetto di classe "InteractiveComboBox", dovrebbe funzionare anche su windows (i keysym sono una astrazione indipendente dal sistema) e sul mio linux e macchina vecchiotta funziona in maniera soddisfacente.

Se qualcuno mai lo trovasse utile mi farà piacere, anche se non lo saprò ;)

Il codice di test:

CODICE
#-*- coding: utf-8 -*-

import tkinter as tk
from tkinter import font
from tkinter import ttk
from mytktools import InteractiveCombobox as ICB


class MyApp(tk.Tk):
   def __init__(self) -> None:
       super().__init__()
       self.title("Esempio d'uso")
       lbl = tk.Label(self, text='Font :')
       lbl.grid(row=0, column=0, padx=5, pady=5, sticky='w')
       self.cmb_fonts = ICB(self)
       self.cmb_fonts.grid(row=0, column=1, padx=5, pady=5, sticky='ew')
       lbl = tk.Label(self, text='Dim. :')
       lbl.grid(row=0, column=2, padx=5, pady=5, sticky='w')
       self.cmb_sizes = ttk.Combobox(self, width=5)
       self.cmb_sizes.grid(row=0, column=3, padx=5, pady=5, sticky='w')
       self.dida = tk.Label(self, text='')
       self.dida.grid(row=1, column=0, columnspan=4,
                      padx=5, pady=5, sticky='w')
       bt_end = tk.Button(self, text='Chiudi programma', command=self.destroy)
       bt_end.grid(row=2, column=0, columnspan=4,
                      padx=5, pady=5, sticky='ew')
       self.grid_columnconfigure(1, weight=1)
       self._set_fonts()
       self._set_fnt_sizes()
       self.cmb_fonts.bind('<<ComboboxSelected>>', self._on_fonts)
       self.cmb_sizes.bind('<<ComboboxSelected>>', self._on_fonts)
       self.update()
       self.minsize(self.winfo_reqwidth(), self.winfo_reqheight())

   def _set_fonts(self) -> None:
       ''' Carica le famiglie di font raggiungibili in tkinter '''
       self.cmb_fonts.delete(0, tk.END)
       fnt = sorted(set(list(font.families())))
       self.cmb_fonts['values'] = fnt
       self.dida.configure(text=f'Trovate {len(fnt)} famiglie di fonts')

   def _set_fnt_sizes(self) -> None:
       ''' Imposta le dimensioni dei caratteri '''
       sizes = [x for x in range(6, 29, 2)]
       sizes += [x for x in range(32, 48, 4)]
       self.cmb_sizes.delete(0, tk.END)
       self.cmb_sizes['values'] = sizes
       self.cmb_sizes.current(3)

   def _on_fonts(self, evt: callable) -> None:
       fnt = self.cmb_fonts.get()
       dim = self.cmb_sizes.get()
       msg = f'Selezionato font: {fnt} {dim}pt.'
       self.dida.configure(text=msg)

if __name__ == '__main__':
   app = MyApp()
   app.mainloop()
view post Posted: 9/3/2023, 11:43 Piccole Perle - Opinioni e confronti
Carissima

CITAZIONE (perla lunare @ 2/3/2023, 12:23) 
...
Ti ho cercato e ti ho trovato, dopo anni di nessun contatto. Succede, anche senza motivo.
...

Il Tuo trovarmi mi commuove e mi fa sentire in colpa, avendo io, di fatto, abbandonato Te, così come altre persone interessanti e degne di attenzione conosciute in questo circuito, senza alcun particolare motivo.

Hai ragione, succede, succede senza alcun perché, forse in un momento della vita ci si inaridisce e non si hanno più parole da dire, o forse altri interessi sorgono ed occupano il poco tempo di cui si dispone o, in fine, forse è la vita stessa, esigente tiranna, a schiacciarti con le sue esigenze, portandoti a dimenticare.
Come ben dici succede che la stessa stanchezza di vivere porti a chiudere gli occhi e dimenticarsi di tutto, alla ricerca di un riposo che non possiamo trovare ma che inevitabilmente giungerà, quando dormiremo davvero.
Oramai è raro, per me, anche venire qui.

Il leggerTi è accompagnato da tanti ricordi di bei pensieri, da interessanti concetti validamente espressi ... verrò volentieri a vedere la Tua nuova fatica, a ricambiare il Tuo saluto ... e, chi sa, magari trovare nuova ispirazione che irrighi e renda fertile la terra della mia aridità.

A Te, leggiadra musa :perte:
view post Posted: 15/5/2022, 08:50 [Python] Giochicchiando con matplotlib e tkinter - Appunti di apprendimento
Salve agli improbabili lettori, ogni tanto mi prende l'impulso di venire qui e lasciare un appunto ... a dire il vero, nel tempo si è accumulata un sacco di roba che vorrei scrivere qui ma è talmente tanta e così abbondante che non saprei neanche da dove iniziare ... peccato.


Comunque, in questo caso, l'impulso mi è venuto da questo post nel Forum di python.it ove l'utente @Domedron chiedeva:
"Quesito: Se creo uno spazio di lavoro con tknter, grazie all'uso dei frame so come collocare gli oggetti: pulsanti, Entry, .... Ma se in un frame volessi mettere un grafico di matplotlib in uno di quei frame come dovrei fare?"

Tanto per cambiare, non conoscevo la liberia "matplotlib" ma una veloce guardata sulla docs e su stackoverflow mi ha fatto capire "come" poter utilizzare alcuni oggetti forniti da matplotlib nell'ambito di una applicazione grafica tkinter, cosa di cui ho comunicato l'essenziale nel post sopra citato.

Per altro l'argomento intriga anche me, dato che ho visto degli esempi di utilizzo delle trasformate di Fourier e matplotlib per definire degli spettri per campionare segnali audio, cosa che si sposerebbe meravigliosamente (ammesso mi riesca di capire Fourier) con una applicazione di archiviazione e classificazione di file multimediali audio/video che ho realizzato per gioco.

In attesa di decifrare l'astrusa matematica Foureriana (o almeno capire come utilizzarla) ho sperimentato come far definire e salvare ad un generico utente un minimale grafico a barre, permettendogli di scegliere il colore delle varie barre oltre che eventuali titolo ed etichetta verticale.
Devo dire che è stato sorprendentemente facile ottenere il risultato sotto in figura

png


matplotlib è uno strumento straordinario, molto potente, maturo ed amichevole, rimpiango di non averlo conosciuto quando ancora lavoravo, purtroppo doversi "sbrigare" non permette molto di cazzeggiare (e quindi imparare).
Comunque è pure una libreria molto estesa, che rende necessario un approfondito studio, oltre che una certa familiarità con metodi matematico-statistici, per poterla padroneggiare.
Come accennato, matplotlib fornisce dei particolari "strumenti" per permettere l'utilizzo della libreria in alcuni framework grafici, tra cui tlinter, tali strumenti sono contenuti nel modulo "matplotlib.backends", in particolare "backend_tkagg" fornisce la classe "FigureCanvasTkAgg" che istanziata restituisce un oggetto gestibile nell'ambito di una GUI tkinter.

Di per se FigureCanvasTkAgg non fornisce direttamente molti metodi, il più interessante è "drav()" che ridisegna il widget ... però eredita una possente API dalla sua classe-madre "FigureCanvasAgg", tutta da studiarsela ma che per gli scopi posti è stato sufficiente solo sfiorare superficialmente.

Utilizzo di matplotlib nella applicazione


Premesso che il metodo adottato è certo un po' rozzo, dato che si tratta del mio primissimo esperimento con matplotlib, osservando il costruttore riportato nella documantazione per la classe FigureCanvasTkAgg
CODICE
class matplotlib.backends.backend_tkagg.FigureCanvasTkAgg(figure=None, master=None, resize_callback=<deprecated parameter>)

si nota la presenza di due parametri, "figure" e "master", oltre ad un deprecato riferimento ad una funzione di callback.
Il parametro "master", banalmente, è la finestra che istanzia ed espone il grafico da realizzarsi, discorso diverso è invece il parametro "figure", la lettura della docs e di alcuni esempi forniti dalla stessa documentazione mi hanno fatto ritenere sia un oggetto di classe "matplotlib.figure.Figure", oggetto terribilmente complesso già a partire dal suo costruttore riportato nella documentazione che, tra le altre cose, permette di definire le dimensioni e la risoluzione del grafico e di definire degli "assi", in realtà "axes", un oggetto che, se già Figure è complesso, questo è da mettersi a piangere!
Pasticciato un pochino con Figure ed Axes, ottenendo risultati sconfortanti (gli assi NON si vedevano) mi è casulamente caduto l'occhio sul metodo "add_subplot()" di Figure, che tra l'altro, restituisce un oggetto "axes.SubplotBase", una prova di utilizzo con i valori di default è stato il punto di svolta che mi ha rivelato quanto in realtà sia amichevole matplotlib!
Nel corso delle ricerche varie effettuate mi ero reso conto di quanto lavoro si facesse con gli oggetti Axes, pertanto ho definito una funzione da utilizzare solo in fase di inizializzazione che costruisse e restituisse un oggetto "Figure" con valori di default, eseguendo sullo stesso un "add_subplot()" minimale memorizzando quale variabile di istanza l'oggetto axes.SubplotBase restituito
CODICE
def _make_fig(self):
       fig = Figure()
       self.ax = fig.add_subplot(111)
       labels = [x[0] for x in self.bars]
       values = [x[1] for x in self.bars]
       colors = [x[2] for x in self.bars]
       self.ax.bar(labels, values, color=colors)
       return fig

L'oggetto Figure restituito da self._make_fig, viene dato in pasto alla istanza di FigureCanvasTkAgg in fase della GUI che lo espone
CODICE
def _populate(self):
       fig = self._make_fig()
       self.canvas = FigureCanvasTkAgg(fig, self)
       self.canvas.draw()
       self.canvas.get_tk_widget().grid(row=0, column=0,
                                        padx=4, pady=4, sticky='nsew')
       ...

ovviamente, _populate() è il processo di definizione dei vari widget costituenti l'interfaccia grafica utente.

Da notare che l'oggetto di classe Figure instanziato, malgrado le sue numerose proprietà e metodi, non è stato neanche memorizzato come variabile di istanza nella gui, in effetti per i semplici scopi posti per questa prova sperimentale, ossia generare un grafico a barre a discrezione dell'user, è sufficiente agire tramite gli oggetti:
  • self.ax, oggetto di classe axes.SubplotBase restituito dall'oggetto "Figure" nel metodo "_make_fig(self)" della GUI (sarebbe a dire MyApp), che provvede alla definizione di titolo ed etichetta verticale, oltre che alla definizione delle barre-dati;
  • self.canvas, oggetto di classe FigureCanvasTkAgg instanziato nel metodo di inizializzazione "_populate(self)" della GUI, che provvede al ridisegno del grafico ed al suo salvataggio.
Anche la parte di codice specifica per la realizzazione (dinamica) del grafico è estremamente ridotta, una quindicina di righe, la maggior parte di recupero dati, sull'insieme di 325 costituenti l'applicazione.
Il grafico viene realizzato dal metodo "_plot(self)" della GUI
CODICE
def _plot(self):
       labels = [x[0] for x in self.bars]
       values = [x[1] for x in self.bars]
       colors = [x[2] for x in self.bars]
       self.ax.clear()
       if self.bar_title:
           self.ax.set_title(self.bar_title)
       if self.bar_y_label:
           self.ax.set_ylabel(self.bar_y_label)
       self.ax.bar(labels, values, color=colors)
       self.canvas.draw()

ove l'oggetto self.ax provvede ad azzerare il grafico esistente, tramite il suo metodo clear(), e quindi a definire, se stabiliti dall'user, il titolo tramite il suo metodo .set_title(stringa), etichetta verticale con il suo metodo set_ylabel(stringa), e le varie barre dati con il suo metodo bar(iterable, iterable, color=iterable); quindi l'oggetto self.canvas ri-disegna il tutto con il suo metodo draw().
Dal codice soprastante è evidente che la definizione delle barre dati viene memorizzata nella variabile di istanza self.bars della GUI che è una lista di tuple i cui vari elementi costituenti le singole tuple vengono estratti separatamente per costruire delle liste da dare in pasto a self.ax.bar(...), mentre titolo ed etichetta verticale hanno specifiche variabili di istanza nella GUI.

Anche il salvataggio del grafico in immagine, sbrigativamente impostata esclusivamente di tipo PNG, è estremamente ridotto
CODICE
def _on_save(self):
       f_name = fdg.asksaveasfilename(title='Salva grafico',
                                      initialfile='grafico',
                                      defaultextension='.png',
                                      filetypes=[('immagine png', '*.png')])
       if not f_name: return
       self.canvas.print_png(f_name)

in sostanza a parte presentare la sottostante finestra per ottenere il nome-file di destinazione

png


viene utilizzato da self.canvas il metodo print_png(nome_del_file) ereditato dalla classe-madre della sua istanza.
Come impostato (cioè SENZA alcuna gestione degli errori) il "salvataggio" potrebbe dare alcuni problemi, essendo oltre tutto richiamabile a discrezione dell'utente più volte in una sessione, ma una definizione puntigliosa della GUI esula dal suo essere "prova".


I colori delle "barre"


Per non perdersi nei meandri della definizione dei colori da parte dell'user, ho deciso di ricorrere ai color-names supportati da tkinter ... e qui ho avuto una piccola sorpresa perché matplotlib NON supporta l'intero insieme dei color-names supportati da tkinter.
Faccenda, questa, che mi ha costretto a ricorrere alla, veramente ottima, documentazione di matplotlib trovando soluzione nel modulo matplotlib.colors che definisce ben tre liste di colori supportati : BASE_COLORS, TABLEAU_COLORS e CSS4_COLORS

Ho approfittato di dette definizioni per stabilire un insieme di colori da esporre all'user definendo un canvas tkinter "ordinario" popolato (con un procedimento un pochino articolato a dire il vero) da un insieme di rettangoli colorati con il colore al momento in lista e scrivendoci dentro il color-name corrispondente con il colore in negativo ed effettuando il binding del tasto sinistro del mouse in modo tale che il colore del rettangolo selezionato vada a costituire il colore di sfondo di una etichetta di esempio ed il color-name valorizza la variabile di istanza self.color.

La definizione dei colori utilizzabili completa l'insieme delle iterazioni GUI-matplotlib, tutto il resto del codice riguarda la funzionalità della GUI e l'interazione con l'user.

... riguardo al metodo di "esposizione", come detto è un po' complesso, sarebbe lungo e fuori argomento esaminarlo, si rimanda al codice dei metodi della GUI _populate_colors(self, pan) e __negative_color(self, color, cnv) oltre che all callback __rec_or_txt_select(self, evt)

Esamineremo sommariamente, invece, il funzionamento della GUI

L'interfaccia utente


Come già anticipato all'inizio, l'applicazione è mirata alla realizzazione, da parte di un generico user, di un semplice grafico a barre realizzato con definizione a piacere dell'utente di vari elementi costituenti il grafico, limitatamente ai soli titolo, etichetta verticale e barre dati.
In sostanza è un semplice "studio di fattibilità" ridotto all'osso dell'utilizzo di matplotlib in una applicazione tkinter. Come tale, è carente di numerose caratteristiche che potrebbero occorrere in una applicazione "da produzione", tipo verifica permessi di scrittura, maggiori elementi costituenti il grafico, etc.
La "costruzione" del grafico avviene con una certa dinamicità, ogni volta che l'utente definisce un elemento che lo costituisce viene involato il metodo "_plot() della GUI ed il grafico ridisegnato.

png


La soprastante immagine mostra una sessione in fase iniziale, in cui l'user ha solo impostato il titolo e l'etichetta verticale premendo il tasto "Imposta" dopo aver compilato i campi relativi, il callback del tasto Imposta ("_on_title(self)") effettuerà l'aggiornamento dati ed il ridisegno, si rimanda al codice per dettagli.
La definizione di titolo ed etichetta verticale può essere effettuata più volte a discrezione dell'user nel corso della sessione, fatto salvo non si sia in condizione di inserimento dei valori per una barra di dati.
La definizione di una barra avviene in due fasi, entrambe finalizzate dal pulsante "Aggiungi barra" il cui callback ("_add_bar(self)") imposta una variabile di istanza della GUI ("self.state") in modalità di aggiunta dati alla prima pressione mentre alla seconda pressione verifica se siano stati valorizzati i campi "Etichetta", "Valore" e "Colore" e la loro congruenza con le tipologie previste, se tutto è in ordine aggiunge un record al dizionario dati, ridisegna il grafico ed aggiorna l'elenco delle barre definite.

png


Da tener presente che mentre i primi due campi dati sono valorizzati tramite digitazione nelle entry preposte, la valorizzazione del campo "Colore" avviene cliccando su di un rettangolo colorato nel canvas dei color-names di cui si è parlato in precedenza, cosa che può essere fatta in qualsiasi momento, nulla vieta che si utilizzi la stessa colorazione per turre le barre.

C'è un bug particolare che afflige il canvas dei colori, che mi ha fatto sorridere e pensare chisenefrega : il binding del canvas dei colori è stabilito sull'intero canvas che intercetta il controllo su cui è stato fatto click, ne estrae il colore associato e fornisce il color-name alla GUI ... ma in un "rettangolo colore" vi sono DUE item, il rettangolo stesso ed il name-color associato scrittoci nel centro colorato in negativo. Qualora venga cliccato il testo interno al rettangolo verrà fornito il codice esadecimale del colore del testo, che per matplotlib andrà bene ugualmente ... ed anche per l'applicazione se l'utente decide di lasciarlo li ;)

Per altro, comparando l'immagine sopra con quella precedente, si può osservare come i valori di riferimento di ascisse ed ordinate siano cambiati : ci ha provveduto matplotlib da se!

Un particolare che potrebbe interessare


Come accennato prima, una volta definita una barra-dati essa viene visualizzata nell'elenco delle barre definite, presente nella parte bassa della finestra, che riporta, per ogni singola barra, l'etichetta, il valore ed il colore assegnati in una visualizzazione di tipo tabellare.
Detta visualizzazione è realizzata utilizzando una classe ("DataPanel") da me "realizzata" specificatamente per la visualizzazione statica, intesa quale non modificabile, di dati tabellari e riposta nel mio "cassetto degli attrezzi" per tkinter ed il cui "costruttore"
CODICE
class DataPanel(tk.Frame):
   def __init__(self, parent, headings, func=None, invert=False, no_evidence=False, *args, **kwargs):
       self...

definisce delle particolari caratteristiche di instanza e funzionamento dipendenti da alcuni parametri definiti :

il parametro "headings", obbligatorio, è dedicato alla costruzione della riga di intestazione delle colonne di dati e richiede una lista di dizionari nella quale ogni dizionario definisce l'intazione di una singola colonna e contiene cle chiavi
  • "name" : è il nome interno della colonna;
  • "label : l'etichetta da scrivere per la colonna;
  • "measure" : una stringa che sarà valutata per definire la dimensione della colonna, se assente (valore None) la colonna avrà dimensione variabile ed adattativa allo spazio disponibile altrimenti sarà della giusta dimensione fissa.
In sostanza, una fase occorrente in fase di instanziamento di un data panel è la creazione di un tale dizionario, nel nostro caso avviene in fase di inizializzazione, verso la fine del metodo "_populate()"
CODICE
heads = [{'name': 'etichetta', 'label': 'Etichetta', 'measure': 'X'*12},
                {'name': 'valore', 'label': 'Valore', 'measure': ' 00000 '},
                {'name': 'colore', 'label': 'Colore', 'measure': None}]
       self.data_p = DataPanel(par_pnl, heads, func=self.selected_bar, no_evidence=True)

In questo test è fissa, definita una singola volta ma in contesti di utilizzo uso pre-definire una serie di righe di intestazione da utilizzare dinamicamente nella stessa finestra distruggendo e ricreando dei DataPanel per diverse rappresentazioni di dati eterogenei.

Il parametro "func" permette di collegare una funzione della classe chiamante (il "parent") cui trasmettere un elemento dati eventualmente selezionato da trattare.

DataPanel prevede colorazione diversificata delle singole righe dati in base al valore (booleano) dell'ultimo elemento della singola riga dati, tale colorazione può essere invertita ponendo a True il parametro "invert", non utilizzato nello instanziamento di questa applicazione, oppure soppressa, lasciando la colorazione di default per tutte le righe di dati, ponendo a Ture il parametr "no_evidence", come fatto nel caso in specie.

L'aggiornamento dati avviene invocando il metodo "_update_data(self, message)" di DataPanel, il parametro "message", così denominato perché il contesto d'uso per cui ho pensato DataPanel è di tipo "Publisher/Subscriber", si aspetta di ricevere una lista di liste o di tuple che, in ogni caso, sarà indicizzata internamente.

Per la rappresentazione dei dati viene utilizzato un ttk.Treeview.

Nel nostro caso, abbiamo assegnato al DataPanel il metodo "selected_bar" di MyApp quale funzione per il trattamento di un elemento (barra dati) selezionato in DataPanel, tale funzione provvederà, se NON si è in fase di definizione di una nuova barra, a rendere corrente l'elemento selezionato, che potrà, nel caso essere cancellato

png


l'immagine soprastante riporta una esemplificazione, ho assegnto alla lattuga lo stesso colore che intendo utilizzare per le carote, non va, quindi seleziono la lattuga nell'elenco, essa diventerà l'elemento corrente, e premo il pulsante "Rimuovi barra" che, obbediente, la eliminirà senza chiedere nulla.

Per ragioni di brevità (essendo solo un test di massima) non ho voluto implementare la logica per la modifica dei dati, in ogni caso ci vuole poco ad inserli nuovamente ed una volta raggiunta una situazione soddisfacente, tipo questa

png


premere il pulsante "Salva" permetterà di produrre una immagine PNG da utilizzare poi per i propri scopi.

È tutto, anche se l'utente @Domedron non si è dimostrato interessato ad approfondire il discorso mi ha intrigato vedere un po' di contesto e toccare con mano quanto sia semplice un utilizzo minimale delle enormi potenzialità di matplotlib in una applicazione tkinter, le librerie richieste sono PIL e matplotlib, oltre, naturalmente, tkinter, ttk e qualcosa della libreria standard, questo post è a mia promemoria nel caso mi serva in futuro.
Se capitasse un qualche improbabile interessato lettore che volesse vedere 'sta cosa, sotto il codice sorgente completo del test

CODICE
# -*- coding: utf-8 -*-

from tkinter import font as fnt
import tkinter as tk
from tkinter import messagebox as msgb
from tkinter import ttk
from tkinter import filedialog as fdg
from PIL import Image
import io

import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
import matplotlib.colors as mcolors


class DataPanel(tk.Frame):
   def __init__(self, parent, headings, func=None, invert=False, no_evidence=False, *args, **kwargs):
       self.parent = parent
       self.parent_function = func
       self.invert = invert
       self.no_evidence = no_evidence
       self.data_list = []
       self.state = ''
       super().__init__(self.parent, *args, **kwargs)
       cols = [x['name'] for x in headings]
       labels = [x['label'] for x in headings]
       measures = [x['measure'] for x in headings]
       self.data_view = ttk.Treeview(self, columns=cols, show='headings', selectmode='browse')
       for i in range(len(cols)):
           self.data_view.heading(cols[i], text=labels[i])
           if measures[i]:
               col_w = fnt.Font().measure(measures[i])
               self.data_view.column(cols[i], width=col_w, stretch=False)
           else:
               self.data_view.column(cols[i], stretch=True)
       if not self.invert:
           self.data_view.tag_configure('active', background='white')
           self.data_view.tag_configure('noactive', background='#fffdcd')
       else:
           self.data_view.tag_configure('active', background='#fffdcd')
           self.data_view.tag_configure('noactive', background='white')
       self.data_view.grid(row=0, column=0, sticky='nsew')
       v_scroll = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.data_view.yview)
       v_scroll.grid(row=0, column=1, sticky='ns')
       h_scroll = ttk.Scrollbar(self, orient=tk.HORIZONTAL, command=self.data_view.xview)
       h_scroll.grid(row=1, column=0, sticky='ew')
       self.data_view.configure(yscrollcommand=v_scroll.set)
       self.data_view.configure(xscrollcommand=h_scroll.set)
       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(0, weight=1)
       self.data_view.bind('<<TreeviewSelect>>', self._on_select)        
   
   def _on_select(self, evt=None):
       if self.parent_function is None: return
       selection = self.data_view.selection()
       if not selection: return
       curr_iid = selection[0]
       curr_item = None
       for el in self.data_list:
           if el[-1] == curr_iid:
               curr_item = el
               break
       if curr_item is None: return
       self.parent_function(curr_item[:-1])

   def _update_data(self, message):
       '''Aggiorna l'elenco delle scadenze trovate.'''
       # Azzeramento dei dati presenti nella treeview ed in self.data_list
       self.data_list.clear()
       childrens = None
       childrens = self.data_view.get_children('')
       if childrens:
           for elem in childrens:
               self.data_view.delete(elem)
       if len(message) == 0: return
       # caricamento nuovi dati
       for row in message:
           if not self.no_evidence:
               if row[-1]:
                   node_id = self.data_view.insert('', 'end', values=row[:-1], tags=('active',))
               else:
                   node_id = self.data_view.insert('', 'end', values=row[:-1], tags=('noactive',))
           else:
               node_id = self.data_view.insert('', 'end', values=row, tags=('active',))
           l_row = list(row)
           l_row.append(node_id)
           self.data_list.append(l_row)

class MyApp(tk.Tk):
   def __init__(self):
       super().__init__()
       self.title('Prova Domedron 2')
       self.color_names = []
       self.bars = []
       self.bar_title = ''
       self.bar_y_label = ''
       self.state = ''
       self.sel_index = None
       self.color = None
       self._populate()

   def _populate(self):
       fig = self._make_fig()
       self.canvas = FigureCanvasTkAgg(fig, self)
       self.canvas.draw()
       self.canvas.get_tk_widget().grid(row=0, column=0,
                                        padx=4, pady=4, sticky='nsew')
       # blocco impostazione titoli grafico
       title_pnl = tk.Frame(self)
       title_pnl.grid(row=1, column=0, padx=4, pady=4, sticky='nsew')
       lbl = tk.Label(title_pnl, text='Titolo :')
       lbl.grid(row=0, column=0, padx=4, pady=4, sticky='w')
       self.e_title = tk.Entry(title_pnl, width=20)
       self.e_title.grid(row=0, column=1, padx=4, pady=4, sticky='ew')
       lbl = tk.Label(title_pnl, text='Etich. vert. :')
       lbl.grid(row=0, column=2, padx=4, pady=4, sticky='w')
       self.e_ylabel = tk.Entry(title_pnl, width=20)
       self.e_ylabel.grid(row=0, column=3, padx=4, pady=4, sticky='ew')
       btn = tk.Button(title_pnl, text='Imposta', command=self._on_title)
       btn.grid(row=0, column=4, padx=4, pady=4, sticky='ew')
       title_pnl.grid_columnconfigure(1, weight=1)
       title_pnl.grid_columnconfigure(3, weight=1)
       # blocco impostazione barre
       par_pnl = tk.Frame(self)
       par_pnl.grid(row=2, column=0, padx=4, pady=4, sticky='nsew')
       lbl = tk.Label(par_pnl, text='Etichetta :')
       lbl.grid(row=0, column=0, padx=4, pady=4, sticky='w')
       self.e_label = tk.Entry(par_pnl, width=10)
       self.e_label.grid(row=0, column=1, padx=4, pady=4, sticky='w')
       lbl = tk.Label(par_pnl, text='Valore :')
       lbl.grid(row=1, column=0, padx=4, pady=4, sticky='w')
       self.e_value = tk.Entry(par_pnl, width=6)
       self.e_value.grid(row=1, column=1, padx=4, pady=4, sticky='w')
       lbl = tk.Label(par_pnl, text='Colore :')
       lbl.grid(row=2, column=0, padx=4, pady=4, sticky='w')
       self.l_color = tk.Label(par_pnl, text='Colore Barra')
       self.l_color.grid(row=2, column=1, padx=4, pady=4, sticky='nsew')
       # blocco pannello dei colori per le barre
       c_colors = tk.Canvas(par_pnl, width=200, height=100)
       c_colors.grid(row=0, column=2, rowspan=3, stick='nsew')
       v_scroll = tk.Scrollbar(par_pnl)
       v_scroll.grid(row=0, column=3, rowspan=3, sticky='ns')
       v_scroll['command'] = c_colors.yview
       c_colors['yscrollcommand'] = v_scroll.set
       c_colors.bind('<Button-1>', self.__rec_or_txt_select)
       # comandi per barre
       bar_cmd_pnl = tk.Frame(par_pnl)
       bar_cmd_pnl.grid(row=3, column=0, columnspan=4,
                        padx=4, pady=4, sticky='ew')
       bt = tk.Button(bar_cmd_pnl, text='Aggiungi barra', command=self._add_bar)
       bt.grid(row=0, column=0, padx=4, pady=4, sticky='ew')
       bt = tk.Button(bar_cmd_pnl, text='Rimuovi barra', command=self._del_bar)
       bt.grid(row=0, column=1, padx=4, pady=4, sticky='ew')
       bt = tk.Button(bar_cmd_pnl, text='Salva', command=self._on_save)
       bt.grid(row=0, column=2, padx=4, pady=4, sticky='ew')
       bt = tk.Button(bar_cmd_pnl, text='Chiudi', command=self.destroy)
       bt.grid(row=0, column=3, padx=4, pady=4, sticky='ew')
       bar_cmd_pnl.grid_columnconfigure(0, weight=1, uniform='cmd')
       bar_cmd_pnl.grid_columnconfigure(1, weight=1, uniform='cmd')
       bar_cmd_pnl.grid_columnconfigure(2, weight=1, uniform='cmd')
       bar_cmd_pnl.grid_columnconfigure(3, weight=1, uniform='cmd')
       heads = [{'name': 'etichetta', 'label': 'Etichetta', 'measure': 'X'*12},
                {'name': 'valore', 'label': 'Valore', 'measure': ' 00000 '},
                {'name': 'colore', 'label': 'Colore', 'measure': None}]
       self.data_p = DataPanel(par_pnl, heads, func=self.selected_bar, no_evidence=True)
       self.data_p.grid(row=4, column=0, columnspan=4,
                        padx=4, pady=4, sticky='nsew')
       par_pnl.grid_columnconfigure(1, weight=1)
       
       self.update()
       self._populate_colors(c_colors)
       
       self.grid_rowconfigure(0, weight=1)
       self.grid_columnconfigure(0, weight=1)

   def _make_fig(self):
       fig = Figure()
       self.ax = fig.add_subplot(111)
       labels = [x[0] for x in self.bars]
       values = [x[1] for x in self.bars]
       colors = [x[2] for x in self.bars]
       self.ax.bar(labels, values, color=colors)
       return fig

   def _plot(self):
       labels = [x[0] for x in self.bars]
       values = [x[1] for x in self.bars]
       colors = [x[2] for x in self.bars]
       self.ax.clear()
       if self.bar_title:
           self.ax.set_title(self.bar_title)
       if self.bar_y_label:
           self.ax.set_ylabel(self.bar_y_label)
       self.ax.bar(labels, values, color=colors)
       self.canvas.draw()
   
   def _populate_colors(self, pan):
       colors = list(mcolors.BASE_COLORS)
       colors += list(mcolors.TABLEAU_COLORS)
       colors += list(mcolors.CSS4_COLORS)
       if not colors:
           return
       row = 0
       posy = 0
       dim_x = pan.winfo_width()
       my_font = fnt.Font(size=8, family='TkDefaultFont')
       dim_ty = my_font.metrics('linespace')
       incr = int(dim_ty * 2)
       for color in colors:
           try:
               rid = pan.create_rectangle((0, posy, dim_x, posy+incr), fill=color)
               n_c = self.__negative_color(color, pan)
               dim_tx = my_font.measure(color)
               x = dim_x // 2
               y = posy + incr//2
               pan.create_text(x, y, text=color, fill=n_c)
               pan.tag_lower(rid)
               posy += incr
               # i bind dopo i test
               #pan.tag_bind(rid, '<Button1>', self.__rec_select)
               row += 1
           except:
               pass
       pan.configure(scrollregion=(0, 0, dim_x, posy))

   def __negative_color(self, color, cnv):
       '''
       restituisce il negativo di un named-color

       parametri : color : il color-named da trattare
                   cnv   : il canvas in cui verrà applicato, può essere, comunque
                           un generico widget con che dispone del metodo winfo_rgb
       '''
       c_rgb = cnv.winfo_rgb(color)
       n_rgb = (255-c_rgb[0]//256, 255-c_rgb[1]//256, 255-c_rgb[2]//256)
       return '#%02x%02x%02x' % (n_rgb)

   def __rec_or_txt_select(self, evt):
       '''
       Assegna il colore dell'elemento
       cliccato nel canvas dei colori alla label di esempio,
       '''
       color = evt.widget.itemcget('current', 'fill')
       self.l_color.configure(bg=color)
       n_c = self.__negative_color(color, evt.widget)
       self.l_color.configure(fg=n_c)
       self.color = color

   def _on_title(self):
       if self.state: return
       self.bar_title = self.e_title.get()
       self.bar_y_label = self.e_ylabel.get()
       self._plot()
       
   def _add_bar(self):
       if not self.state:
           self.e_label.delete(0, 'end')
           self.e_value.delete(0, 'end')
           self.state = 'add'
           self.e_label.focus_set()
           return
       new_label = self.e_label.get()
       if new_label == '':
           msg = 'Etichetta non definita'
           msgb.showwarning('Carenza dati', msg)
           self.e_label.focus_set()
           return
       if new_label in [x[0] for x in self.bars]:
           msg = 'Etichetta già esistente'
           msgb.showwarning('Carenza dati', msg)
           self.e_label.focus_set()
           return
       value = self.e_value.get()
       if value == '' or not value.isnumeric():
           msg = 'Il valore deve essere un numero intero'
           msgb.showwarning('Dati difformi', msg)
           self.e_value.focus_set()
           return
       if not self.color:
           msg = 'Nessun colore corrente'
           msgb.showwarning('Carenza dati', msg)
           return            
       self.bars.append((new_label, int(value), self.color))
       self.data_p._update_data(self.bars)
       self._plot()
       self.state = ''
       
   def _del_bar(self):
       if self.state: return
       if self.sel_index is None: return
       del self.bars[self.sel_index]
       self.sel_index = None
       self.data_p._update_data(self.bars)
       self._plot()

   def selected_bar(self, item):
       if self.state: return
       self.sel_index = None
       for i in range(len(self.bars)):
           if self.bars[i][0] == item[0]:
               self.sel_index = i
               break
       if self.sel_index == None: return
       bar = self.bars[self.sel_index]
       self.e_label.delete(0, 'end')
       self.e_label.insert('end', bar[0])
       self.e_value.delete(0, 'end')
       self.e_value.insert('end', bar[1])
       color = bar[2]
       self.l_color.configure(bg=color)
       n_c = self.__negative_color(color, self.l_color)
       self.l_color.configure(fg=n_c)

   def _on_save(self):
       f_name = fdg.asksaveasfilename(title='Salva grafico',
                                      initialfile='grafico',
                                      defaultextension='.png',
                                      filetypes=[('immagine png', '*.png')])
       if not f_name: return
       self.canvas.print_png(f_name)


if __name__ == '__main__':
   app = MyApp()
   app.mainloop()
view post Posted: 28/1/2022, 10:58 [Python] giocando con i video 01 - immagini ed audio - Appunti di apprendimento
I miei saluti ad improbabili lettori.

Recentemente mi sono imbattuto in una discussione che mi ha intrigato, l'autore voleva creare un file video da immagini utilizzando un improbabile miscuglio tra linea di comando e finestre di utilità (gestione files) tkinter ... c'è da dire che vi era un "che" di ingegnoso nel modo in cui riusciva effettivamente ad utilizzare le box tkinter aggirandone il mainloop, ma non era questo che mi ha intrigato.

L'oggetto intrigante è stato la libreria moviepy, un wrapper per ffmpeg mirato a semplificare in qualche modo l'utilizzo di quel panzer per l'edithing audio-video ... in questo periodo, giusto per passare il tempo, ogni tanto metto mano ad una utility che mi sto realizzando per riordinare la mia mediateca e moviepy potrebbe essermi utile sotto vari aspetti (eliminare pubblicità indesiderate, salvare solo parti interessanti, comporre, etc.) ho quindi deciso di provarla realizzando "qualcosa" di analogo a quello che secondo me lo OP del post intendeva realizzare.

Cosa ho stabilito di fare


In questo primo test delle funzionalità di moviepy ho deciso di realizzare un semplice video, in qualchuno dei formati più comuni, costituito da una successione di immagini ed un singolo file audio, senza alcun effetto di transizione, testo, etc. ... una cosa facile facile, insomma, senza fronzoli e che pensavo potesse risolversi con poche righe di codice.
Inutile dire che le "poche righe di codice" sono una pia illusione, insomma, si, se ci si vuole mantenere entro il tracciato stabilito da Zulko (l'autore di moviepy) basta la linea di comando e con pochi comandi lo si fa, ma l'autore ha deciso di interfacciarsi a pygame per eventuali anteprime ed a ipython per la rappresentazione dei video, entrambe soluzioni che a me NON stanno bene, da buon dinosauro voglio la mia brava GUI da cui interagire, per quanto riguarda la rappresentazione di video in python ho già i miei metodi da un bel po', ed circa l'anteprima ci posso tranquillamente rinunciare, per ora.

Quindi, in sintesi, è da realizzarsi un sistema che permetta :
  1. di impostare un tipo di video
  2. di dimensionare il video
  3. di selezionare le immagini da utilizzare
  4. di selezionare l'audio da usare
  5. di definire destinazione e nome del video
Dal punto di vista "interfaccia grafica" la semplice finestra in figura ha tutte le caratteristiche che servono, più alcune altre sussidiarie

png


Come potete evincere dalla figura, dal punto di vista grafico non è niente di trascendentale, una list-box nata per esporre i files immagine selezionati, ed eventualmente rimuoverli, un canvas (a sfondo nero) per visualizzare le immagini scelte, alcuni controlli per inserire il tipo e le dimensioni del video da realizzare, una label per comunicazioni, una progress-bar ed una serie di pulsanti di comando.

Funzionamento della GUI


Esaminiamo preliminarmente il funzionamento d'uso della interfaccia grafica, di per se molto semplice e per cui si rimanda ai vari callback per i dettagli implementativi.
I comandi disponibili permettono di caricare o singole immagini (pulsante "Carica file") oppure tutte le immagini contenute in una directory (pulsante "Carica directory"), metodo a mio parere preferibile, nel secondo caso i files contenuti nella directory verranno ordinati per nome-file, ciò permette di preparare in una direttrice i file-immagine che si vogliono utilizzare rinominandoli secondo l'ordine in cui si vuole vengano visualizzati nel filmato finale, ovviamente verrà mantenuto l'ordine con cui i files verranno inseriti con comandi successivi.

Tipi di immagini supportati


Le tipologie di immagini supportate sono quelle gestibili in pillow, fork di PIL installato nel venv dedicato, quando verranno caricate delle immagini avrete disponibili i nome-file delle stesse nella list-box, basterà selezionarne una per vederla nel canvas di visualizzazione

png


Nel caso il tipo di immagine non sia supportato non verrà segnalato errore ma il canvas diverrà, o rimarrà, nero e potrete rimuovere l'immagine selezionandola e premendo il pulsante "Rimuovi immagine" posto immediatamente sotto la list-box, ovviamente tale bottone funziona anche per le immagini supportate, se non vi piacciono ;).
Vi è da precisare che se in una directory caricata vi sono presenti dei files che NON sono immagini, essi non verranno caricati, in effetti il callback del pulsante, una volta stabilità una directory di origine lancia il metodo "_search_img(self)" della classe "MainWin" che esamina i files tramite i metodi della libreria python-magic prima di aggiungerli alla lista dei files da utilizzarsi (proprietà "self.img_list" della classe MainWin) ... forse, può interessare il codice di tale metodo
CODICE
def _search_img(self):
       '''Aggiunge tutti i file-immagine di una directory ordinandoli per nome'''
       tot_f = [f for f in os.listdir(self.start_dir) if
                os.path.isfile(os.path.join(self.start_dir, f))]
       if not tot_f:
           self._make_msg('Nessun file utile trovato')
           return
       new_f = []
       for f in tot_f:
           f_name = os.path.join(os.path.join(self.start_dir, f))
           if 'image' in magic.from_file(f_name, mime=True):
               if f_name not in self.img_list:
                   new_f.append(f_name)
       num_new_img = len(new_f)
       if not new_f:
           self._make_msg('Nessuna nuova immagine trovata')
           return
       new_f.sort()
       self.img_list += new_f
       msg = 'Trovate %d nuove immagini, ora %d immagini totali' % (num_new_img,
                                                                    len(self.img_list))
       self._make_msg(msg)
       self._reset_list()


File audio ...


Questa prova prevede, obbligatoriamente, la presenza di un file audio da collegare alle immagini per le quali si sta realizzando il video.
Anche se possibile utilizzare più file audio nella composizione di un video con moviepy, in questa prova si è scelto di utilizzarne uno ed uno soltanto, la sua durata determina la durata totale del filmato che verrà ripartita equamente tra le immagini caricate per la visualizzazione. L'eventuale caricamento di un nuovo audio comporta la sostituzione della precedente scelta.

... e continuando ...


Fasi successive e necessarie per poter produrre il video sono la definizione della directory di destinazione, eseguibile tramite il pulsante di comando "Destinazione" che farà apparire una finestra di dialogo mirata alla selezione (non definizione, deve esistere già) della dirctory di destinazione, e lo stabilire del nome da assegnare al video, effettuabile tramite il pulsante "Nome video" che farà comparire una finestra in input relativa.
Il nome del video NON deve avere estensione, l'estensione sarà assegnata durante il processo di creazione in base al tipo di video selezionato tra le opzioni della sezione "Tipi video" dell'apposito pannello ... tali opzioni ricoprono una infima parte delle tipologie possibili, per questo primo test ho scelto le più comuni, è solo un esercizio.
Il secondo blocco di opzioni disponibile riferisce alla dimensione del video da realizzare, le dimensioni di default definite sono 4, crescenti a partire da quelle del classivo DVD (720 x 576) ma è possibile definirle a piacere scegliendo l'opzione "altro" della sezione "Risoluzione", tale scelta renderà attive due caselle di testo per tale inserimento, ricordate che le convenzioni per l'edithing video recitano che le dimensioni dovrebbero essere multipli di 16, non vi darà errore se non lo saranno, come invece farà se NON inserirete un numero : avrete un errore non gestito (questo è solo un test) che interromperà il processo e il programma sembrerà non far niente :lol:

Una volta definiti i quattro articoli sopra (immagini, audio, destinazione e nome) vedrete diventare disponibile il pulsante "Crea video"

png


la cui pressione darà avvio alle operazioni di produzione del video stesso ... e finalmente si è giunti a qualcosa di cui vale la pena parlare.

Il processo di creazione del video


Sin dai primi test effettuati a linea di comando mi son reso conto di alcune cose :
1° moviepy è stato creato mirando alle tecnologie web (ipython ed il suo notebook in sostanza), non ad ambienti desktop.
In una GUI desktop vi è una certa difficoltà ad intercettare i vari output, con particolare riferimento a quelli emessi da ffmpeg, cosa invece abbastanza semplice da un notebook ipython.
2° gli automatismi di moviepy producono un filmato di dimensioni pari alla immagine più grande passata per la riproduzione;
3° è possibile ridimensionare le immagini, o meglio i vari spezzoni video per le singole immagini, ma le immagini vengono deformate se non hanno un rapporto lunghezza/altezza pari a quello della risoluzione scelta;
4° ci vuole un certo tempo perché il video venga composto.

Uno dei problemi su cui ho fatto più prove è stato senz'altro quello della perdita di proporzione delle immagini, molto antipatico visivamente e potenzialmente limitante, dopo numerosi tentativi di effettuare un adeguato proporzionamento delle immagini decente tramite gli strumenti di moviepy son giunto alla conclusione che o la libreria non dispone di strumenti adeguati oppure io non li ho trovati. In ogni caso, volendo ottenere un corretto proporzionamento la soluzione più pratica era creare una immagine con sfondo nero della dimensione voluta, incollarci dentro l'immagine da rappresentare scalata, salvarla da qualche parte e passare l'immagine creata per l'elaborazione del video, ciò per ogni singola immagine.

Ovviamente, l'isieme delle immagini "manipolate" può essere salvato in qualsiasi punto del file-system ma ho deciso di utilizzare la libreria appdirs, che consente di avere un "set" di directory predefinite standardizzato, ho approfittato di tale libreria per definire un set che va a valorizzare una variabile globale (DEFAULT_DIRS = {}) a livello di modulo, che diviene riferimento per le elaborazioni da effettuarsi ... ovviamente, ho creato delle apposite funzioni per verifica dell'esistenza ed eventuale creazione delle directory interessate
CODICE
def make_recursive_dir(pathname):
   dir_name = os.path.basename(pathname)
   dir_mater = os.path.dirname(pathname)
   if os.path.exists(pathname) and os.path.isdir(pathname):
       return
   else:
       make_recursive_dir(dir_mater)
       os.mkdir(pathname)

def def_dirs():
   '''
   Definisce le directory da utilizzare nella applicazione,
   eventualmente non esistano le crea.'''
   global DEFAULT_DIRS
   app_name = 'movie_test'
   app_author = 'nuzzopippo'
   DEFAULT_DIRS['data'] = appdirs.user_data_dir(app_name, app_author)
   DEFAULT_DIRS['cache'] = appdirs.user_cache_dir(app_name, app_author)
   DEFAULT_DIRS['log'] = appdirs.user_log_dir(app_name, app_author)
   DEFAULT_DIRS['state'] = appdirs.user_state_dir(app_name, app_author)
   DEFAULT_DIRS['config'] = appdirs.user_config_dir(app_name, app_author)
   try:
       for key in DEFAULT_DIRS.keys():
           make_recursive_dir(DEFAULT_DIRS[key])
   except OSError as e:
       exit(1)

la funzione "def_dirs()" sopra codificata viene invocata all'avvio della applicazione, essa provvede a compilare il dizionario globale DEFAULT_DIRS ed invocare la funzione "make_recursive(...)" su ogni singola voce dei path definiti, quest'ultima provvede ad esaminare ricorsivamente l'albero di directory necessario creando le directory mancanti, se non le trova.

Una volta stabilito "dove" salvare le immagini ben proporzionate, ho definito una classe preposta a detto proporzionamento : "ImgAdapter" ... beh, forse non è il nome "giusto" ma, in fin dei conti, sta "adattando" le immagini no?
CODICE
class ImgAdapter:
   '''Dalle immagini originali produce nuove immagini ri-scalate alla dimensione
      finale voluta per il video.
   '''
   def __init__(self, parent, img_list, img_size, lblitem, prgitem):
       self.parent = parent
       self.source = img_list
       self.d_size = img_size
       self.l_msg = lblitem
       self.progress = prgitem
       self.result = []
       self.cache = DEFAULT_DIRS['cache']
   
   def resize_images(self):
       tot_img = len(self.source)
       for i in range(len(self.source)):
           f_bname = os.path.basename(self.source[i])
           msg = 'Ridimensionamento di %s' % f_bname
           self.l_msg['text'] = msg
           try:
               self._resize(self.source[i], i)
           except (FileNotFoundError,
                   UnidentifiedImageError,
                   ValueError,
                   TypeError,
                   OSError) as e:
               self.parent.message('error', repr(e))
               return
           perc = (i + 1) * 100 / tot_img
           self.progress['value'] = floor(perc)
       self.parent.message('image_resize_finished', self.result)
           
   def _resize(self, f_name, img_num):
       n_name = 'img%04d.png' % img_num
       new_fname = os.path.join(self.cache, n_name)
       bg_img = Image.new(mode= 'RGB', size=self.d_size,
                          color=(0, 0, 0))
       d_w, d_h = bg_img.size
       img = Image.open(f_name, 'r')
       img_w, img_h = img.size
       # calcola i fattori di scala
       r_img = img_w / img_h
       r_bg = d_w / d_h
       if r_bg <= r_img:
           f_scala = d_w / img_w
       else:
           f_scala = d_h / img_h
       fw = int(img_w * f_scala)
       fh = int(img_h * f_scala)
       img = img.resize((fw, fh))
       offset = ((d_w - fw) //2, (d_h - fh) // 2)
       bg_img.paste(img, offset)
       bg_img.save(new_fname)
       self.result.append(new_fname)

un oggetto ImgAdapter deve esser invocato con un insieme predefinito di parametri, nell'ordine
parent, l'oggetto chiamante
img_list, una lista delle immagini originali da comporre
img_size, le dimensioni delle immagini da produrre
lblitem, una label in cui indicare cosa si sta elaborando
prgitem, una progressbar con cui indicare la percentale complessiva di elaborazione.
Non mi dilungo sui processi di ri-definizione delle immagini, codice e documentazione parleranno chiaro agli interessati, ciò che importa è che ImgAdapter mette a disposizione il metodo "resize_images()" che può essere posto quale target di un thread, in tal modo, approfittando del fatto che il mainloop di tkinter è thread-safe, l'aggiornamento della GUI avviene in parallelo alla lavorazione, per altro ImgAdapter esige che il "parent" possieda un metodo "message(message, data)" al quale comunicare di aver finito i suoi processi e fornire i dati prodotti (pathname deile nuove immagini) ... in questo processo la Image di PIL proclama ogni eventuale errore, interrompendo l'elaborazione.

I messaggi sulla label e nella barra di avanzamento, quasi inutili per poche decine di immagini, sono significativi in fase di produzione del filmato

png


processo che richiede un certo tempo per essere effettuato.
I dati di avanzamento della produzione del video non sono propri di moviepy, bensì di ffmpeg, della quale moviepy è un wrapper, e trovare il modo di acqusire l'output prodotto da ffmpeg è stato difficoltoso, ne son venuto a capo trovando un riferimento a "proglog" da parte di Zulko in una discussione sul sito della libreria moviepy.
Tra le altre cosette, proglog rende disponibile un oggetto "ProgressBarLogger" che viene accettato da un oggetto clip di moviepy in fase di produzione del video, quale logger.
L'oggetto ProgressBarLogger ha un metodo "callback(self, **changes)" che interviene alla sua variazione di stato, è stato sufficiente ridefinire tale metodo leggendo lo stato del ProgressBarLogger (un dizionario) per identificare i dati necessari (comparandoli con l'output di ffmpeg a linea di comando) e farli trasmettere alla GUI
CODICE
class MyLogger(ProgressBarLogger):
   '''Oggetto per intercettare l'output di ffmpeg e comunicarlo alla finestra'''
   def __init__(self, lblitem, progritem):
       super().__init__(init_state=None, bars=None, ignored_bars=None,
                        logged_bars='all', min_time_interval=0, ignore_bars_under=0)
       self.lbl = lblitem
       self.progr = progritem
   
   def callback(self, **changes):
       ''' Qui legge lo stato di avanzamento del processo, un dizionario come segue:
           {'bars': OrderedDict([('chunk',
                                  {'title': 'chunk',
                                   'index': 4898,
                                   'total': 4898,
                                   'message': None,
                                   'indent': 0}),
                                 ('t',
                                  {'title': 't',
                                   'index': 5554,
                                   'total': 5554,
                                   'message': None,
                                   'indent': 2})]),
            'message': 'Moviepy - Writing video /home/nuzzopippo/test/leggo_state.mp4\n'}
       '''
       try:
           index = self.state['bars']['t']['index']
           total = self.state['bars']['t']['total']
           percent = index / total * 100
           if percent < 0:
               percent = 0
           if percent > 100:
               percent = 100
           self.progr['value'] = percent
           self.lbl['text'] = \
               f"{index} di {total} video frames completati... ({floor(percent)}%)"
       except KeyError as e:
           pass


Una volta risolto il problema del proporzionamento delle immagini e della intercettazione dello stato di elaborazione emesso da ffmpeg, il resto è in discesa ...

Occorre, naturalmente un "mulo" cui caricare il lavoro pesante, tale bestia da soma è rappresentato dalla classe "MyWorker" che per la sua istanza richiede solo il "parent", ossia la finesta in cui viene istanziato (ma potrebbe essere qualciasi cosa con un metodo "nessage") e che rende disponibile il metodo "make_video(self, kwargs)" che richiede un solo parametro : un dizionario contenente precise chiavi valorizzate e la cui eventuale carenza produce l'interruzione con errore
CODICE
class MyWorker:
   '''Esecutore della creazione del video'''
   def __init__(self, parent):
       self.parent = parent
   
   def make_video(self, kwargs):
       try:
           img_list = kwargs['img_list']
           au_f = kwargs['audio']
           size = kwargs['size']
           logger = kwargs['logger']
           name = kwargs['name']
           # codecs da https://ffmpeg.org/ffmpeg-codecs.html
           # libxvid da errore, forse per bug di moviepy, forse per mancanza di parametri
           # si applica, per i "contenitori" la sola libx264
           #if name.split('.')[-1] == 'avi':
           #    codec = 'libxvid'
           #elif  name.split('.')[-1] == 'mkv':
           #    codec = 'libx264'
           if name.split('.')[-1] in ['avi', 'mkv']:
               codec = 'libx264'
           else:
               codec = None
       except KeyError as e:
           print(repr(e))
           self.parent.message('Error')
           return
       au_clip = AudioFileClip(au_f)
       t_d = au_clip.duration / len(img_list)
       clips = []
       for f in img_list:
           clip = ImageClip(f).set_duration(t_d)
           clips.append(clip.resize(size))
       video = concatenate(clips)
       video.audio = au_clip
       if codec is None:
           video.write_videofile(name, fps=25, logger=logger)
       else:
           video.write_videofile(name, codec=codec, fps=25, logger=logger)
       self.parent.message('done')

Il su esposto codice di MyWorker è decisamente semplice, poco più di quanto occorra utilizzando la linea di comando ... ma giusto perché si è voluto semplificare al massimo, noterete certo che per le tipologie "avi" e "mkv" viene prodotto un elemento "codec" che non esiste per file mp4 o webm, ciò perche i formati AVI e matroska sono dei "contenitori" e possono contenere numerosi elementi e diversi tipi di codifica, in questa prima prova si è utilizzata un singolo encoder ma la in realtà i formati audio/video e gli encoders utilizzabili sono numerosi, oltre tutto con una caterva di possibili ozioni ... un utilizzo puntuale delle varie possibilità richiederebbe processi molto più complessi del presente test (si notino i commenti su "libxvid", c'è una pagina di moviepy dove si invita a segnalare malfunzionamenti ;) ).

Cosa avviente premuto "Crea video"?


Come sarà evidente da quanto detto, il processo di creazione del video è diviso in due fasi logiche, svolte da due successivi thread, nella prima fase vengono ridimensionate le immagini originali e prodotte nuove immagini ben proporzionate, nella seconda fase le nuove immagini prodotte vengono utilizzate per creare spezzoni di video di lunghezza calcolata che poi vengono assemblati e montati unitamente all'audio.

il callback del pulsante "Crea video", metodo "_make(self)" della GUI si limita ad istanziare un ImgAdapter ed un threading.Thread a cui assegna il metodo resize_images dell'ImgAdapter istanziato, invoca l'avvio del thread ("t.start()") ed esce.

Alla fine della sua esecuzione l'oggetto ImgAdapter istanziato invocherà il metodo "message(self, text, data=None)" valorizzando il parametro "text" con la stringa "image_resize_finished" ed il parametro "data" con la lista dei pathname delle immagini prodotte.
Nel metodo "message" viene effettuata la valutazione del testo ricevuto e riconosciuta la stringa "image_resize_finished" viene invocato il metodo "_make_video(self, img_list)" della GUI, passando la lista delle nuove immagini, quest'ultimo metodo provvederà a raccogliere i parametri di produzione impostati inserendoli in un dizionario, istanzierà un oggetto MyLogger che archivierà nel dizionarie per poi istanziare un oggetto MyWorker ed un nuovo threading.Thread, cui passerà il metodo "make_video" quale target ed il dizionario creato quale dati associati al target, quindi avvierà il thread ed uscirà.

Man mano che la creazione del video avanza il MyLogger istanziato informerà l'utente dello stato delle cose ed al completamento delle elaborazioni l'oggetto MyWorker provvederà a comunicare l'esito positivo alla GUI che informerà l'utente

png



Il file sarà pronto da visualizzare dove l'utente ha stabilito che stia, salvo, ovviamente, eventuali errori.

segue il codice completo del test
CODICE
# -*- coding: utf-8 -*-

import tkinter as tk
import tkinter.messagebox as mb
import tkinter.filedialog as fd
from tkinter.ttk import Progressbar
import os
import glob
import magic
from PIL import UnidentifiedImageError
from PIL import Image
from PIL import ImageTk
from proglog import ProgressBarLogger
from threading import Thread
from moviepy.editor import AudioFileClip, ImageClip, concatenate
from moviepy.video.fx import resize
from math import floor
import appdirs

DEFAULT_DIRS = {}

VIDEO_FORMATS_EXT = {'WebM'            : '.webm',
                    'Matroska'        : '.mkv',
                    'AVI'             : '.avi',
                    'MPEG-4 Part. 14' : '.mp4'}

VIDEO_RESOLUTIONS = (('DVD', 720, 576),
                    ('HIGH DEFINITON', 1920, 1080),
                    ('2K CINEMA', 2048, 1080),
                    ('4K CINEMA', 4096, 2160),
                    ('altro', 0, 0))

def make_recursive_dir(pathname):
   dir_name = os.path.basename(pathname)
   dir_mater = os.path.dirname(pathname)
   if os.path.exists(pathname) and os.path.isdir(pathname):
       return
   else:
       make_recursive_dir(dir_mater)
       os.mkdir(pathname)

def def_dirs():
   '''
   Definisce le directory da utilizzare nella applicazione,
   eventualmente non esistano le crea.'''
   global DEFAULT_DIRS
   app_name = 'movie_test'
   app_author = 'nuzzopippo'
   DEFAULT_DIRS['data'] = appdirs.user_data_dir(app_name, app_author)
   DEFAULT_DIRS['cache'] = appdirs.user_cache_dir(app_name, app_author)
   DEFAULT_DIRS['log'] = appdirs.user_log_dir(app_name, app_author)
   DEFAULT_DIRS['state'] = appdirs.user_state_dir(app_name, app_author)
   DEFAULT_DIRS['config'] = appdirs.user_config_dir(app_name, app_author)
   try:
       for key in DEFAULT_DIRS.keys():
           make_recursive_dir(DEFAULT_DIRS[key])
   except OSError as e:
       exit(1)

def center_win(w):
   '''Centra sullo schermo una finestra'''
   x = w.winfo_screenwidth()
   y = w.winfo_screenheight()
   wx = w.winfo_reqwidth()
   wy = w.winfo_reqheight()
   w.geometry('{}x{}+{}+{}'.format(wx, wy, (x-wx)//2, (y-wy)//2))

def get_tk_image(fname, wdg):
   try:
       image = Image.open(fname)
   except (FileNotFoundError, OSError,
           ValueError, TypeError):
       return None
   img_x, img_y = image.size
   r_img = img_x / img_y
   r_wdg = wdg.winfo_width() / wdg.winfo_height()
   if r_wdg <= r_img:
       f_scala = (wdg.winfo_width()-10) / img_x
   else:
       f_scala = (wdg.winfo_height()-10) / img_y
   fx = int(img_x * f_scala)
   fy = int(img_y * f_scala)
   image = image.resize((fx, fy))
   return ImageTk.PhotoImage(image)


class MyLogger(ProgressBarLogger):
   '''Oggetto per intercettare l'output di ffmpeg e comunicarlo alla finestra'''
   def __init__(self, lblitem, progritem):
       super().__init__(init_state=None, bars=None, ignored_bars=None,
                        logged_bars='all', min_time_interval=0, ignore_bars_under=0)
       self.lbl = lblitem
       self.progr = progritem
   
   def callback(self, **changes):
       ''' Qui legge lo stato di avanzamento del processo, un dizionario come segue:
           {'bars': OrderedDict([('chunk',
                                  {'title': 'chunk',
                                   'index': 4898,
                                   'total': 4898,
                                   'message': None,
                                   'indent': 0}),
                                 ('t',
                                  {'title': 't',
                                   'index': 5554,
                                   'total': 5554,
                                   'message': None,
                                   'indent': 2})]),
            'message': 'Moviepy - Writing video /home/nuzzopippo/test/leggo_state.mp4\n'}
       '''
       try:
           index = self.state['bars']['t']['index']
           total = self.state['bars']['t']['total']
           percent = index / total * 100
           if percent < 0:
               percent = 0
           if percent > 100:
               percent = 100
           self.progr['value'] = percent
           self.lbl['text'] = \
               f"{index} di {total} video frames completati... ({floor(percent)}%)"
       except KeyError as e:
           pass


class MyWorker:
   '''Esecutore della creazione del video'''
   def __init__(self, parent):
       self.parent = parent
   
   def make_video(self, kwargs):
       try:
           img_list = kwargs['img_list']
           au_f = kwargs['audio']
           size = kwargs['size']
           logger = kwargs['logger']
           name = kwargs['name']
           # codecs da https://ffmpeg.org/ffmpeg-codecs.html
           # libxvid da errore, forse per bug di moviepy, forse per mancanza di parametri
           # si applica, per i "contenitori" la sola libx264
           #if name.split('.')[-1] == 'avi':
           #    codec = 'libxvid'
           #elif  name.split('.')[-1] == 'mkv':
           #    codec = 'libx264'
           if name.split('.')[-1] in ['avi', 'mkv']:
               codec = 'libx264'
           else:
               codec = None
       except KeyError as e:
           print(repr(e))
           self.parent.message('Error')
           return
       au_clip = AudioFileClip(au_f)
       t_d = au_clip.duration / len(img_list)
       clips = []
       for f in img_list:
           clip = ImageClip(f).set_duration(t_d)
           clips.append(clip.resize(size))
       video = concatenate(clips)
       video.audio = au_clip
       if codec is None:
           video.write_videofile(name, fps=25, logger=logger)
       else:
           video.write_videofile(name, codec=codec, fps=25, logger=logger)
       self.parent.message('done')


class ImgAdapter:
   '''Dalle immagini originali produce nuove immagini ri-scalate alla dimensione
      finale voluta per il video.
   '''
   def __init__(self, parent, img_list, img_size, lblitem, prgitem):
       self.parent = parent
       self.source = img_list
       self.d_size = img_size
       self.l_msg = lblitem
       self.progress = prgitem
       self.result = []
       self.cache = DEFAULT_DIRS['cache']
   
   def resize_images(self):
       tot_img = len(self.source)
       for i in range(len(self.source)):
           f_bname = os.path.basename(self.source[i])
           msg = 'Ridimensionamento di %s' % f_bname
           self.l_msg['text'] = msg
           try:
               self._resize(self.source[i], i)
           except (FileNotFoundError,
                   UnidentifiedImageError,
                   ValueError,
                   TypeError,
                   OSError) as e:
               self.parent.message('error', repr(e))
               return
           perc = (i + 1) * 100 / tot_img
           self.progress['value'] = floor(perc)
       self.parent.message('image_resize_finished', self.result)
           
   def _resize(self, f_name, img_num):
       n_name = 'img%04d.png' % img_num
       new_fname = os.path.join(self.cache, n_name)
       bg_img = Image.new(mode= 'RGB', size=self.d_size,
                          color=(0, 0, 0))
       d_w, d_h = bg_img.size
       img = Image.open(f_name, 'r')
       img_w, img_h = img.size
       # calcola i fattori di scala
       r_img = img_w / img_h
       r_bg = d_w / d_h
       if r_bg <= r_img:
           f_scala = d_w / img_w
       else:
           f_scala = d_h / img_h
       fw = int(img_w * f_scala)
       fh = int(img_h * f_scala)
       img = img.resize((fw, fh))
       offset = ((d_w - fw) //2, (d_h - fh) // 2)
       bg_img.paste(img, offset)
       bg_img.save(new_fname)
       self.result.append(new_fname)


class MainWin(tk.Tk):
   def __init__(self):
       super().__init__()
       self.title('Test 01 moviepy : Imagini+audio')
       pn_data = tk.Frame(self)
       pn_data.grid(row=0, column=0, sticky='nsew')
       self.l_img = tk.Listbox(pn_data)
       self.l_img.grid(row=0, column=0, padx=(4,1), pady=4, sticky='nsew')
       v_scroll = tk.Scrollbar(pn_data, command=self.l_img.yview)
       self.l_img.configure(yscrollcommand=v_scroll.set)
       v_scroll.grid(row=0, column=1, sticky='ns')
       self.cnvimg = tk.Canvas(pn_data, bg='black')
       self.cnvimg.grid(row=0, column=2, rowspan=2, padx=4, pady=4, sticky='nsew')
       bt_remove = tk.Button(pn_data, text='Rimuovi immagine', command=self._on_remove)
       bt_remove.grid(row=1, column=0, padx=4, pady=4, sticky='ew')
       pn_conf = tk.LabelFrame(pn_data, text=' Opzioni ')
       pn_conf.grid(row=0, column=3, rowspan=2, sticky='ns')
       lbl = tk.Label(pn_conf, text='Tipi video :')
       lbl.grid(row=0, column=0, padx=4, pady=4, sticky='nsew')
       self.v_type = tk.StringVar()
       row = 1
       for key in sorted(VIDEO_FORMATS_EXT.keys()):
           text = key + ' (*' + VIDEO_FORMATS_EXT[key] + ')'
           rbt = tk.Radiobutton(pn_conf, text=text, variable=self.v_type,
                                value=VIDEO_FORMATS_EXT[key], command=self._on_video_type)
           rbt.grid(row=row, column=0, padx=(6,4), pady=1, sticky='w')
           row += 1
       initial = sorted(VIDEO_FORMATS_EXT.keys())[0]
       self.v_type.set(VIDEO_FORMATS_EXT[initial])
       lbl = tk.Label(pn_conf, text='Risoluzione :')
       lbl.grid(row=row, column=0, padx=4, pady=4, sticky='w')
       row += 1
       self.resolution = tk.StringVar()
       for i in range(len(VIDEO_RESOLUTIONS)):
           text = VIDEO_RESOLUTIONS[i][0]
           rbt = tk.Radiobutton(pn_conf, text=text, variable=self.resolution,
                                value=text, command=self._on_resolution)
           rbt.grid(row=row, column=0, padx=(6,4), pady=1, sticky='w')
           row += 1
       self.resolution.set(VIDEO_RESOLUTIONS[0][0])
       pn_res = tk.Frame(pn_conf)
       pn_res.grid(row=row, column=0, sticky='ew')
       self.v_x = tk.Entry(pn_res, width=5)
       self.v_x.grid(row=0, column=0, padx=4, pady=4, sticky='ew')
       lbl = tk.Label(pn_res, text='x')
       lbl.grid(row=0, column=1)
       self.v_y = tk.Entry(pn_res, width=5)
       self.v_y.grid(row=0, column=2, padx=4, pady=4, sticky='ew')
       pn_res.grid_columnconfigure(0, weight=1, uniform='b')
       pn_res.grid_columnconfigure(2, weight=1, uniform='b')
       pn_data.grid_rowconfigure(0, weight=1)
       pn_data.grid_columnconfigure(0, uniform='a')
       pn_data.grid_columnconfigure(2, weight=1)
       pn_data.grid_columnconfigure(3, uniform='a')
       self.lbl_mess = tk.Label(self, text='Ben venuto', justify='left')
       self.lbl_mess.grid(row=1, column=0, padx=4, pady=4, sticky='w')
       self.progr = Progressbar(self, orient='horizontal', mode='determinate')
       self.progr.grid(row=2, column=0, padx=4, pady=4, sticky='ew')
       pn_cmd = tk.Frame(self)
       pn_cmd.grid(row=3, column=0, sticky='nsew')
       self.bt_openf = tk.Button(pn_cmd, text='Carica file', command=self._open_file)
       self.bt_openf.grid(row=0, column=0, padx=4, pady=4, sticky='nsew')
       self.bt_opend = tk.Button(pn_cmd, text='Carica directory', command=self._open_dir)
       self.bt_opend.grid(row=0, column=1, padx=4, pady=4, sticky='nsew')
       self.bt_open_au = tk.Button(pn_cmd, text='Carica audio', command=self._open_music)
       self.bt_open_au.grid(row=0, column=2, padx=4, pady=4, sticky='nsew')
       self.bt_dest_d = tk.Button(pn_cmd, text='Destinazione', command=self._dest_dir)
       self.bt_dest_d.grid(row=0, column=3, padx=4, pady=4, sticky='nsew')
       self.bt_v_name = tk.Button(pn_cmd, text='Nome video', command=self._video_name)
       self.bt_v_name.grid(row=0, column=4, padx=4, pady=4, sticky='nsew')
       self.bt_make = tk.Button(pn_cmd, text='Crea video', command=self._make)
       self.bt_make.grid(row=0, column=5, padx=4, pady=4, sticky='nsew')
       self.bt_exit = tk.Button(pn_cmd, text='Chiudi programma', command=self.destroy)
       self.bt_exit.grid(row=0, column=6, padx=4, pady=4, sticky='nsew')
       for i in range(7): pn_cmd.grid_columnconfigure(i, weight=1, uniform='c')
       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(0, weight=1)

       self.l_img.bind('<ButtonRelease-1>', self._on_image)
       self.l_img.bind('<Return>', self._on_image)

       self.update()
       wx = self.winfo_reqwidth()
       wy = self.winfo_reqheight()
       self.minsize(width=wx, height=wy)
       center_win(self)
       self._on_resolution()

       self.start_dir = os.path.expanduser('~')

       self.img_list = []
       self.audio_f = ''
       self.dir_dest = ''
       self.video_name = ''
       self._evaluate_self()
       self.img = None
   
   def _on_remove(self):
       indexes = self.l_img.curselection()
       if not indexes: return
       index = indexes[0]
       del self.img_list[index]
       self.l_img.delete(index)
       self.cnvimg.delete('all')
       msg = 'Elemento rimosso, restano %d immagini' % len(self.img_list)
       self._make_msg(msg)
       self._evaluate_self()

   def _on_video_type(self):
       pass

   def _on_resolution(self):
       self.update()
       key = self.resolution.get()
       match = False
       x = 0
       y = 0
       for i in range(len(VIDEO_RESOLUTIONS)):
           if VIDEO_RESOLUTIONS[i][0] == key:
               match = True
               x = VIDEO_RESOLUTIONS[i][1]
               y = VIDEO_RESOLUTIONS[i][2]
               break
       if not match: return
       self.v_x.configure(state='normal')
       self.v_y.configure(state='normal')
       self.v_x.delete(0, 'end')
       self.v_x.insert('end', str(x))
       self.v_y.delete(0, 'end')
       self.v_y.insert('end', str(y))
       if key != 'altro':
           self.v_x.configure(state='disabled')
           self.v_y.configure(state='disabled')
   
   def _open_file(self):
       tipology = [('Files immagine', ('*.png', '*.PNG',
                                       '*.gif', '*.GIF',
                                       '*.jpg', '*.JPG',
                                       '*.jpeg', '*.JPEG',
                                       '*.bmp', '*.BMP')),
                   ('Tutti i files', '*.*')]
       f_name = fd.askopenfilename(parent=self, initialdir=self.start_dir,
                                   title='Selezione immagine', defaultextension='*.png',
                                   filetypes=tipology)
       if not f_name: return
       self.start_dir = os.path.dirname(f_name)
       if 'image' in magic.from_file(f_name, mime=True):
           self.img_list.append(f_name)
           self.l_img.insert('end', os.path.basename(f_name))
       msg = 'Aggiunto %s, %d immagini ora presenti' % (os.path.basename(f_name),
                                                        len(self.img_list))
       self._make_msg(msg)
       self._evaluate_self()

   def _open_dir(self):
       d_o = fd.askdirectory(parent=self, initialdir=self.start_dir,
                             title='Caricamento da directory')
       if d_o:
           if os.path.exists(d_o) and os.path.isdir(d_o):
               self.start_dir = d_o
               msg = 'Esame directory %s' % d_o
               self._make_msg(msg)
               self._search_img()
               self._evaluate_self()

   def _open_music(self):
       tipology = [('Tutti i files', '*.*')]
       f_name = fd.askopenfilename(parent=self, initialdir=self.start_dir,
                                   title='Selezione musica', defaultextension='*.*',
                                   filetypes=tipology)
       if not f_name: return
       self.start_dir = os.path.dirname(f_name)
       if 'audio' in magic.from_file(f_name, mime=True):
           self.audio_f = f_name
       msg = 'Impostato %s quale audio' % os.path.basename(f_name)
       self._make_msg(msg)
       self._evaluate_self()

   def _dest_dir(self):
       d_d = fd.askdirectory(parent=self, initialdir=self.start_dir,
                             title='Definizione directory destinazione')
       if d_d:
           if os.path.exists(d_d) and os.path.isdir(d_d):
               self.dir_dest = d_d
               msg = 'Directory di destinazione impostata'
           else:
               msg = 'Directory non valida'
           self._make_msg(msg)
           self._evaluate_self()

   def _video_name(self):
       msg = 'Inserire il nome (senza estensione) del\nvideo da creare\n(evitare spazi e caratteri strani)'
       name = tk.simpledialog.askstring('Nome filamato', msg)
       if name:
           self.video_name = name
           msg = 'Nome video : %s' % self.video_name
           self._make_msg(msg)
           self._evaluate_self()

   def _make(self):
       # parent, img_list, img_size, lblitem, prgitem
       x = int(self.v_x.get())
       y = int(self.v_y.get())
       size = x, y
       self.progr['value'] = 0
       resizer = ImgAdapter(self, self.img_list, size,
                            self.lbl_mess, self.progr)
       t = Thread(target=resizer.resize_images)
       t.start()
   
   def _make_video(self, img_list):
       data = {}
       data['img_list'] = img_list
       data['audio'] = self.audio_f
       x = int(self.v_x.get())
       y = int(self.v_y.get())
       data['size'] = x, y
       ext = self.v_type.get()
       f_name = os.path.join(self.dir_dest, self.video_name + ext)
       data['name'] = f_name
       self.progr['value'] = 0
       logger = MyLogger(self.lbl_mess, self.progr)
       data['logger'] = logger
       worker = MyWorker(self)
       t = Thread(target=worker.make_video, args=(data,))
       t.start()


   def _on_image(self, evt=None):
       indexes = self.l_img.curselection()
       if not indexes: return
       index = indexes[0]
       f_name = self.img_list[index]
       self.cnvimg.delete('all')
       img = get_tk_image(f_name, self.cnvimg)
       if img is None: return
       self.img = img
       x = self.cnvimg.winfo_width() // 2
       y = self.cnvimg.winfo_height() // 2
       self.cnvimg.create_image(x, y, image=self.img, anchor='center')

   def _evaluate_self(self):
       if self.img_list and self.audio_f and self.dir_dest and self.video_name:
           self.bt_make.configure(state='normal')
       else:
           self.bt_make.configure(state='disabled')
   
   def _make_msg(self, msg):
       self.lbl_mess.configure(text=msg)
   
   def _search_img(self):
       '''Aggiunge tutti i file-immagine di una directory ordinandoli per nome'''
       tot_f = [f for f in os.listdir(self.start_dir) if
                os.path.isfile(os.path.join(self.start_dir, f))]
       if not tot_f:
           self._make_msg('Nessun file utile trovato')
           return
       new_f = []
       for f in tot_f:
           f_name = os.path.join(os.path.join(self.start_dir, f))
           if 'image' in magic.from_file(f_name, mime=True):
               if f_name not in self.img_list:
                   new_f.append(f_name)
       num_new_img = len(new_f)
       if not new_f:
           self._make_msg('Nessuna nuova immagine trovata')
           return
       new_f.sort()
       self.img_list += new_f
       msg = 'Trovate %d nuove immagini, ora %d immagini totali' % (num_new_img,
                                                                    len(self.img_list))
       self._make_msg(msg)
       self._reset_list()
   
   def _reset_list(self):
       self.l_img.delete(0, 'end')
       for f in self.img_list:
           f_name = os.path.basename(f)
           self.l_img.insert('end', f_name)
   
   def message(self, text, data=None):
       if text == 'Error':
           msg = 'Produzione video cessata con errore'
           if data:
               msg += '\n' + data
           mb.showerror('Produzione fallita', msg)
       elif text == 'image_resize_finished':
           self._make_video(data)
       elif text == 'done':
           msg = 'Video prodotto con successo'
           mb.showinfo('Video pronto', msg)
           self._clear_cache()
   
   def _clear_cache(self):
       files = glob.glob(DEFAULT_DIRS['cache'] + '/*.*')
       for f in files:
           try:
               os.remove(f)
           except OSError:
               pass


def main():
   def_dirs()
   app = MainWin()
   app.mainloop()    


       

if __name__ == '__main__':
   main()


Sono 535 righe ed è stato pensato per Linux e nello stesso s.o. implementato, non so se funzionerà nei sistemi windows (sistema operativo che proprio non mi interessa) comunque, avrà bisogno delle librerie che seguono:
CODICE
(moviepy_v) NzP:~$ python -m pip list
Package            Version  
------------------ ---------
appdirs            1.4.4    
certifi            2021.10.8
charset-normalizer 2.0.10  
decorator          4.4.2    
idna               3.3      
imageio            2.13.5  
imageio-ffmpeg     0.4.5    
moviepy            1.0.3    
numpy              1.22.1  
Pillow             9.0.0    
pip                20.0.2  
pkg-resources      0.0.0    
proglog            0.1.9    
pygame             2.1.2    
python-magic       0.4.24  
requests           2.27.1  
setuptools         44.0.0  
six                1.16.0  
tqdm               4.62.3  
urllib3            1.26.8  
(moviepy_v) NzP:~$


Questo post è a mio promemoria, comunque se a qualcuno capitasse di leggerlo e gli fosse utile mi farebbe piacere, magari, in futuro sperimento qualcosa d'altro.

:D

Edited by nuzzopippo - 29/1/2022, 10:48
view post Posted: 14/10/2021, 16:33 [Python] Messaggi per un ttk.Notebook - Appunti di apprendimento
Riflettendo un po' mi son reso conto che è opportuno affrontare la modalità adottata nella gestione dei mici PRIMA di affrontare il discorso della gestione a livello di GUI utente. Parliamo ora della interazione GUI=>Modell implementata.

pypubsub: funzionamento elementare


Il modulo pubsub è una implementazione python dei pattern Publisher, in sostanza fornisce una API per lo scambio di messaggi tra elementi "registrati". Il modulo implementa il pattern "Singleton", il che vuol significare che esiste una ed una sola istanza del modulo attiva nella applicazione ... il che rende questo modulo molto comodo*, istanze diverse di pubsub effettuate in moduli diversi agiranno sullo stesso oggetto.

* rende anche disponibili delle possibilità difficilmente "pensabili", tipo istanziare il modello dati completamente al di fuori del mainloop tkinter, cosa testata apportando leggerissime modifiche a quanto qui esposto

Il funzionamento generale è concettualmente piuttosto semplice :
  1. si iscrive un metodo di un oggetto in un "gruppo";
  2. si invia un messaggio a pubsub indirizzato ad un dato "gruppo", pubsub provvederà a smistare il messaggio a tutti gli iscritti del "gruppo";
  3. si rimuove l'iscrizione del metodo di un oggetto quando non è più utile.
i "gruppi" possono essere più di uno, possono avere "sotto-gruppi e non necessariamente devono essere conosciuti a priori; inoltre, i "messaggi" possono essere oggetti qualsiasi.

Già l'utilizzo nella forma più elementare (per le avanzate vedere la docs) di pypubsub, ossia l'utilizzo dei metodi "subscribe(obj.method, group)", "sendMessage(group, message)" e "unsubscribe(obj.method, group)" di pub permette di definire un protocollo di gestione dati sufficientemente efficace per il nostro semplice "caso".

I Gruppi


Il passo preliminare è definire i gruppi di sottoscrizione, valutando le necessità della applicazione ho optato per la definizione di due gruppi, diciamo così, "fissi" e di un gruppo "composto", sotto descritti
  1. gruppo REQUEST : fisso, ascolta una "richiesta", unico iscritto il metodo "_requests(self, message)" della classe model.Cats, iscrizione permanente per tutta la durata della applicazione;
  2. gruppo LISTUPDATED : fisso, ascolta una comunicazione di aggiornamento dell'elenco dei gatti, unico iscritto il metodo "update_values(self, message)" della classe view.ListPanel, la sottoscrizione avviene ad instanziamento di un ListPanel e cancellata prima della sua distruzione;
  3. gruppo CATUPDATED.cat_name : composto, la parte variabile è "cat_name" ed individua un aggiornamento dati di uno specifico "gatto", unico iscritto il metodo "update_values(self, message)" della classe view.CatPanel cui viene assegnato il "cat_name", la sottoscrizione avviene ad instanziamento di un CatPanel e cancellata prima della sua distruzione.
Pur se nulla avrebbe impedito di effettuarle nelle classi Micio, ListPanel e CatPanel, si è scelto, per facilità di controllo, di effettuare quasi tutta la produzione dei messaggi nelle sole classi, diciamo principali, "Cats" e "MyApp", quest'ultima provvede anche alla sottoscrizione e cancellazione dei pannelli dai gruppi di sottoscrizione.

Anche se non ce ne sarebbe bisogno, si precisa che le sottoscrizioni possono essere multiple per ogni singolo gruppo, anche se nella nostra applicazione ciò non avviene.

Nello stralcio di codice sottostante, tratto dal metodo "view_cat(self, name)" della classe MyApp mostra la creazione e registrazione di un pannello per la visualizzazione dei dati di un dato "gatto" già esistente
CODICE
pnl = CatPanel(self.book)
       pnl.set_state('')
       self.book.add(pnl, text=name)
       self.book.select(pnl)
       pub.subscribe(pnl.update_values,
                     'CATUPDATED.' + '_'.join(name.split()))
       msg = ['SHOWCAT', name]
       pub.sendMessage('REQUEST', message=msg)

Mentre lo stralcio del callback di MyApp che segue mostra la cancellazione della sottoscrizione, eliminazione dal notebook e distruzione del pannello correntemente visualizzato e chiuso dall'utente
CODICE
def _on_close(self):
       label = self.book.tab(self.book.select(), 'text')
       item = self.book.nametowidget(self.book.select())
       self.book.forget(self.book.select())
       if label == 'Elenco gatti':
           pub.unsubscribe(item.update_values, 'LISTUPDATED')
       else:
           data = item.get_data()
           try:
               pub.unsubscribe(item.update_values, 'CATUPDATED.' +
                               '_'.join(data[0].split()))
           except:
               pass
       item.destroy()
       self.update()
       self._evaluate_self()


Il "Vocabolario" di comunicazione


Naturalmente, quando oggetti "diversi" devono scambiarsi messaggi deve essere stabilita una forma di dialogo conosciuta da tutti i soggetti interessati, contenente, per altro, i dati necessari per soddisfare le azioni opportune al messaggio ricevuto.
Nella implementazione realizzata, un elemento che abbia bisogno sia effettuata una certa azione invia al gruppo "REQUEST" un messaggio contenente il tipo di azione richiesta e gli eventuali dati necessari alla sua realizzazione.
Come detto in precedenza, nella nostra applicazione l'unico sottoscrittore al gruppo REQUEST è il metodo "_requests(self, message)" di Cats, il codice di tale metodo evidenzierà il "vocabolario" comune tra la view ed il modello dati
CODICE
def _requests(self, message):
       request = message[0]
       if request == 'GETLIST':
           self._pub_list()
       elif request == 'ADDCAT':
           self._add(message[1:])
       elif request == 'SHOWCAT':
           self._show(message[1])
       elif request == 'UPDATECAT':
           self._update(message[1:])
       elif request == 'CATDELETE':
           self._delete(message[1])


I Messaggi


dal codice sin qui esposto si evince facilmente che i messaggi costituenti le richieste sono delle liste, di uno o più oggetti, nel quale il primo degli elementi identifica l'azione richiesta, i restanti sono i dati indispensabili ad effettuare l'azione.
Segue ora una disamina di detto "vocabolario"

GETLIST
  • struttura :['GETLIST']
  • Azione : emissione messaggio con la lista di oggetti "Micio"
  • messaggio conclusivo emesso : ('LISTUPDATED', self.cats)

ADDCAT
  • struttura : ['ADDCAT', nome, immagine, descrizione]
  • Azione : aggiunge un nuovo gatto, memorizzando l'immagine nella directory di cache, aggiorna la persistenza dati. Se il gatto esiste già emette un ValueError
  • messaggio conclusivo emesso : ('LISTUPDATED', self.cats)

SHOWCAT
  • struttura : ['SHOWCAT', nome]
  • Azione : invia i dati di uno specifico gatto
  • messaggio conclusivo emesso : ('CATUPDATED._nome', [nome, immagine, descrizione])

UPDATECAT
  • struttura : ['UPDATECAT', nome, immagine, descrizione]
  • Azione : sostituisce i dati esistenti di uno specifico gatto con i nuovi, eventualmente elimina la vecchia immagine dalla cache e ci memorizza la nuova. Se non viene rintracciato il gatto emette un ValueError
  • messaggio conclusivo emesso : ('CATUPDATED._nome', [nome, immagine, descrizione]) e ('LISTUPDATED', self.cats)

CATDELETE
  • struttura : ['CATDELETE', nome]
  • Azione : elimina un gatto esistente ed aggiorna la persistenza dati. Se il gatto non viene trovato emette un ValueError
  • messaggio conclusivo emesso : ('LISTUPDATED', self.cats)

Naturalmente, i messaggi al gruppo "LISTUPDATE" vengono intercettati dal metodo "update_values" dell'oggetto "view.ListPanel", se un tale pannello è aperto, mentre i messaggi di gruppo "CATUPDATED._nome" verranno intercettati dal metodo "update_values" dell'istanza di "view.CatPanel" registrata al gruppo, i pannelli provvederanno ad aggiornare la loro rappresentazione.

Cosa avviene nella view


Credo sia ora di esaminare per sommi capi (complessivamente siamo a circa 650 righe di codice ;) ) come funzionano le cose nella view, con particolare attenzione sulla gestione del notebook.

Premettiamo che il codice in esame basa la sua funzionalità sullo scambio di messaggi e sul protocollo appena descritti, non avendo quasi per nulla rapporti diretti con le classi del modulo "model" (in una ulteriore versione qui non discussa neanche importato), si aspetta solo di ricevere degli oggetti che abbiano delle determinate proprietà, cosa siano non interessa, ed invia degli oggetti con certe caratteristiche, che fine facciano non riguarda la view. I punti focali sono i metodi "update_values(self, message)" definiti nei pannelli visti nel post precedente, per la ricezione dati, ed il sistema di messaggistica gestito dai callback dei pulsanti disponibili nella finestra, che danno all'utente la possibilità di decidere cosa vuole fare.

Un ulteriore elemento "importante" è la label dei tabs presenti nel notebook, utilizzata quale discriminante in diversi processi, di norma essa è il "nome" del "gatto" ed in ogni caso DEVE essere unica, non è consentito, in sostanza, avere due tabs con lo stesso testo ... potrebbe essere interessante lo stralcio di codice sottostante
CODICE
if name == 'Elenco gatti':
           msgb.showwarning('Nuovo micio', 'Nome gatto inaccettabile.',
                            parent=self)
           return
       labels = [self.book.tab(x, 'text') for x in
                 self.book.winfo_children()]
       if name in labels:
           msgb.showwarning('Nuovo micio', 'Micio già presente e visualizzato',
                            parent=self)
           return

esso è tratto dal callback "_on_new(self)" collegato al pulsante "Nuovo" di MyApp, che da all'user la possibilità di definire un nuovo gatto.
Si noti come il nome (name) richiesto all'utente nel callback venga verificato sia diverso dalla stringa "Elenco gatti", riservata al solo ListPanel, e come sia controllato non sia presente nella lista labels estratta tramite la list comprehension nel codice.
Riguardo la comprehension è banale che "self.book.tab(x, 'text')" legge la label del singolo tab dell'insieme dei tabs aperti nel notebook ed estratti con "self.book.winfo_children()".

A questo punto e forse il caso di esaminare qualcosa del resto del callback, la parte
CODICE
pnl = CatPanel(self.book)
       pnl.set_name(name)
       pnl.set_state(self.state)
       self.book.add(pnl, text=name)
       self.book.select(pnl)

definisce un nuovo CatPanel con assegnato il notebook quale suo parent, gli assegna alcune proprietà, lo aggiunge al notebook tramite il metodo add di quest'ultimo e lo rende quale corrente (self.book.select(pnl)).
Per altro, nella condizione di nuovo inserimento ('new') od anche in quella di modifica ('modify') sono disponibili dei comandi che possono modificare delle proprietà (solo l'immagine ;) ) del pannello corrente ... non è il caso che possa venir selezionato un'altro, questo stralcio di codice impedisce possa essere fatto
CODICE
for item in self.book.winfo_children():
           if str(item) != self.book.select():
               self.book.tab(item, state='disabled')

Noterete che qui utilizziamo la stessa istruzione self.book.winfo_children() per esaminarne ogni "item", che non è proprio il nostro pannello ma un oggetto, chiamato "window" nella documentazione del quale estrae la stringa descrittiva comparandola con quella restituita dal tab correntemente selezionato e se diversa disabilitando lo item

Quest'ultimo procedimento viene utilizzato anche in caso l'utente voglia modificare l'immagine o la descrizione del "gatto" e prema il pulsante "Modifica"
CODICE
def _on_modify(self):
       for item in self.book.winfo_children():
           if str(item) != self.book.select():
               self.book.tab(item, state='disabled')
       item = self.book.nametowidget(self.book.select())
       self.state = 'modify'
       item.set_state(self.state)
       self._evaluate_self()

nel cui callback vediamo come possiamo ottenere il nostro CatPanel, tramite l'istruzione "item = self.book.nametowidget(self.book.select())", ed operarci su.
Metodo "importante" nametowidget, utilizzata ogni volta si è dovuto operare con un CatPanel ... ovviamente, il pulsante "Modifica" non è disponibile se è selezionato un ListPanel.

Una volta entrati in modalità di inserimento o modifica dati, saranno disponibili i comandi "Immagine", "Salva" ed "Annulla" che permetteranno, rispettivamente, di inserire/cambiare l'immagine del gatto, salvare i dati oppure annullare il processo corrente, distruggendo il tab ed il CatPanel se nuovo inserimento. Si rimanda ai callback "_on_image(self)", "_on_save(self)" e "_on_cancel(self)" di MyApp per i relativi dettagli.

Lo OP del post da cui è scaturito il presente intendeva rappresentare una scheda per ogni suo gatto esistente ... discorso che va bene se si hanno solo pochi gatti ma nella macchina su cui sviluppo non ho gatti, ho però gattine tipo questa

png


e sono molte :lol: ho ritenuto opportuno, quindi, dare l'opportunità di aprire e chiudere la visualizzazione di una micetta ... ops, gatto a volontà dell'utente.

Nel contesto attuale dell'esposizione, diamo la precedenza alla chiusura della visualizzazione, ottenibile utilizzando il pulsante "Chiudi" che provocherà la chiusura del tab corrente ed il cui callback è interessante
CODICE
def _on_close(self):
       label = self.book.tab(self.book.select(), 'text')
       item = self.book.nametowidget(self.book.select())
       self.book.forget(self.book.select())
       if label == 'Elenco gatti':
           pub.unsubscribe(item.update_values, 'LISTUPDATED')
       else:
           data = item.get_data()
           try:
               pub.unsubscribe(item.update_values, 'CATUPDATED.' +
                               '_'.join(data[0].split()))
           except:
               pass
       item.destroy()
       self.update()
       self._evaluate_self()

nel codice vedete utilizzare metodi già noti per estrarre la label ed il pannello dal tag corrente e quindi eliminarlo dal notebook tramite il suo metodo (nuovo) "forget(item)", quindi distruggere il pannello ed aggiornare la finestra.
... invito, però, a porre molta attenzione alle istruzioni "unbsubscribe" di pub è necessario farle e prima di distruggere il pannello o avrete dei tentativi di notifica ad oggetti inesistenti che daranno errori non gestibili nella view o che necessiterebbero di un sistema intercettazione più complesso ed integrato nel model.

Per altro, la sottoscrizione dei pannelli avviene ad ogni loro apertura su dati esitenti, ovvero alla registrazione di nuovi dati con istruzioni tipo sotto
CODICE
pub.subscribe(pnl.update_values, 'LISTUPDATED')
       ...
       pub.subscribe(pnl.update_values,
                     'CATUPDATED.' + '_'.join(name.split()))

si vedano il callback "_on_list(self)" e "_on_save(self)" oltre ai metodi "view_cat(self, name)" e "cat_deleted(self, name)" di MyApp per dettagli in merito.

Memorizzazione della finestra


Una ulteriore richiesta del più volte citato OP era "salvare le schede aperte alla chiusura e riaprirle al nuovo avvio" ... beh, perché non farlo?
Ho quindi deciso di salvare le dimensioni della finestra e gli eventuali tabs aperti al momento della chiusura.

Salvataggio


per salvare lo "stato" della finestra nella sua inizializzazione ho dirottato l'handler del protocollo su di un apposito callback che interviene in fase di chiusura
CODICE
self.protocol('WM_DELETE_WINDOW', self._on_destroy)


e nel callback memorizzo in un dizionario i dati interessanti che salvo poi in un file json localizzato nella direttrice predefinita per i dati, chiudendo quindi la finestra
CODICE
def _on_destroy(self, evt=None):
       '''Memorizza lo stato corrente e chiude,'''
       conf_current = {}
       conf_current['width'] = self.winfo_reqwidth()
       conf_current['height'] = self.winfo_reqheight()
       labels = [self.book.tab(x, 'text') for x in
                 self.book.winfo_children()]
       conf_current['tabs'] = labels
       dirs = model.def_dirs()
       f_name = os.path.join(dirs['data'], 'appstate.json')
       try:
           with open(f_name, mode='w', encoding='utf-8') as f:
               json.dump(conf_current, f)
       except OSError:
           pass
       self.destroy()


Caricamento


per ricaricare i dati, verifico esista il file di salvataggio e nel caso apro il file e ricarico il dizionario con json, quindi ridefinisco le dimensioni della finestra e leggo le eventuali labels dei tag memorizzate, invocando il callback "_on_list()" od il metodo "view_cat(...)" di MyApp a seconda del caso.
CODICE
def _load_conf(self):
       dirs = model.def_dirs()
       f_name = os.path.join(dirs['data'], 'appstate.json')
       prec_conf = None
       if not os.path.exists(f_name) or not os.path.isfile(f_name):
           return
       try:
           with open(f_name, mode='r', encoding='utf-8') as f:
               prec_conf = json.load(f)
       except OSError:
           pass
       if prec_conf is None: return
       # ridimensiona la finestra e la centra
       l = self.winfo_screenwidth()
       a = self.winfo_screenheight()
       wx = prec_conf['width']
       wy = prec_conf['height']
       self.geometry('{}x{}+{}+{}'.format(wx, wy, (l-wx)//2, (a-wy)//2))
       # ricrea gli eventuali tabs
       labels = prec_conf['tabs']
       if not labels: return
       for t in labels:
           if t == 'Elenco gatti':
               self._on_list()
           else:
               self.view_cat(t)
       self.update()


... FINITO! :D

Giocattolo magari un po' rozzo, essendo la prima volta che sperimenti pypubsub e ttk.Notebbok, ma che ho trovato divertente realizzare, nel post successivo troverete il codice completo per provare quanto su esposto.

Ciao

Ed ora il codice completo.

Per poterlo vedere in azione dovrete creare un venv importando i moduli indicati nel primo post, quindi attivarlo e lanciare da terminale il comando
CODICE
python view.py


i moduli sono due e devono essere posizionati nella stessa directory (che, ovviamente, è la stessa in cui darete il comando di lacio)

model.py
CODICE
# -*- coding: utf-8 -*-

import appdirs
import os
from pubsub import pub
import sys
import shutil


def make_recursive_dir(pathname):
   dir_name = os.path.basename(pathname)
   dir_mater = os.path.dirname(pathname)
   if os.path.exists(pathname) and os.path.isdir(pathname):
       return
   else:
       make_recursive_dir(dir_mater)
       os.mkdir(pathname)    
   
def def_dirs():
   '''
Definisce le directory da utilizzare nella applicazione,
eventualmente non esistano le crea.'''
   default_dirs = {}
   appname = 'micetti'
   appauthor = 'nuzzopippo'

   default_dirs['data'] = appdirs.user_data_dir(appname, appauthor)
   default_dirs['cache'] = appdirs.user_cache_dir(appname, appauthor)
   default_dirs['log'] = appdirs.user_log_dir(appname, appauthor)
   try:
       for key in default_dirs.keys():
           make_recursive_dir(default_dirs[key])
   except OSError as e:
       exit(1)
   return default_dirs


class Micio:
   '''Un "Model" per un micio.'''
   def __init__(self, name='', image='', descr=''):
       self._name = name
       self._image = image
       self._description = descr

   @property
   def name(self):
       return self._name

   @name.setter
   def name(self, value):
       self._name = value

   @property
   def image(self):
       return self._image
   
   @image.setter
   def image(self, value):
       self._image = value

   @property
   def description(self):
       return self._description

   @description.setter
   def description(self, value):
       self._description = value

   def get_csv_data(self):
       if not self._name or not self._image or not self._description:
           raise ValueError('I dati del micio sono incompleti')
       return '{}|{}|{}'.format(self._name, self._image, self._description)

   def append_to_csv(self, file_name):
       if not self._name or not self._image or not self._description:
           raise ValueError('I dati del micio sono incompleti')
       try:
           f_exists = os.path.exists(file_name) and os.path.isfile(file_name)
           with open(file_name, 'a') as f:
               if f_exists:
                   f.write('\n')
               f.write(self.get_csv_data())
       except OSError as e:
           raise OSError(e)

   def get_cat(self):
       if not self._name or not self._image or not self._description:
           raise ValueError('I dati del micio sono incompleti')
       return [self._name, self._image, self._description]


class Cats:
   def __init__(self):
       dirs = def_dirs()
       if not dirs:
           raise RuntimeError('Errore di configurazione ambiente operativo')
       self.data = os.path.join(dirs['data'], 'cat.csv')
       self.d_cache = dirs['cache']
       self.cats = []
       self._load_data()
       pub.subscribe(self._requests, 'REQUEST')

   def _load_data(self):
       if os.path.exists(self.data) and os.path.isfile(self.data):
           with open(self.data, 'r') as f:
               self.cats = [Micio(*x.rstrip('\n').split('|')) for x in f.readlines()
                            if x!='\n']
   
   def _requests(self, message):
       request = message[0]
       if request == 'GETLIST':
           self._pub_list()
       elif request == 'ADDCAT':
           self._add(message[1:])
       elif request == 'SHOWCAT':
           self._show(message[1])
       elif request == 'UPDATECAT':
           self._update(message[1:])
       elif request == 'CATDELETE':
           self._delete(message[1])

   def _pub_list(self):
       pub.sendMessage('LISTUPDATED', message=self.cats)

   def _add(self, data):
       # verifica nome micio
       if data[0] in [x.name for x in self.cats]:
           raise ValueError('Nome micio già presente')
       # cheching del file immagine
       try:
           f_o = data[1]
           f_name = os.path.basename(f_o)
           f_d = os.path.join(self.d_cache, f_name)
           shutil.copy(f_o, f_d)
           data[1] = f_d
       except OSError as e:
           msg = 'Errore caching file immagine:\n' + repr(e)
           raise ValueError(msg)
       new = Micio(*data)
       new.append_to_csv(self.data)
       self.cats.append(new)
       self._pub_list()

   def _show(self, name):
       for item in self.cats:
           if item.name == name:
               group = 'CATUPDATED.' + '_'.join(name.split())
               msg = item.get_cat()
               pub.sendMessage(group, message=msg)
               break

   def _update(self, data):
       cat = Micio(*data)
       if not cat.name or not cat.image or not cat.description:
           raise ValueError('Gatto %s ha dati incompleti' % data[0])
       match = False
       for i in range(len(self.cats)):
           if self.cats[i].name == cat.name:
               # verifica e sostituzione dell'immagine
               if self.cats[i].image != cat.image:
                   try:
                       os.unlink(self.cats[i].image)
                       f_o = cat.image
                       f_name = os.path.basename(f_o)
                       f_d = os.path.join(self.d_cache, f_name)
                       shutil.copy(f_o, f_d)
                       cat.image = f_d
                   except OSError as e:
                       msg = 'Errore caching file immagine:<br>' + repr(e)
                       raise ValueError(msg)
               self.cats[i] = cat
               match = True
               break
       if not match:
           raise ValueError('Gatto %s inesistente' % data[0])
       with open(self.data, 'w') as f:
           f.writelines([x.get_csv_data()+'\n' for x in self.cats])
       self._show(cat.name)
       self._pub_list()
   
   def _delete(self, data):
       cat = None
       for item in self.cats:
           if item.name == data:
               cat = item
               break
       if cat is None:
           raise ValueError('Gatto %s inesistente' % data[0])
       self.cats.remove(cat)
       if not self.cats:
           os.unlink(self.data)
       else:
           with open(self.data, 'w') as f:
               f.writelines([x.get_csv_data()+'\n' for x in self.cats])
       self._pub_list()


view.py
CODICE
#-*- coding: utf-8 -*-

import tkinter as tk
from tkinter import ttk
from pubsub import pub
import model
import tkinter.messagebox as msgb
import tkinter.filedialog as fdlg
import tkinter.simpledialog as sdlg
from PIL import Image
from PIL import ImageTk
import os
import json


def get_tk_image(fname, wdg):
   image = Image.open(fname)
   img_x, img_y = image.size
   r_img = img_x / img_y
   r_wdg = wdg.winfo_width() / wdg.winfo_height()
   if r_wdg <= r_img:
       f_scala = (wdg.winfo_width()-10) / img_x
   else:
       f_scala = (wdg.winfo_height()-10) / img_y
   fx = int(img_x * f_scala)
   fy = int(img_y * f_scala)
   image = image.resize((fx, fy))
   return ImageTk.PhotoImage(image)
   

class CatPanel(ttk.Frame):
   '''Un pannello per mostrare un gatto.'''
   def __init__(self, parent, *args, **kwargs):
       super().__init__(parent, *args, **kwargs)
       self.parent = parent
       self.lbl_img = tk.Label(self, justify='center')
       self.lbl_img.grid(row=0, column=0, padx=10, pady=10, sticky='nsew')
       self.e_descr = tk.Entry(self, state='disabled')
       self.e_descr.grid(row=1, column=0, padx=10, pady=10, sticky='ew')

       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(0, weight=1)

       self.bind('<Configure>', self._on_resize)

       self.state = ''
       self.name = ''
       self.file = ''
       self.img = None

   def _on_resize(self, evt=None):
       self.update()
       self._show_image()
       
   def set_state(self, state):
       self.state = state
       if self.state == 'modify' or self.state == 'new':
           self.e_descr.configure(state='normal')
       else:
           self.e_descr.configure(state='disabled')

   def set_name(self, name):
       self.name = name

   def set_file(self, fname):
       self.file = fname
       self._show_image()

   def update_values(self, message):
       self.name = message[0]
       self.file = message[1]
       if self.state == '': self.e_descr.configure(state='normal')
       self.e_descr.delete(0, 'end')
       self.e_descr.insert('end', message[2])
       if self.state == '': self.e_descr.configure(state='disabled')
       self._show_image()
       self.update()

   def _show_image(self):
       if not self.file: return
       try:
           self.img = get_tk_image(self.file, self.lbl_img)
       except Exception as e:
           msgb.showerror('Caricamento immagine', repr(e))
           self.img = None
       self.lbl_img.configure(image=self.img)
       
   def get_data(self):
       descr = self.e_descr.get().replace('|', '')
       return [self.name, self.file, descr]

   
class ListPanel(ttk.Frame):
   '''Un pannello per elencare gatti... e gattine.'''
   def __init__(self, mater, parent, *args, **kwargs):
       super().__init__(parent, *args, **kwargs)
       self.parent = parent
       self.mater = mater
       self.data = []
       self.cat_list = tk.Listbox(self)
       self.cat_list.grid(row=0, column=0, rowspan=5,
                          padx=(5,2), pady=5, sticky='nsew')
       v_scroll = tk.Scrollbar(self, orient='vertical',
                               command=self.cat_list.yview)
       v_scroll.grid(row=0, column=1, rowspan=5,
                     padx=(2,5), pady=5, sticky='ns')
       self.cat_list.config(yscrollcommand=v_scroll.set)
       bt_open = tk.Button(self, text='Visualizza', command=self._on_open)
       bt_open.grid(row=0, column=2, padx=10, pady=5, sticky='ew')
       bt_del = tk.Button(self, text='Elimina', command=self._on_del)
       bt_del.grid(row=1, column=2, padx=10, pady=5, sticky='ew')
       self.vision = tk.BooleanVar()
       self.ck_vis = tk.Checkbutton(self, text='Anteprima', onvalue=True,
                                    offvalue=False, variable=self.vision,
                                    command=self._on_image)
       self.ck_vis.grid(row=2, column=2, padx=10, pady=10, sticky='w')
       self.lbl_tbn = tk.Label(self)
       self.lbl_tbn.grid(row=3, column=2, padx=10, pady=10, sticky='nsew')
       self.lbl_dida = tk.Label(self, justify='left')
       self.lbl_dida.grid(row=4, column=2, padx=10, pady=10, sticky='w')

       self.cat_list.bind('<ButtonRelease-1>', self._on_image)
       self.cat_list.bind('<Return>', self._on_image)

       self.grid_columnconfigure(2, weight=1)
       self.grid_rowconfigure(3, weight=1)

       self.img = None

   def _on_open(self):
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name: return
       for m in self.data:
           if m.name == sel_name:
               micio = m
       self.mater.view_cat(micio.name)
   
   def _on_del(self):
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name: return
       self.mater.cat_deleted(sel_name)
       msg = ['CATDELETE', sel_name]
       try:
           pub.sendMessage('REQUEST', message=msg)
       except ValueError as e:
           msgb.showerror('Avvenuto errore', repr(e))

   def _on_image(self, evt=None):
       if not self.vision.get():
           self.img = None
           self.lbl_tbn.configure(image='')
           self.lbl_dida.configure(text='')
           return
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name: return
       for m in self.data:
           if m.name == sel_name:
               micio = m
       self.lbl_dida.configure(text=micio.description)
       self.img = get_tk_image(micio.image, self.lbl_tbn)
       self.lbl_tbn.configure(image=self.img)
   
   def update_values(self, message):
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name:
           sel_name = None
       self.cat_list.delete(0, 'end')
       self.data = message
       for e in [x.name for x in self.data]:
           self.cat_list.insert('end', e)
       if sel_name is None: return
       index = 0
       for item in self.data:
           if item.name == sel_name:
               self.cat_list.index(index)
               break
           index += 1
   

class MyApp(tk.Tk):
   def __init__(self):
       super().__init__()
       self.protocol('WM_DELETE_WINDOW', self._on_destroy)
       self.title('Ron-Ron Land')
       try:
           self.cat_mng = model.Cats()
       except (RuntimeError, OSError) as e:
           msgb.showerror('Avvenuto errore', repr(e))
           self.destroy()
           exit(1)
       self.init_dir = os.path.expanduser('~')
       self.book = ttk.Notebook(self)
       self.book.grid(row=0, column=0, padx=10, pady=10, sticky='nsew')
       cmdnbp = tk.Frame(self)
       cmdnbp.grid(row=1, column=0, padx=5, pady=5, sticky='ew')
       self.bt_new = tk.Button(cmdnbp, text='Nuovo', command=self._on_new)
       self.bt_new.grid(row=0, column=0, padx=5, pady=5, sticky='ew')
       self.bt_change = tk.Button(cmdnbp, text='Modifica', command=self._on_modify)
       self.bt_change.grid(row=0, column=1, padx=5, pady=5, sticky='ew')
       self.bt_img = tk.Button(cmdnbp, text='Immagine', command=self._on_image)
       self.bt_img.grid(row=0, column=2 ,padx=5, pady=5, sticky='ew')
       self.bt_save = tk.Button(cmdnbp, text='Salva', command=self._on_save)
       self.bt_save.grid(row=0, column=3 ,padx=5, pady=5, sticky='ew')
       self.bt_cancel = tk.Button(cmdnbp, text='Annulla', command=self._on_cancel)
       self.bt_cancel.grid(row=0, column=4 ,padx=5, pady=5, sticky='ew')
       self.bt_close = tk.Button(cmdnbp, text='Chiudi', command=self._on_close)
       self.bt_close.grid(row=0, column=5 ,padx=5, pady=5, sticky='ew')
       for i in range(5):
           cmdnbp.grid_columnconfigure(i, weight=1, uniform='a')
       cmdp = tk.Frame(self)
       cmdp.grid(row=2, column=0, padx=5, pady=5, sticky='ew')
       self.bt_list = tk.Button(cmdp, text='Elenco', command=self._on_list)
       self.bt_list.grid(row=0, column=0, padx=5, pady=5, sticky='ew')
       bt_end = tk.Button(cmdp, text='Esci', command=self._on_destroy)
       bt_end.grid(row=0, column=1 ,padx=5, pady=5, sticky='ew')
       cmdp.grid_columnconfigure(0, weight=1, uniform='b')
       cmdp.grid_columnconfigure(1, weight=1, uniform='b')
       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(0, weight=1)
       self.book.bind('<<NotebookTabChanged>>', self._on_tab)
       self.update()
       l = self.winfo_reqwidth()
       self.minsize(l, l)

       self.state = ''
       self.init_dir = os.path.expanduser('~')
       self._load_conf()
       self._evaluate_self()

   def _on_tab(self, evt=None):
       if self.state: return
       if not self.book.tabs(): return
       text = self.book.tab(self.book.select(), 'text')
       if not text:
           self.bt_change.configure(state='disabled')
       elif text == 'Elenco gatti':
           self.bt_change.configure(state='disabled')
       else:
           self.bt_change.configure(state='normal')
       
   def _on_list(self):
       pnl = ListPanel(self, self.book)
       pub.subscribe(pnl.update_values, 'LISTUPDATED')
       self.book.add(pnl, text='Elenco gatti')
       pub.sendMessage('REQUEST', message=['GETLIST'])
       self.book.update()
       self._evaluate_self()

   def _on_new(self):
       msg = 'Inserire nome del micio'
       name = sdlg.askstring('Nuovo micio', msg, parent=self)
       if name is None: return
       if name == 'Elenco gatti':
           msgb.showwarning('Nuovo micio', 'Nome gatto inaccettabile.',
                            parent=self)
           return
       labels = [self.book.tab(x, 'text') for x in
                 self.book.winfo_children()]
       if name in labels:
           msgb.showwarning('Nuovo micio', 'Micio già presente e visualizzato',
                            parent=self)
           return            
       self.state = 'new'
       pnl = CatPanel(self.book)
       pnl.set_name(name)
       pnl.set_state(self.state)
       self.book.add(pnl, text=name)
       self.book.select(pnl)
       for item in self.book.winfo_children():
           if str(item) != self.book.select():
               self.book.tab(item, state='disabled')
       self._evaluate_self()

   def _on_modify(self):
       for item in self.book.winfo_children():
           if str(item) != self.book.select():
               self.book.tab(item, state='disabled')
       item = self.book.nametowidget(self.book.select())
       self.state = 'modify'
       item.set_state(self.state)
       self._evaluate_self()

   def _on_image(self):
       f_name = fdlg.askopenfilename(parent=self,
                                     initialdir=self.init_dir,
                                     title='Seleziona immagine')
       if not f_name: return
       # memorizza la direttrice del file quale corrente
       if os.path.isfile(f_name):
           self.init_dir = os.path.dirname(f_name)
       item = self.book.nametowidget(self.book.select())
       item.set_file(f_name)

   def _on_save(self):
       item = self.book.nametowidget(self.book.select())
       data = item.get_data()
       if self.state == 'new':
           msg = ['ADDCAT'] + data
       elif self.state == 'modify':
           msg = ['UPDATECAT'] + data
       else:
           return
       try:
           pub.sendMessage('REQUEST', message=msg)
       except (ValueError, OSError) as e:
           msgb.showerror('Avvenuto errore', repr(e))
           return
       item.set_state('')
       if self.state == 'new':    
           pub.subscribe(item.update_values, 'CATUPDATED.' +
                         '_'.join(data[0].split()))
       for item in self.book.winfo_children():
           self.book.tab(item, state='normal')
       self.state = ''
       self._evaluate_self()

   def _on_cancel(self):
       if self.state == 'new':
           item = self.book.nametowidget(self.book.select())
           self.book.forget(self.book.select())
           item.destroy()
           self.update()
       else:
           item = self.book.nametowidget(self.book.select())
           item.set_state('')
           data = item.get_data()
           msg = ['SHOWCAT', data[0]]
           pub.sendMessage('REQUEST', message=msg)
       for item in self.book.winfo_children():
           self.book.tab(item, state='normal')
       self.book.select(self.book.winfo_children()[-1])
       self.state = ''
       self._evaluate_self()

   def _on_close(self):
       label = self.book.tab(self.book.select(), 'text')
       item = self.book.nametowidget(self.book.select())
       self.book.forget(self.book.select())
       if label == 'Elenco gatti':
           pub.unsubscribe(item.update_values, 'LISTUPDATED')
       else:
           data = item.get_data()
           try:
               pub.unsubscribe(item.update_values, 'CATUPDATED.' +
                               '_'.join(data[0].split()))
           except:
               pass
       item.destroy()
       self.update()
       self._evaluate_self()

   def _on_destroy(self, evt=None):
       '''Memorizza lo stato corrente e chiude,'''
       conf_current = {}
       conf_current['width'] = self.winfo_reqwidth()
       conf_current['height'] = self.winfo_reqheight()
       labels = [self.book.tab(x, 'text') for x in
                 self.book.winfo_children()]
       conf_current['tabs'] = labels
       dirs = model.def_dirs()
       f_name = os.path.join(dirs['data'], 'appstate.json')
       try:
           with open(f_name, mode='w', encoding='utf-8') as f:
               json.dump(conf_current, f)
       except OSError:
           pass
       self.destroy()
           
   def _evaluate_self(self):
       self.bt_new.configure(state='disabled')
       self.bt_change.configure(state='disabled')
       self.bt_img.configure(state='disabled')
       self.bt_save.configure(state='disabled')
       self.bt_cancel.configure(state='disabled')
       self.bt_close.configure(state='disabled')
       self.bt_list.configure(state='disabled')
       if self.state == 'modify' or self.state == 'new':
           self.bt_img.configure(state='normal')
           self.bt_save.configure(state='normal')
           self.bt_cancel.configure(state='normal')
       elif self.state == '':
           self.bt_new.configure(state='normal')
           if self.book.tabs():
               self.bt_close.configure(state='normal')
           labels = [self.book.tab(x, 'text') for x in
                     self.book.winfo_children()]
           if 'Elenco gatti' not in labels:
               self.bt_list.configure(state='normal')

   def view_cat(self, name):
       labels = [self.book.tab(x, 'text') for x in
                 self.book.winfo_children()]
       if name in labels:
           msgb.showinfo('Attento', 'Micio già visualizzato')
           return
       pnl = CatPanel(self.book)
       pnl.set_state('')
       self.book.add(pnl, text=name)
       self.book.select(pnl)
       pub.subscribe(pnl.update_values,
                     'CATUPDATED.' + '_'.join(name.split()))
       msg = ['SHOWCAT', name]
       pub.sendMessage('REQUEST', message=msg)
       self._evaluate_self()
   
   def cat_deleted(self, name):
       labelitem = None
       for i in self.book.winfo_children():
           label = self.book.tab(i, 'text')
           if label == name:
               labelitem = i
               break
       if labelitem is None: return
       item = self.book.nametowidget(labelitem)
       self.book.forget(labelitem)
       data = item.get_data()
       pub.unsubscribe(item.update_values, 'CATUPDATED.' +
                       '_'.join(data[0].split()))
       item.destroy()

   def _load_conf(self):
       dirs = model.def_dirs()
       f_name = os.path.join(dirs['data'], 'appstate.json')
       prec_conf = None
       if not os.path.exists(f_name) or not os.path.isfile(f_name):
           return
       try:
           with open(f_name, mode='r', encoding='utf-8') as f:
               prec_conf = json.load(f)
       except OSError:
           pass
       if prec_conf is None: return
       # ridimensiona la finestra e la centra
       l = self.winfo_screenwidth()
       a = self.winfo_screenheight()
       wx = prec_conf['width']
       wy = prec_conf['height']
       self.geometry('{}x{}+{}+{}'.format(wx, wy, (l-wx)//2, (a-wy)//2))
       # ricrea gli eventuali tabs
       labels = prec_conf['tabs']
       if not labels: return
       for t in labels:
           if t == 'Elenco gatti':
               self._on_list()
           else:
               self.view_cat(t)
       self.update()



if __name__ == '__main__':
   app = MyApp()
   app.mainloop()


Mi ci son divertito, provando diverse varianti, tra cui una in cui model non era neanche importato da view ... magari interesserà qualcuno.

Ciao :)

Edited by nuzzopippo - 23/10/2021, 08:25
view post Posted: 3/10/2021, 11:00 [Python] Messaggi per un ttk.Notebook - Appunti di apprendimento
I miei saluti ad eventuali, improbabili, lettori che inciampino in questo scritto. Come sta diventando "solito" nei rari argomenti che tratto qui, in seguito viene realizzata una idea nata in un pos, in forumpython.it, nel quale sono intervenuto e che mi ha intrigato abbastanza da farmi rimandare un altro post che ho appeso per altro argomento.

La problematica posta


Sostanzialmente lo OP del post origine dell'idea, diceva di aver realizzato una applicazione per mostrare dei micetti in un tab-panel tkinter (ovvero un tkinter.ttk.Notebook), riusciva a visualizzarne qualcuno da codice e voleva sapere :
1° - come aggiungere qualche gatto senza riscrivere il codice;
2° - come presentare, all'avvio dell'applicazione, le schede presenti nella precedente sessione.
Lo OP voleva sapere se fosse possibile o dovesse rinunciare all'idea.

Naturalmente, che tali banali operazioni fossero possibili è stato subito detto al proponente e, dato che quest'ultimo non ha proposto neanche una sola riga di codice, sono state proposti, da utenti più esperti di me, vari suggerimenti relativi alla persistenza dei dati ed indicazioni circa il possibile utilizzo del pattern MVC nel design della applicazione.

La mia idea di base


Come detto, la problematica mi ha intrigato ma non certo per l'applicazione, di per se semplice, bensì, inizialmente, per il pattern MVC proposto nel corso dei post, solitamente non utilizzo tale pattern nel mio sviluppo.
La semplicità del modello dati della discussione, una immagine e del testo, e della applicazione stessa, è una buona "occasione" per sperimentare una implementazione "tipo" MVC, dico "tipo" perché non mi è riuscito ad inquadrare per bene l'aspetto "controller", dato che interviene una notevole sovrapposizione di funzionalità della GUI.

Pur se inafficace, riferendo al pattern MVC, per il quale NON mi è riuscito ad implementare un "Controller" degno del suo nome, realizzare il giocattolo sotto è stato comunque un esperimento per me interessante, magari potrebbe essere anche utile qualcuno perciò lo posto.

Considerazioni preliminari


Come innanzi esposto, l'applicazione da realizzarsi deve esporre delle immagini (nei tabs di un notebook) e deve essere in grado di memorizzare alla sua chiusura le immagini correntemente visualizzate e ricaricarle al suo successivo avvio.
Ovviamente, ho escluso a priori il metodo adottato dallo OP di definire le immagini direttamente nello script, in presenza di numerose immagini l'esposizione sarebbe problematica. Ho scelto, quindi, di realizzare una applicazione in grado di definire, selezionare, modificare ed eliminare gli elementi (mici) da conservare oltre che aprire e chiudere la visualizzazione di alcuni degli elementi memorizzati.

Riguardo alla memorizzazione dei mici, data la semplicità strutturale dei dati, ho deciso di registrali in un semplice file di testo formattato con notazine di tipo CSV senza indicazione del nome dei campi (name, image, description) per la cui definizione ho scelto una selezione posizionale. La "posizione" del file di testo è stabilita in una direttrice nella home utente dedicata alla applicazione.

Una problematica riveniente dalla decisa metodologia di definizione, modifica e cancellazione dei dati, e l'iterazione tra eventuali operazioni di modifica/cancellazione dati con le scelte di visualizzazione effettuate dall'utente e l'elenco dei dati necessario per la consultazione utente, per tale evenienza ho deciso inizialmente di sperimentare l'utilizzo del pattern "Observer", per poi passare al pattern "Publish/subscriber" trovando che ben si presta alla casistica.

Una ulteriore problematica è data dalla "dimensione" della immagine, dalle tipologie supportate e dalla loro "posizione", nel caso derivino da supporti rimovibili.

Il venv minimale necessario


Le considerazioni su esposte mi hanno portato ad utilizzare il modulo "appdirs" per stabilire una directory per lo storage dei dati ed una directory di caching delle immagini selezionate, perché siano comunque disponibili, per quanto possibile in modalità standardizzata per il s.o. in uso.
Altro modulo utilizzato per l'implementazione del pattern "Publish/subscriber" è "pypubsub", da me scoperto nell'ottimo testo "Capire wxpython" del Poligneri.
In ultimo, ho scelto di utilizzare "pillow" per la "gestione" delle immagini.
Il tutto installato in un venv con questa configurazione minimale
CODICE
(test_mvc) PS C:\Users\DESKTOP-User\venvs\test_mvc> python -m pip list
Package    Version
---------- -------
appdirs    1.4.4
Pillow     8.3.2
pip        21.2.4
Pypubsub   4.0.3
setuptools 56.0.0
(test_mvc) PS C:\Users\DESKTOP-User\venvs\test_mvc>

oltre alcune librerie di base, presenti di default.

Testando, per la prima volta, il codice contemporaneamente nei sistemi operativi Linux e Windows (10) ho rilevato alcune lievi differenze funzionali nei due sistemi, in particolare la differente resa del modulo appdirs per ottenere le directory "di default" per le esigenza applicative ... particolare che ritengo utile da tener presente.

i brevi test sottostanti
CODICE
test_mvc) NzP:~$ python
Python 3.8.10 (default, Jun  2 2021, 10:49:15)
[GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import appdirs
>>> default_dirs = {}
>>> appname = 'micetti'
>>> appauthor = 'nuzzopippo'
>>> default_dirs['data'] = appdirs.user_data_dir(appname, appauthor)
>>> default_dirs['cache'] = appdirs.user_cache_dir(appname, appauthor)
>>> default_dirs['log'] = appdirs.user_log_dir(appname, appauthor)
>>> for key in default_dirs.keys():
...     print(key, ' - ', default_dirs[key])
...
data  -  /home/nuzzopippo/.local/share/micetti
cache  -  /home/nuzzopippo/.cache/micetti
log  -  /home/nuzzopippo/.cache/micetti/log
>>>



(test_mvc) PS C:\Users\DESKTOP-User\venvs\test_mvc> python
Python 3.9.6 (tags/v3.9.6:db3ff76, Jun 28 2021, 15:26:21) [MSC v.1929 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> import appdirs
>>> default_dirs = {}
>>> appname = 'micetti'
>>> appauthor = 'nuzzopippo'
>>> default_dirs['data'] = appdirs.user_data_dir(appname, appauthor)
>>> default_dirs['cache'] = appdirs.user_cache_dir(appname, appauthor)
>>> default_dirs['log'] = appdirs.user_log_dir(appname, appauthor)
>>> for key in default_dirs.keys():
...     print(key, ' - ', default_dirs[key])
...
data  -  C:\Users\DESKTOP-User\AppData\Local\nuzzopippo\micetti
cache  -  C:\Users\DESKTOP-User\AppData\Local\nuzzopippo\micetti\Cache
log  -  C:\Users\DESKTOP-User\AppData\Local\nuzzopippo\micetti\Logs
>>>

rendono evidente una differenza sostanziale : Windows raccoglie per "autore" le applicazioni mentre linux ignora allegramente l'autore e considera le applicazioni di per se ... dopo tutto il softare "libero" è la natura stessa di Linux, no?

Comunque, non considerare questa piccola differenza, da me completamente ignorata in prima stesura, comporterebbe la non-funzionalità del codice scritto per linux in windows, dato che in linux bisognerebbe in sostanza creare un singolo livello di directory o, al più, fare attenzione all'ordine per la direttrice di log, mentre in windows, se l'autore non esiste, bisogna creare sistematicamente due ... una volta accortomi del problema, lo ho affrontato creando ricorsivamente le directory dalla più esterna alla più interna; il codice :
CODICE
def make_recursive_dir(pathname):
   dir_name = os.path.basename(pathname)
   dir_mater = os.path.dirname(pathname)
   if os.path.exists(pathname) and os.path.isdir(pathname):
       return
   else:
       make_recursive_dir(dir_mater)
       os.mkdir(pathname)    
   
def def_dirs():
   '''
Definisce le directory da utilizzare nella applicazione,
eventualmente non esistano le crea.'''
   default_dirs = {}
   appname = 'micetti'
   appauthor = 'nuzzopippo'

   default_dirs['data'] = appdirs.user_data_dir(appname, appauthor)
   default_dirs['cache'] = appdirs.user_cache_dir(appname, appauthor)
   default_dirs['log'] = appdirs.user_log_dir(appname, appauthor)
   try:
       for key in default_dirs.keys():
           make_recursive_dir(default_dirs[key])
   except OSError as e:
       exit(1)
   return default_dirs

Il cuore della "soluzione" è la funzione "make_recursive_dir(pathname)" il cui algoritmo è semplicissimo : se il "pathname" esiste ritorna senza far niente, altrimenti richiama se stessa sulla direttrice madre ed al ritorno crea la directory di competenza.
La funzione "def_dirs()" provvede a definire il nome dell'autore (io, naturalmente) e della applicazione da dare in pasto ad appdirs per ricavare le direttrici applicative interessanti da dare in pasto a make_recursive_dir.
Entrambe le funzioni sono definite nel modulo "model.py", che fa un uso molto maggiore del file-sistem rispetto ai processi grafici.

Il "Modello" dati


beh ... chiamiamolo modello, sostanzialmente sono due classi : "Micio" (mi perdonino gli anglofoni) e "Cats".

Micio è una unità elementare di informazione, un record, se vogliamo vederlo così, con tre proprietà pubbliche "name", "image" e "description" definite con i relativi decoratori "@property e @setter" e dei metodi per lo storage dei dati e per la restituzione degli stessi in formato stile csv o lista, l'invocazione di detti utilimi due metodi su dati incompleti provoca un "ValueError".
Lo storage dei dati avviene per invocazione esterna alla classe, l'operazione effettuata è un "append" di una stringa dati in formato CSV ad un file passato nella invocazione.

Cats è la vera unità funzionale del "modello", provvede a storage e lettura dei dati ed al caching delle immagini associate, intercetta e provvede alle richieste di inserimeto, modifica, cancellazione e visualizzazione dati.

Per le particolarità funzionali "lato modello" si rimanda al prosieguo del post ed al codice, nell'insieme piuttosto semplice, le poche cose che potrebbero essere interessanti è la modalità di storage dei dati, che è un semplice file di testo in formato CSV improprio, non essendo codificato in ASCII e non avendo riga delle intestazioni e la modalità di caching delle immagini : "Cats" provvede a copiare il file immagine collegato ai dati ricevuti nella directory di caching definita per l'applicazione e modificare il collegamento nei dati, provvede, inoltre ad eliminare le copie di immagini sostituite o appartenenti a dati eliminati. Inoltre la classe controlla non vi siano gue "gatti" con nome uguale, mantenendo l'ordine di inserimento effettuato.

La "View"


La "View" è di per se strutturalmente semplice, come potete vedere in figura

png



Sub-classamento di tkinter.Tk, sostanzialmente, al suo avvio non è altro che un ttk.Notebook vuoto ed alcuni bottoni, per la maggior parte disabilitati.
Dato che uno dei quesiti posti dallo OP riguardava il "come inserire un nuovo tab" in un ttk.Notebook e che personalmente non ho mai utilizzato in precedenza tale controllo (vado sempre sul "semplice", se posso) ho voluto far si che tutte le operazioni "grafiche" riguardassero elementi sostanzialmente appartenenti a tale controllo, dato che ho testato lo script anche in ambiente windows, ecco come appare al primo avvio in tale sistema operativo

png



Come potete vedere nelle immagini soprastanti, la finestra in se dispone di un totale di otto pulsanti di comando, attivi o meno secondo il contesto corrente della finestra, a parte l'ultimo comando "Esci" che rimane comunque attivo ed esegue la chiusura della finestra e dell'applicazione, senza chiedere conferme ANCHE se è in corso una definizione/modifica dati ... ovviamente, le variazioni inserite e non salvate, nel caso, andranno perse.

Gli altri sei pulsanti di comando avranno effetto o direttamente sul notebook ovvero su uno dei suoi "pannelli" e, tramite alcune proprietà di questi ultimi, sui dati.
Per definire i pannelli ho implementato due classi una per presentare una lista dei "Mici" (cioè il nome dei gatti o che si vuole dare ad una immagine) ed una per "Presentare" un gatto, ossia una immagine ed un commento dedicato.

Il pannello "Lista"


Richiamabile tramite la pressione del tato "Elenco" presenta un elenco dei nomi dei mici (o immagini) registrati e qualche comando

png


Può essere presente solo un oggetto di questo tipo in una sessione, penso che sia giusto il momento di presentare il codice che definisce tale pannello
CODICE
class ListPanel(ttk.Frame):
   '''Un pannello per elencare gatti... e gattine.'''
   def __init__(self, mater, parent, *args, **kwargs):
       super().__init__(parent, *args, **kwargs)
       self.parent = parent
       self.mater = mater
       self.data = []
       self.cat_list = tk.Listbox(self)
       self.cat_list.grid(row=0, column=0, rowspan=5,
                          padx=(5,2), pady=5, sticky='nsew')
       v_scroll = tk.Scrollbar(self, orient='vertical',
                               command=self.cat_list.yview)
       v_scroll.grid(row=0, column=1, rowspan=5,
                     padx=(2,5), pady=5, sticky='ns')
       self.cat_list.config(yscrollcommand=v_scroll.set)
       bt_open = tk.Button(self, text='Visualizza', command=self._on_open)
       bt_open.grid(row=0, column=2, padx=10, pady=5, sticky='ew')
       bt_del = tk.Button(self, text='Elimina', command=self._on_del)
       bt_del.grid(row=1, column=2, padx=10, pady=5, sticky='ew')
       self.vision = tk.BooleanVar()
       self.ck_vis = tk.Checkbutton(self, text='Anteprima', onvalue=True,
                                    offvalue=False, variable=self.vision,
                                    command=self._on_image)
       self.ck_vis.grid(row=2, column=2, padx=10, pady=10, sticky='w')
       self.lbl_tbn = tk.Label(self)
       self.lbl_tbn.grid(row=3, column=2, padx=10, pady=10, sticky='nsew')
       self.lbl_dida = tk.Label(self, justify='left')
       self.lbl_dida.grid(row=4, column=2, padx=10, pady=10, sticky='w')

       self.cat_list.bind('<ButtonRelease-1>', self._on_image)
       self.cat_list.bind('<Return>', self._on_image)

       self.grid_columnconfigure(2, weight=1)
       self.grid_rowconfigure(3, weight=1)

       self.img = None

   def _on_open(self):
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name: return
       for m in self.data:
           if m.name == sel_name:
               micio = m
       self.mater.view_cat(micio.name)
   
   def _on_del(self):
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name: return
       self.mater.cat_deleted(sel_name)
       msg = ['CATDELETE', sel_name]
       try:
           pub.sendMessage('REQUEST', message=msg)
       except ValueError as e:
           msgb.showerror('Avvenuto errore', repr(e))

   def _on_image(self, evt=None):
       if not self.vision.get():
           self.img = None
           self.lbl_tbn.configure(image='')
           self.lbl_dida.configure(text='')
           return
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name: return
       for m in self.data:
           if m.name == sel_name:
               micio = m
       self.lbl_dida.configure(text=micio.description)
       self.img = get_tk_image(micio.image, self.lbl_tbn)
       self.lbl_tbn.configure(image=self.img)
   
   def update_values(self, message):
       sel_name = self.cat_list.get(tk.ANCHOR)
       if not sel_name:
           sel_name = None
       self.cat_list.delete(0, 'end')
       self.data = message
       for e in [x.name for x in self.data]:
           self.cat_list.insert('end', e)
       if sel_name is None: return
       index = 0
       for item in self.data:
           if item.name == sel_name:
               self.cat_list.index(index)
               break
           index += 1

Parliamo un po' di questa classe. Noterete che per definire il pannello ho sub-classato un ttk.Frame invece di un normale frame tkinter, tale scelta è stata fatta senza motivazioni precise, solo perché ttk.Frame, come il ttk.Notebook, supporta la proprietà "style" che però NON utilizzo nel codice, per il resto l'ho trattato come un normale Frame, applicando i metodi relativi, che sono supportati.

Per altro, l'oggetto "ListPanel" è fornito di alcune capacità proprie di azione nei riguardi di se stesso, della finestra-madre e dei dati stessi, tali "capacità" ruotano attorno al contenuto di "self.cat_list", un contenitore dei "nomi" dei gatti registrati (o meglio delle immagini registratte) che al primo avvio della applicazione è vuoto.
Quando viene sezionato un "nome" nella cat_list può :
  • Invocare la visualizzazione dei dati a "self.mater", la finestra-madre (non il Notebook), che è un parametro di istanza obbligatorio, ciò viene effettuato tramite la pressione del tasto "Visualizza" (bt_open);
  • pubblicare una richiesta di cancellazione dei dati riferiti al "nome", di cui viene notiziata self.mater, questa azione viene effettuata tramice il pulsante "Elimina" (bt_del);
  • ottenersi una anteprima dei dati se la checkbox "self.ck_vis" è selezionata.
Per i dettagli "operativi" si guardino i metodi di callback nel codice sopra, intanto, una immagine con un ListPanel in piena funzionalità in ambiente windows

png
Il micione nella foto è il mio Honey ;)



Si accenna, ora, alla "strana" modalità implementata per la "richiesta" di cancellazione dati effettuata con questo stralcio di codice del callback "_on_del(self)"
CODICE
msg = ['CATDELETE', sel_name]
       try:
           pub.sendMessage('REQUEST', message=msg)
       except ValueError as e:
           msgb.showerror('Avvenuto errore', repr(e))

Ne parlerò più diffusamente nella parte terminale del post ma intanto notate che viene definita una lista "msg" con una stringa in maiuscolo ed un oggetto sel_name che altri non è che il nome corrente selezionato nell'elenco del gatti, viene quindi invocato un metodo "sendMessage(...)" di un fantomatico "pub" che si importa da pubsub (quel pypubsub all'inizio del post) : tale metodica serve a comunicare a chi è in ascolto l'intenzione di cancellare un dato "gatto", ossia un record, ed ha la sua controparte nel metodo di classe "update_values(self, message)" che viene registrato per ricevere un "message" sulla cui base provvede ad aggiornare l'elenco dei gatti, si legga il codice della classe per dettagli.

Al momento, è più interessante la funzione di callback del pulsante "Elenco" della finestra principale:
CODICE
def _on_list(self):
       pnl = ListPanel(self, self.book)
       pub.subscribe(pnl.update_values, 'LISTUPDATED')
       self.book.add(pnl, text='Elenco gatti')
       pub.sendMessage('REQUEST', message=['GETLIST'])
       self.book.update()
       self._evaluate_self()

Vi sono varie cose in queste sette righe di codice, in primo luogo osserviamo che istanziamo un oggetto "ListPanel" dandogli in pasto la finestra stessa quale "mater" ed il ttk.Notebook quale "parent", o meglio master, quindi si fa il refresh del notebook e si riesamina lo stato della finestra.
Si noti il parametro "text='Elenco gatti'" del metodo add() del notebook, con tale parametro si definisce l'etichetta di un tab (un sub-pannello del notebook), proprietà che, nell'ambito del programma, ho costruttivamente fatto in modo sia unica per utilizzarla quale discriminante.

... riprenderemo poi le due righe con "pub.etc" al momento è sufficiente sapere che con la prima delle righe iscrive in un gruppo (LISTUPDATED) il metodo "update_value" del ListPanel istanziato quale observer mentre il secondo pubblica una richiesta di dati.
Invito, però a leggere il codice del metodo "update_values(self, message)" di ListPanel, si noterà che "message" viene assegnato così com'è alla variabile di istanza "self.data" e dalle manipolazioni successive, ed ancor di più da quelle nel callback "_on_image(self, evt=None)", credo sarà evidente che il messaggio altro non è che una lista di oggetti "Micio" dei quali vengono utilizzate le proprietà definite pur se nel modulo tali classi non sono conosciute.

Per altro, agendo sul pulsante "Visualizza" il suo callback "_on_open(self)" invierà direttamente alla finestra madre (self.mater) una richiesta di visualizzare il gatto correntemente selezionato.

Il pannello del micio


Ricevendo la richiesta di visualizzazione di un gatto, la finestra madre verificherà, in base al nome, che il gatto non sia già visualizzato, in caso contrario provvede ad istanziare un "CatPanel" ed a mostrarlo

png


Nel caso in specie la mia, disastrata, attuale dispobilità hardware, al momento non ho molte immagini per esemplificare :D

CatPanel ha funzionalità ben diverse dal precedente, essendo mirato alla definizione, rappresentazione e modifica di un singolo record (gatto - Micio), intanto il codice della classe :
CODICE
class CatPanel(ttk.Frame):
   '''Un pannello per mostrare un gatto.'''
   def __init__(self, parent, *args, **kwargs):
       super().__init__(parent, *args, **kwargs)
       self.parent = parent
       self.lbl_img = tk.Label(self, justify='center')
       self.lbl_img.grid(row=0, column=0, padx=10, pady=10, sticky='nsew')
       self.e_descr = tk.Entry(self, state='disabled')
       self.e_descr.grid(row=1, column=0, padx=10, pady=10, sticky='ew')

       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(0, weight=1)

       self.bind('<Configure>', self._on_resize)

       self.state = ''
       self.name = ''
       self.file = ''
       self.img = None

   def _on_resize(self, evt=None):
       self.update()
       self._show_image()
       
   def set_state(self, state):
       self.state = state
       if self.state == 'modify' or self.state == 'new':
           self.e_descr.configure(state='normal')
       else:
           self.e_descr.configure(state='disabled')

   def set_name(self, name):
       self.name = name

   def set_file(self, fname):
       self.file = fname
       self._show_image()

   def update_values(self, message):
       self.name = message[0]
       self.file = message[1]
       if self.state == '': self.e_descr.configure(state='normal')
       self.e_descr.delete(0, 'end')
       self.e_descr.insert('end', message[2])
       if self.state == '': self.e_descr.configure(state='disabled')
       self._show_image()
       self.update()

   def _show_image(self):
       if not self.file: return
       try:
           self.img = get_tk_image(self.file, self.lbl_img)
       except Exception as e:
           msgb.showerror('Caricamento immagine', repr(e))
           self.img = None
       self.lbl_img.configure(image=self.img)
       
   def get_data(self):
       descr = self.e_descr.get().replace('|', '')
       return [self.name, self.file, descr]


Questo "pannello" è l'elemento dedicato alla definizione di parte dei dati, essenzialmente della descrizione accompagnante una immagine assegnata dall'esterno, inseribile e modificabile in una entry (self.e_descr) accessibile a seconda dello stato impostato per il controllo tramite il metodo "set_state(self, state)", credo che leggere il codice sia sufficiente per i particolri.

png



CatPanel è un oggetto piuttosto semplice, credo che leggere il codice sopra (e magari un po' di docs base, se proprio serve) sia sufficiente a chiarirsi le idee circa la sua funzionalità. Forse, un pochino più interessante per gli iniziandi è guardarsi :

La visualizzazione delle immagini


Tanto in CatPanel quanto in ListPanel utilizzano una semplice tinker.Label, utilizzando la proprietà "image" di questo widget.
Le immagini vengono riscalate alla dimensione della label, nel caso di "CatPanel" anche al ridimensionamento della finestra e trasformate in PhotoImage. Per tali operazioni ho utilizzato pillow ed il suo modulo ImageTk. Naturalmente, dato che più di un elemento necessita di tali operazioni, ho ritenuto di definire una funzione apposita che ricevento il nome del file e la label provvede ad elaborare il tutto e restituire la PhotoImage occorrente : get_tk_image(fname, wdg), il codice:
CODICE
def get_tk_image(fname, wdg):
   image = Image.open(fname)
   img_x, img_y = image.size
   r_img = img_x / img_y
   r_wdg = wdg.winfo_width() / wdg.winfo_height()
   if r_wdg <= r_img:
       f_scala = (wdg.winfo_width()-10) / img_x
   else:
       f_scala = (wdg.winfo_height()-10) / img_y
   fx = int(img_x * f_scala)
   fy = int(img_y * f_scala)
   image = image.resize((fx, fy))
   return ImageTk.PhotoImage(image)


... il post sta diventando molto lungo, lo spezzo qui, nel prossimo post si tratterà la gestione dei dati e dei tabs del notebook.

I miei saluti ad improbabili lettori :)

Edited by nuzzopippo - 13/10/2021, 11:16
view post Posted: 5/7/2021, 08:36 [Python] GUI & PDF a passetti 2 : testo e csv - Appunti di apprendimento

TextUtility - 2a parte


Riprendiamo il post da dove l'avevamo lasciato, cioè ai metodi di utilizzo di quanto realizzato più coerenti con le impostazioni dati, che abbiamo visto sintetizzata nella tabella di controlli ed uso precedente (un paio sono stati omessi, vedremo poi).

Dal post sul viewer abbiamo visto che, a discrezione dell'utilizzatore, può essere estratto il testo da una o più pagine alla volta e da più documenti in successione aggiungendolo al contenuto del controllo self.pdf_text, di una istanza formato "text" di TextUtility, editato e salvato, oppure manipolato per produrre dati in formato CSV.
La prima casistica (salvataggio testo) è decisamente banale, l'utente carica il testo che gli serve, con operazioni di edithing ordinario cancella eventuali parti non interessanti, corregge eventuali svarioni quindi lo salva come testo, oppure seleziona le parti interessanti per copiarle con i comandi del sistema in uso per poiincollarli nel word-processor preferito, e chiude li ... metodologia presumibilmente utile per estrazioni OCR, i PDF non da immagini permettono di farlo direttamente.

... Non è detto sia così banale organizzare i dati derivanti dall'estrazione di testo di uno o più file pdf in quelle tabelle "piatte" che caratterizzano la codifica CSV, tali tabelle, per essere lette, devono avere un numero di campi costante, con magari una intestazione, una estrazione OCR è certo che non darà tale "costanza", inoltre, dato l'alto numero di "spurie" che si ottengo è inadatta a tale compito, salvo casi eccezionali, ma anche parlando di "estrazione diretta" del testo (con pdftotext per ora) detta "costanza" non è garantita per niente: la lunghezza del testo si fermerà all'ultimo dato utile, con conseguenti possibili problemi di indicizzazione in fase di "lettura dati", inoltre gli allineamenti, tanto per riga quanto per colonna, non sempre sono perfettamente coerenti.
Pertanto, la prima operazione da effettuare è l'estrazione del testo da trattare, magari con contestuale eliminazione delle parti di testo ridondanti, tipo intestazione di colonna ripeture su varie pagine, eliminazione di righe testo non pertinenti ai dati, etc., solo dopo aver ottenuto il testo che si vuol effettivamente trattare dare avvio al una sessione di definizione dei dati csv premendo il pulsante "Attiva CSV" (ovvero self.bt_edit) nel cui callback (_on_edit(self)) verrà avviata una ulteriore istanza di TextUtility di tipo "csv", memorizzata nella variabile di istanza "self.csv_win"*, verificato che il corrente carattere separatore non sia presente nel testo, in tal caso si ricerà un warning, ed elaborato il testo presente in self.pdf_text valutando il numero di caratteri* presenti nella riga di testo più lunga ed aggiungendo degli spazi alle altre righe in modo che raggiungano la stessa dimensione.

* In questo prototipo non si è sottilizzato a equilibrare completamente il codice con accorgimenti quali verificare l'esistenza di self.csv_win al momento del trasferimento dati, ovvero ricalibrazione del testo ad eventuali aggiunte successive all'avvio di una sessione di definizione dati. Accorgimenti facilmente implementabili ma ridondanti per un semplice prototipo.

Una volta estratto il testo che vogliamo trattare, il "cosa fare" dipenderà tanto dal nostro obiettivo quanto dalla "qualità" di ciò che abbiamo ottenuto, se tutto ciò che vogliamo è "ottenere" quel testo ci sarà sufficiente salvarlo, direttamente o dopo qualche correzione di eventuali spurie (lo OCR ne da). Se, invece, ciò che vogliamo è definire il testo estratto quale "dati", da inserire in un foglio elettronico, database o altro, apriremo la nostra sessione di definizione dati, le caratteristiche del testo estratto ci forniranno due possibili vie di approccio.

La via "Facile"


In molte circostanze, probabilmente, ci si troverà a voler estrarre blocchi di dati numerici separati tra loro da spazi, magari con spazi interni presenti solo in una riga di intestazione, o neanche. In tal caso, è prevista una forma semplificata di elaborazione, all'occorrenza può essere editata la riga rappresentante l'intestazione eliminando gli spazi eccedenti ed inserendo il carattere di separazione campo stabilito

png


quindi premere il pulsante "Trasferisci Righe", la riga di intestazione verrà inviata "così com'é" alla finestra per il csv ed eliminata dalla casella di testo di definizione.

A questo punto non resta altro da fare che selezionare l'insieme di righe da elaborare quali dati CSV

png


e premere il pulsante "Elabora CSV", il callback relativo valuterà la variabile di stato "self.defcol" che memorizza la condizione di attivazione della definizione di colonne dati e, non trovandola attiva, invocherà il metodo di classe "self._elab_minor()" che elaborerà ed eliminerà le righe selezionate dall'indice maggiore al minore, per poi trasferire il tutto alla finestra per il csv
CODICE
def _make_csv(self):
       try:
           self._verify_sep()
       except ValueError as e:
           message = 'Errore separatore : {0}'.format(e)
           msg = mydialog.Message(self).show_error(message,
                                                   title='Separatore incongruo')
           mydialog.center_win(msg)
           return
       sep = self.e_sep.get()
       self.csv_win.set_separator(sep)
       if not self.defcol:
           self._elab_minor()
       else:
           self._elab_major()
   ...
   def _elab_minor(self):
       if self.csv_win == None or not self.csv_win.winfo_exists():
           return
       rows = self._get_rows_intervall()
       if not rows: return
       sep = self.e_sep.get()
       if len(sep) > 1: return
       if not sep: return
       for n in rows:
           text = self.pdf_text.get(str(n)+'.0', str(n)+'.end')
           words = text.split()
           text = sep.join(words)
           self.csv_win.add_text(text)
       rows.reverse()
       for n in rows:
           self.pdf_text.delete(str(n)+'.0', str(n)+'.end + 1 char')

Tale elaborazione leggerà le singole righe di testo selezionare estraendone le singole "parole" con le quali produrrà una nuova stringa unendo dette "parole" tramite il separatore, la stringa prodotta verrà inviata alla finestra per il CSV quale aggiunta, alla fine le righe di testo elaborate verranno eliminate, partendo da quella di indice maggiore.
La procedura può essere effettuata in più "fasi", una volta completata non resterà altro da fare che salvare i dati dalla finestra del CSV.

... Da notare che nel callback ("_make_csv(self)") viene nuovamente verificata la eventuale presenza del carattere separatore nel testo (TUTTO il testo), questa volta, però, non vi sarà un avvertimento ma un messaggio di errore : o si cambia il carattere di separazione (ovvero lo si elimina dal testo) o i dati non verranno processati.

Ok : complichiamoci la vita!


Non necessariamente i dati da estrarre sono "ragionevoli", nel senso di blocchi numerici ben strutturati con etichette brevi e concise, tal volta i dati da trattare sono stringhe multilinea o, peggio, sono dati numerici ma con romanzi al posto delle etichette (vezzo diffusissimo tra i miei colleghi burocrati) ... giusto per dare un esempio, un tipico caso :

png


Si tratta di un PDF in formato A3 orizzontale con etichette orizzontali e verticali multi-riga e variamente giustificate ... il nostro pdftotext (con lo OCR è intrattabile) è in grado di estrarre correttamente il testo, anche di tenere un incolonnamento approssimativamente congruo ma per le righe ... beh, non sono proprio perfette!

png



Un caso del genere comporta una certa attenzione in ciò che si fa, i posizionamenti di riga sono in genere attendibili ma non sempre i dati sono sulle stesse righe che contengono le etichette. Comunque, ci si trova in una condizione nella quale occorre indicare alla applicazione che alcune righe vanno considerate quale un unico elemento. Pur potendo effettuare tale operazione con la definizione delle colonne attivata (e senza le quali non avrebbe effetto), trovo che tale concomitanza confonde, principalmente trattando fogli dati piuttosto ampi.
La progressione migliore, una volta definito il testo da trattare è in primo luogo individuare le righe extra-dati da eliminarsi, selezionarle e cancellarle utilizzando il pulsante "Cancella righe, quindi passare alla

Definizione delle unioni di righe


Effettuare una unione di righe, per l'utente, è una operazione piuttosto semplice, gli basterà selezionare le righe da unire presenti nel controllo di testo e premere il pulsante "Unisci righe", prendiamo ad esempio l'immagine soprastante, per definire la seconda riga dati bisognerà cliccare sulla 2a e, tenendo il pulsante premuto, trascinare la selezione sino alla 21a

png


quindi premere il pulsante "Unisci righe", ripetendo poi l'operazione per le altre righe interessate

png


Le righe "unite" avranno l'intera prima riga e la prima colonna di tutte le altre righe interessate con un celestino quale colore di background, per facilitare all'utente la visualizzazione di quanto fatto.

Dal nostro punto di vista di programmatori, o aspiranti tali quale lo scrivente :D , la pressione del pulsante "Unisci righe" (self.bt_merge nel codice) avvierà il callback "_merge_rows(self)" che provvederà ad estrarre l'intervallo di righe selezionate in self.pdf_text, se vi sono più righe selezionate controllerà che nessuna di esse ricada in un intervallo di unione già definito, se tutto è "in ordine" verrà formata una lista degli indici di riga delle righe comprese nella unione, detta lista verrà aggiunta alla variabile di istanza self.merge_rows (anch'essa una lista) ed invocherà il metodo di classe _repaint_row_columns() che, tra le altre cose, applicherà il tag "r_merge" a tutti i caratteri della prima riga dell'unione ed al primo carattere delle altre.

Ovviamente, è prevedibile avvengano errori nella impostazione delle unioni di righe, in tal caso sarà sufficiente posizionare il cursore in una delle righe appartenenti all'unione errata e premere il pulsante "Dividi righe" (self.bt_nomerge nel codice) il cui callback (_no_merge_rows(self)) provvederà ad identificare ed eliminare l'elemento interessato di self.merge_rows ed invocare il ridisegno dell'area di testo.

i callbacks
CODICE
def _merge_rows(self):
       if self.pdf_text.tag_ranges(tk.SEL):
           init_index = self.pdf_text.index(tk.SEL_FIRST)
           end_index = self.pdf_text.index(tk.SEL_LAST)
       else:
           init_index = self.pdf_text.index(tk.INSERT)
           end_index = init_index
       first = int(init_index.split('.')[0])
       last = int(end_index.split('.')[0])
       if first == last: return
       for r in range(first, last+1):
           for mr in self.merge_rows:
               if r in mr:
                   message = 'Riga %d già compresa in precedente accorpamento' % r
                   msg = mydialog.Message(self).show_warning(message,
                                                             title='Posizione incongrua')
                   mydialog.center_win(msg)
                   return
       self.merge_rows.append([x for x in range(first, last+1)])
       self._repaint_row_columns()

   def _no_merge_rows(self):
       curr_index = int(self.pdf_text.index(tk.INSERT).split('.')[0])
       for i in range(len(self.merge_rows)):
           if curr_index in self.merge_rows[i]:
               del(self.merge_rows[i])
               break
       self._repaint_row_columns()


Come detto, sarebbe cosa buona e giusta eseguire separatamente le fasi di definizione delle unioni di righe e di definizione delle colonne, comunque sono processi logicamente "separati" che possono essere eseguiti contemporaneamente.
La definizione di unioni di righe, come già detto, non ha effetti se non vi è una contemporanea definizione di colonne, per contro una definizione di colonne può agire anche in assenza di definizione di unioni di righe : considererà le singole righe per definire una griglia di celle da elaborare per l'estrazione dati.
In assenza di unioni di righe l'elaborazione considererà il testo della singola riga compreso tra le ordinate di inizio e fine di una colonna, ripulito dei caratteri "spazio" a sinistra e destra, quale singolo dato.
In presenza di unioni di righe l'elaborazione considererà il testo delle singole righe facenti parte dell'unione e compreso tra le ordinate di inizio e fine di una colonna, ripulito dei caratteri "spazio" a sinistra e destra e sommato con inserzione di uno spazio intermedio, quale singolo dato.

Definizione delle colonne


Una cosa non ancora detta è che l'area di testo contenente i dati da impostare è configurata con un font a passo fisso ed ha inibito il ritorno a capo "automatico" (proprietà "wrap='none allo instanziamento di self.pdf_text), ciò per rendere il più lineare possibile il lavoro di un user ... e poi, lavoriamo sulle righe ;)
Per altro, una volta avviata la modalità di impostazione per un CSV, viene immediatamente eseguita una valutazione delle righe di testo (ripetuta ad ogni eventuale ulteriore aggiunta di testo) che pareggerà la lunghezza di tutte le righe-dati alla dimensione maggiore, tale operazione viene effettuata con chiamate al metodo "_def_max_len(self)" di TextUtility
CODICE
def _def_max_len(self):
       text = self.pdf_text.get('1.0', 'end-1c')
       rows = text.split('\n')
       maximo = 0
       for r in rows:
           if len(r) > maximo: maximo = len(r)
       if maximo > self.maximo:
           self.maximo = maximo
           self.sc_columns.configure(from_=0, to=self.maximo,
                                     tickinterval=self.maximo//10)
       # ricodifica il testo aggiungendo spazi per raggiungere sempre il massimo
       self.pdf_text.delete("1.0", "end-1c")
       for r in rows:
           r += ' ' * (self.maximo-len(r))
           self.pdf_text.insert('end', r + '\n')


Gli accorgimenti sopra esposti hanno la ben precisa finalità di ottenere una dimensione di testo visivamente "regolare", ossia che tutti i caratteri di indice "x" delle varie righe di testo presenti abbiano le stesse ordinate visuali e che comunque esistano su ogni riga caratteri in corrispondenza sino alla più alta "x" esistente ... cosa non garantita dalla estrazione di pdftotext (provare per credere).
Quanto sopra è realizzato per far visualizzare all'user il punto di inizio della definizione di una colonna, tale "punto di inizio" è costituito dalla colorazione verde-giallino di background per i caratteri con una determinata posizione "x" in tutte le righe, la colorazione è effettuata applicando il tag "defcol" ai caratteri.

Riguardo alla definizione della "posizione x" da parte dell'utente sono state implementate due possibili modalità.
Una volta avviata la modalità di definizione delle colonne dati l'utente potrà cliccare, col tasto sinistro del mouse, nel punto voluto dell'area di testo

png


vedrà comparire la barra verde-giallo indicate il punto di inizio di una nuova colonna, si noti come in figura si estenda un paio di righe oltre il testo, basterà che prema il pulsante "Inizio colonna" e su quella ordinata verrà impostato l'inizio di una nuova colonna dati.

Operativamente ho trovato tale metodo molto efficace e semplice, semplicità che, naturalmente, comporta un certo "lavoro" di implementazione, per ottenere tale "effetto" si è eseguito il binding per l'evento "<button-1>" per self.pdf_text collegandolo al callback "_on_locate(self, evt)" il quale, verificato se è il giusto contesto, provoca il refresh del controllo, in maniera da avere la "giusta" posizione del cursore, e dopo una attesa di 10 millisecondi (per il refresh) invoca il metodo di classe "_get_text_coords(self)" che provvede a leggere la posizione (riga/colonna, ovvero y/x) del cursore, che esporrà nella label in fondo alla finestra ("Cursore : 1, 8" in figura) ed imposterà l'ordinata quale valore di un controllo tk.Scale (self.sc_columns), è il binding di quest'ultimo, _column_evidence(self, evt=None), in realtà, a "fare il lavoro" di colorazione, che è indipendente dal cursore, come potete vedere nella sottostante figura

png


ed è il valore (value) corrente nel controllo scale che sarà valutato nel callback del pulsante "Inizio colonna" (ovvero di self.bt_confcol nel codice) che provvederà a valutare la "liceità" della nuova posizione e, nel caso, ad aggiungere "il nuovo" a self.columns, aggiornare il combo-box e la finestra in generale
CODICE
def _def_new_column(self):
       pos = self.sc_columns.get()
       if pos == 0 or pos == self.maximo:
           message = 'Una separazione di colonne non può\nsussistere sui caratteri estremi.'
           msg = mydialog.Message(self).show_error(message,
                                                   title='Posizione incongrua')
           mydialog.center_win(msg)
           return
       if self.columns:
           for i in range(len(self.columns)):
               if pos == self.columns[i][0] or pos == self.columns[i][1]:
                   message = "Una separazione di colonne non può\ngiacere all'inizio o alla fine di una colonna esistente"
                   msg = mydialog.Message(self).show_warning(message,
                                                             title='Posizione incongrua')
                   mydialog.center_win(msg)
                   return
               if pos in range(self.columns[i][0]+2,self.columns[i][1]-1):
                   old1 = self.columns[:i]
                   old2 = self.columns[i+1:]
                   new = [(self.columns[i][0], pos-1, True), (pos, self.columns[i][1], True)]
                   self.columns = old1 + new + old2
                   break
       else:
           new = [(0, pos-1, True), (pos, self.maximo, True)]
           self.columns = new
       self._repaint_row_columns()
       self._update_combo()
       self._evaluate_context()

Si noti, nel codice sopra, che i singoli valori di self.columns siano in realtà una tupla formata da due interi, costituenti la posizione di inizio e fine di una colonna, ed un valore booleano, inizialmente "Vero", che indica se la colonna è da valutarsi o meno nella elaborazione dei dati.
Una volta definita una colonna, sulla stessa potranno applicarsi un paio di proprietà, una di esse è la proprietà di essere "ignorata" nel corso della definizione dei dati CSV, la definiremo premendo il pulsante "Ignora colonna" (self.bt_ignorecol nel codice), la colonna verrà colorata di grigio applicando il rag "not" ... e se abbiamo cliccato "a menga" lo ripremeremo (è uno switch), il callback (_ignore_col(self)) si limita a ricreare la tupla utilizzando le stesse ordinate e la negazione del precedente stato del valore per "ignorata".
Analogamente, si comporta come uno switch il pulsante "Attiva/Disattica conservazione spazi interni", ovvero self.bt_spaces nel codice, il cui callback (_switch_i_spaces(self)) provvederà ad aggiungere la colonna interessata nella lista self.del_spaces, se non vi è contenuta, la eliminerà altrimenti, la colonna avrà applicato il tag "no_space" ai caratteri della seconda riga, che apparirà con sfondo rossastro, nel caso sia attiva l'eliminazione degli spazi interni ... si tenga presente che, se presente, l'indice di una colonna verrà eliminato da self.del_spaces nell caso sulla stessa si traffichi con il pulsante "Ignora colonna".
Per altro, al fine di distinguerle, le varie colonne saranno alternativamente di colore bianco o giallo-verdino, per applicazioni ai caratteri in esse ricadenti dei tags "even" ed "odd".
Naturalmente si è previsto il caso di una impostazione errata di colonna, basterà posizionarsi sulla colonna "sbagliata" e premere il pulsante "Rimuovi colonna", verrà eliminata e, se proprio avete combinato un pasticcio premete il pulsante "Azzera impostazioni", saranno rimosse tutte le impostazioni di colonna e potrete ricominciare da capo.

Si rimanda al codice completo del modulo per approfondimenti sui callback non esposti. Forse, più interessante è la procedura _elab_major richiamata da _make_csv, callback di self.bt_makecsv, quando self.defcol è "Vero"
CODICE
def _elab_major(self):
       if self.csv_win == None or not self.csv_win.winfo_exists():
           return
       sep = self.e_sep.get()
       if len(sep) > 1: return
       text_rows = self.pdf_text.get('1.0', tk.END).splitlines()
       if not text_rows: return
       index = 0
       while index < len(text_rows):
           # verifica se l'indice corrente ricade in una fusione di righe
           merge_index = None
           index += 1
           cell_text = []
           for i in range(len(self.merge_rows)):
               if index in self.merge_rows[i]:
                   merge_index = i
                   break
           if merge_index != None:
               cell_x = self.merge_rows[merge_index]
               index = self.merge_rows[merge_index][-1]
           else:
               cell_x = [index]
           for y in range(len(self.columns)):
               cell_text.append(self._get_cell_text(cell_x, y))
           for item in cell_text:
               if item == None:
                   cell_text.remove(item)
           csv_text = sep.join(cell_text)
           self.csv_win.add_text(csv_text)

   def _get_cell_text(self, cell_x, y):
       response = ''
       for r in cell_x:
           text = self.pdf_text.get(str(r)+'.0', str(r)+'.end')
           if self.columns[y][2]:
               text = text[self.columns[y][0]:self.columns[y][1]+1]
               text = text.lstrip()
               text = text.rstrip()
           else:
               return None
           response += ' ' + text
       response = response.lstrip()
       response = response.rstrip()
       if y in self.del_spaces:
           response = response.replace(' ', '')
       return response


In tale processo viene scandito il testo presente nell'area di testo da elaborare, in primo luogo, è definita una lista di indici cell_x* di riga costituita dalla singola riga in esame se non presente in una fusione di riga, ovvero dagli elementi di self.merge_rows che la comprendono (in tal caso la riga corrente è riposizionata all'indice più alto dell'elemento in self.merge_rows), viene, quindi, invocato il metodo _get_cell_text(self, cell_x, y)* su ogni singolo elemento di self.columns definito.

* la scelta dei nomi x, y e cell_x è infelice e fuorviante dato che x tratta numeri di riga (quindi ascisse) ed y tratta numeri di colonna dei caratteri (quindi ordinate), in futuro vi sarà indicazione più congrua

_get_cell_text estrarrà il testo compreso nei limiti di colonna (y[0] ed y[1]) di ogni riga memorizzata in cell_x e lo memorizzerà in una singola stringa che, ripulito degli spazi se la colonna è compresa in self.del_spaces, verrà restituito se la colonna non è da ignorare, in tal caso verrà restituito "None".
Il valore restituito viene memorizzato nella lista cell_text, scandite tutte le colonne verranno, quindi, eliminati i valori "None" e quindi costruita la limea dati CSV, unendo i vari elementi con il carattere separatore, che verrà inviata alla finestra per i dati CSV.

In pratica, supponendo di aver impostato in tal modo i dati da elaborare :

png



Otterremo i dati CSV sotto riportati

png



Non ci rimarrà altro che spuntare la casella Sostituisci con tabulazione, nel caso volessimo un tab UTF-32 quale separatore, altrimenti sarà il carattere separatore di elaborazione, e preme il pulsante "Salva" per ottenere il nostro file dati CSV.

Ora il codice completo di textutility.py (626 righe)
CODICE
#-*- coding: utf-8 -*-

import tkinter as tk
from tkinter import ttk
import tkinter.filedialog as fdlg
from text_ico_and_tip import IcoDispencer
from my_tk_object import CreaToolTip as ctt
import mydialog

class TextUtility(tk.Toplevel):
   colours = ['white', '#f4ef93', '#D5E711', 'lightgray', '#92BDFA', '#FA92A5']
   def __init__(self, master, type='text'):
       super().__init__(master)
       self.master = master
       self.type = type
       self.state = 'no_work'
       self.edit = False
       self.defcol = False
       self.maximo = 0
       self.columns = []
       self.merge_rows = []
       self.del_spaces = []
       self.csv_win = None
       self._populate()
       self._evaluate_context()

   def _populate(self):
       if self.type == 'text':
           self.title('Testo dal PDF')
       elif self.type == 'csv':
           self.title('CSV dal testo')
       else:
           self.title('%s : Stato non definito' % self.type)
       ids = IcoDispencer()
       # toolbar
       p_tools = tk.Frame(self)
       p_tools.grid(row=0, column=0, sticky='ew')
       self.ico_save = tk.PhotoImage(data=ids.getIco('salva'))
       self.bt_save = tk.Button(p_tools, image=self.ico_save,
                                command=self._on_save)
       bt_save_ttp = ctt(self.bt_save, ids.getDescr('salva'))
       self.bt_save.grid(row=0, column=0, padx=5, pady=5)
       self.ico_edit = tk.PhotoImage(data=ids.getIco('edit_csv'))
       self.bt_edit = tk.Button(p_tools, image=self.ico_edit,
                                command=self._on_edit)
       bt_edit_ttp = ctt(self.bt_edit, ids.getDescr('edit_csv'))
       self.bt_edit.grid(row=0, column=2, padx=5, pady=5)
       lbl = tk.Label(p_tools, text='Car. separatore:', anchor='w',
                      padx=5, pady=5)
       lbl.grid(row=0, column=4)
       self.e_sep = tk.Entry(p_tools, width=3)
       self.e_sep.insert(tk.END, ';')
       self.e_sep.grid(row=0, column=5, padx=5)
       message = 'Carattere utilizzato come separatore dati (solo modalità testo)'
       e_sep_ttp = ctt(self.e_sep, message)
       self.chk_var = tk.BooleanVar()
       self.chk_var.set(False)
       self.chk_tab = tk.Checkbutton(p_tools, text='Sostituisci con tabulazione',
                                     var= self.chk_var, padx=5, pady=5,
                                     justify='left')
       message = 'Sostituisce il carattere di separazione con la codifica di tabulazione (solo modalità CSV)'
       chk_tab_ttp = ctt(self.chk_tab, message)
       self.chk_tab.grid(row=0, column=6, padx=5, pady=5)
       self.ico_defcol = tk.PhotoImage(data=ids.getIco('def_col'))
       self.bt_defcol = tk.Button(p_tools, image=self.ico_defcol,
                                  command=self._on_defcol)
       bt_defcol_ttp = ctt(self.bt_defcol, ids.getDescr('def_col'))
       self.bt_defcol.grid(row=0, column=7, padx=5, pady=5)
       self.ico_delrows = tk.PhotoImage(data=ids.getIco('del_row'))
       self.bt_delrows = tk.Button(p_tools, image=self.ico_delrows,
                                   command=self._del_select_rows)
       bt_delrows_ttp = ctt(self.bt_delrows, ids.getDescr('del_row'))
       self.bt_delrows.grid(row=0, column=9, padx=5, pady=5)
       self.ico_trafrows = tk.PhotoImage(data=ids.getIco('trasf_row'))
       self.bt_trafrows = tk.Button(p_tools, image=self.ico_trafrows,
                                    command=self._trasf_rows)
       bt_trafrows_ttp = ctt(self.bt_trafrows, ids.getDescr('trasf_row'))
       self.bt_trafrows.grid(row=0, column=10, padx=5, pady=5)
       self.ico_merge = tk.PhotoImage(data=ids.getIco('merge_rows'))
       self.bt_merge = tk.Button(p_tools, image=self.ico_merge,
                                 command=self._merge_rows)
       bt_merge_ttp = ctt(self.bt_merge, ids.getDescr('merge_rows'))
       self.bt_merge.grid(row=0, column=11, padx=5, pady=5)
       self.ico_nomerge = tk.PhotoImage(data=ids.getIco('no_row_merge'))
       self.bt_nomerge = tk.Button(p_tools, image=self.ico_nomerge,
                                   command=self._no_merge_rows)
       bt_nomerge_ttp = ctt(self.bt_nomerge, ids.getDescr('no_row_merge'))
       self.bt_nomerge.grid(row=0, column=12, padx=5, pady=5)
       self.ico_makecsv = tk.PhotoImage(data=ids.getIco('make_csv'))
       self.bt_makecsv = tk.Button(p_tools, image=self.ico_makecsv,
                                   command=self._make_csv)
       bt_makecsv_ttp = ctt(self.bt_makecsv, ids.getDescr('make_csv'))
       self.bt_makecsv.grid(row=0, column=14, padx=5, pady=5)
       self.ico_close = tk.PhotoImage(data=ids.getIco('close'))
       self.bt_close = tk.Button(p_tools, image=self.ico_close,
                                 command=self.destroy)
       bt_close_ttp = ctt(self.bt_close, ids.getDescr('close'))
       self.bt_close.grid(row=0, column=16, padx=5, pady=5)
       p_tools.grid_columnconfigure(1, weight=1)
       p_tools.grid_columnconfigure(3, weight=1)
       p_tools.grid_columnconfigure(8, weight=1)
       p_tools.grid_columnconfigure(13, weight=1)
       p_tools.grid_columnconfigure(15, weight=1)
       # gestione colonne
       p_cols = tk.Frame(self)
       p_cols.grid(row=1, column=0, sticky='ew')
       self.sc_columns = tk.Scale(p_cols, from_=0, to=0, orient=tk.HORIZONTAL,
                                  command=self._column_evidence)
       message = 'imposta il punto iniziale per una nuova colonna'
       sc_columns_ttp = ctt(self.sc_columns, message)
       self.sc_columns.grid(row=0, column=0, padx=5, pady=5, sticky='ew')
       self.ico_confcol = tk.PhotoImage(data=ids.getIco('conf_col'))
       self.bt_confcol = tk.Button(p_cols, image=self.ico_confcol,
                                   command=self._def_new_column)
       bt_confcol_ttp = ctt(self.bt_confcol, ids.getDescr('conf_col'))
       self.bt_confcol.grid(row=0, column=1, padx=5, pady=5)
       self.cmb_col = ttk.Combobox(p_cols, values=[], width=10, state='readonly')
       message = 'Elenco delle colonne dati definite (formato : num_inizio num_fine)'
       cmb_col_tpp = ctt(self.cmb_col, message)
       self.cmb_col.grid(row=0, column=2, padx=5, pady=5)
       self.ico_spaces = tk.PhotoImage(data=ids.getIco('save_space'))
       self.bt_spaces = tk.Button(p_cols, image=self.ico_spaces,
                                  command=self._switch_i_spaces)
       bt_spaces_ttp = ctt(self.bt_spaces, ids.getDescr('save_space'))
       self.bt_spaces.grid(row=0, column=3, padx=5, pady=5)
       self.ico_ignorecol = tk.PhotoImage(data=ids.getIco('ignore_col'))
       self.bt_ignorecol = tk.Button(p_cols, image=self.ico_ignorecol,
                                     command=self._ignore_col)
       bt_ignorecol_ttp = ctt(self.bt_ignorecol, ids.getDescr('ignore_col'))
       self.bt_ignorecol.grid(row=0, column=4, padx=5, pady=5)
       self.ico_removecol = tk.PhotoImage(data=ids.getIco('remove_col'))
       self.bt_removecol = tk.Button(p_cols, image=self.ico_removecol,
                                     command=self._remove_column)
       bt_removecol_ttp = ctt(self.bt_removecol, ids.getDescr('remove_col'))
       self.bt_removecol.grid(row=0, column=5, padx=5, pady=5)
       self.ico_nocols = tk.PhotoImage(data=ids.getIco('no_cols'))
       self.bt_nocols = tk.Button(p_cols, image=self.ico_nocols,
                                  command=self._restart_cols)
       bt_nocols_ttp = ctt(self.bt_nocols, ids.getDescr('no_cols'))
       self.bt_nocols.grid(row=0, column=6, padx=5, pady=5)
       p_cols.grid_columnconfigure(0, weight=1)
       p_text = tk.Frame(self)
       p_text.grid(row=2, column=0, sticky='nsew')
       self.pdf_text = tk.Text(p_text, font='courier 10',
                               selectbackground="yellow",
                               wrap='none', padx=5, pady=5)
       self.pdf_text.tag_config('even', background=self.colours[0])
       self.pdf_text.tag_config('odd', background=self.colours[1])
       self.pdf_text.tag_config('r_merge', background=self.colours[4])
       self.pdf_text.tag_config('not', background=self.colours[3])
       self.pdf_text.tag_config('no_space', background=self.colours[5])
       self.pdf_text.tag_config('defcol', background=self.colours[2])
       self.pdf_text.tag_raise('sel')
       if self.type == 'csv':
           self.pdf_text.configure(bg='#f4ef93')
       self.pdf_text.grid(row=0, column=0, sticky='nsew')
       v_scr_1 = tk.Scrollbar(p_text, orient=tk.VERTICAL,
                               command=self.pdf_text.yview)
       self.pdf_text.configure(yscrollcommand=v_scr_1.set)
       v_scr_1.grid(row=0, column=1, sticky='ns')
       h_scr_1 = tk.Scrollbar(p_text, orient=tk.HORIZONTAL,
                               command=self.pdf_text.xview)
       self.pdf_text.configure(xscrollcommand=h_scr_1.set)
       h_scr_1.grid(row=1, column=0, sticky='ew')
       self.lbl_message = tk.Label(p_text, text='Cursore : ', justify='left')
       self.lbl_message.grid(row=2, column=0, columnspan=2, sticky='w')
       p_text.grid_columnconfigure(0, weight=1)
       p_text.grid_rowconfigure(0, weight=1)
       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(2, weight=1)
       # bindings

       self.pdf_text.bind('<Button-1>', self._on_locate)

   # ***************************
   # ***     CALLBACK'S      ***
   # ***************************

   def _on_save(self):
       if self.type == 'csv':
           self._write_csv()
       else:
           self._write_text()
       self._evaluate_context()

   def _on_edit(self):
       if self.csv_win == None or not self.csv_win.winfo_exists():
           self.csv_win = TextUtility(self, type='csv')
       try:
           self._verify_sep()
       except ValueError as e:
           message = 'Errore separatore : {0}'.format(e)
           msg = mydialog.Message(self).show_warning(message,
                                                     title='Separatore incongruo')
           mydialog.center_win(msg)
       self.edit = True
       self._def_max_len()
       self._evaluate_context()
   
   def _on_defcol(self):
       self.defcol = True
       self._evaluate_context()
   
   def _on_locate(self, evt):
       if self.type != 'text': return
       if not self.defcol: return
       self.pdf_text.update()
       self.after(10, self._get_text_coords)
   
   def _get_text_coords(self):
       y, x = self.pdf_text.index(tk.INSERT).split('.')
       msg = 'Cursore : ' + y + ', ' + x
       self.lbl_message.configure(text=msg)
       self.sc_columns.set(int(x))

   def _del_select_rows(self):
       rows = self._get_rows_intervall()
       if not rows: return
       rows.reverse()
       for n in rows:
           self.pdf_text.delete(str(n)+'.0', str(n)+'.end + 1 char')
   
   def _trasf_rows(self):
       rows = self._get_rows_intervall()
       if not rows: return
       for row in rows:
           text = self.pdf_text.get(str(row )+ '.0', str(row) + '.end')
           self.csv_win.add_text(text)
       rows.reverse()
       for n in rows:
           self.pdf_text.delete(str(n)+'.0', str(n)+'.end + 1 char')

   def _make_csv(self):
       try:
           self._verify_sep()
       except ValueError as e:
           message = 'Errore separatore : {0}'.format(e)
           msg = mydialog.Message(self).show_error(message,
                                                   title='Separatore incongruo')
           mydialog.center_win(msg)
           return
       sep = self.e_sep.get()
       self.csv_win.set_separator(sep)
       if not self.defcol:
           self._elab_minor()
       else:
           self._elab_major()
   
   def _column_evidence(self, evt=None):
       self.pdf_text.tag_remove('defcol', '1.0', 'end')
       last_row = int(self.pdf_text.index('end').split('.')[0])
       pos = self.sc_columns.get()
       for r in range(1, last_row+1):
           self.pdf_text.tag_add('defcol', '%d.%d' % (r, pos),
                                 '%d.%d' %(r, pos+1))
   
   def _def_new_column(self):
       pos = self.sc_columns.get()
       if pos == 0 or pos == self.maximo:
           message = 'Una separazione di colonne non può\nsussistere sui caratteri estremi.'
           msg = mydialog.Message(self).show_error(message,
                                                   title='Posizione incongrua')
           mydialog.center_win(msg)
           return
       if self.columns:
           for i in range(len(self.columns)):
               if pos == self.columns[i][0] or pos == self.columns[i][1]:
                   message = "Una separazione di colonne non può\ngiacere all'inizio o alla fine di una colonna esistente"
                   msg = mydialog.Message(self).show_warning(message,
                                                             title='Posizione incongrua')
                   mydialog.center_win(msg)
                   return
               if pos in range(self.columns[i][0]+2,self.columns[i][1]-1):
                   old1 = self.columns[:i]
                   old2 = self.columns[i+1:]
                   new = [(self.columns[i][0], pos-1, True), (pos, self.columns[i][1], True)]
                   self.columns = old1 + new + old2
                   break
       else:
           new = [(0, pos-1, True), (pos, self.maximo, True)]
           self.columns = new
       self._repaint_row_columns()
       self._update_combo()
       self._evaluate_context()
   
   def _switch_i_spaces(self):
       if not self.columns: return
       pos = self.sc_columns.get()
       for i in range(len(self.columns)):
           start = self.columns[i][0]
           end = self.columns[i][1]
           if pos in range(start, end+1):
               if i in self.del_spaces:
                   self.del_spaces.remove(i)
               elif self.columns[i][2]:
                   self.del_spaces.append(i)
               break
       self._repaint_row_columns()
   
   def _ignore_col(self):
       if not self.columns: return
       pos = self.sc_columns.get()
       for i in range(len(self.columns)):
           start = self.columns[i][0]
           end = self.columns[i][1]
           if pos in range(start, end+1):
               col = i
               break
       data = (self.columns[col][0], self.columns[col][1], not self.columns[col][2])
       self.columns[col] = data
       if col in self.del_spaces:
           self.del_spaces.remove(col)
       self._repaint_row_columns()
   
   def _remove_column(self):
       if not self.columns: return
       pos = self.sc_columns.get()
       for i in range(len(self.columns)):
           start = self.columns[i][0]
           end = self.columns[i][1]
           if pos in range(start, end+1):
               col = i
               break
       if col == 0: return
       data = (self.columns[col-1][0], self.columns[col][1], True)
       self.columns[col-1] = data
       del(self.columns[col])
       for c in self.del_spaces:
           if c >= col: self.del_spaces.remove(c)
       self._repaint_row_columns()

   def _restart_cols(self):
       self.columns = []
       self.del_spaces = []
       self._repaint_row_columns()
   
   def _merge_rows(self):
       if self.pdf_text.tag_ranges(tk.SEL):
           init_index = self.pdf_text.index(tk.SEL_FIRST)
           end_index = self.pdf_text.index(tk.SEL_LAST)
       else:
           init_index = self.pdf_text.index(tk.INSERT)
           end_index = init_index
       first = int(init_index.split('.')[0])
       last = int(end_index.split('.')[0])
       if first == last: return
       for r in range(first, last+1):
           for mr in self.merge_rows:
               if r in mr:
                   message = 'Riga %d già compresa in precedente accorpamento' % r
                   msg = mydialog.Message(self).show_warning(message,
                                                             title='Posizione incongrua')
                   mydialog.center_win(msg)
                   return
       self.merge_rows.append([x for x in range(first, last+1)])
       self._repaint_row_columns()

   def _no_merge_rows(self):
       curr_index = int(self.pdf_text.index(tk.INSERT).split('.')[0])
       for i in range(len(self.merge_rows)):
           if curr_index in self.merge_rows[i]:
               del(self.merge_rows[i])
               break
       self._repaint_row_columns()

   # ***************************
   # *** METODI DELLA CLASSE ***
   # ***************************

   def set_text(self, text):
       '''Imposta il testo nell'area di visualizzazione cancellando il precedente.
       e tutte le impostazioni effettuate'''
       self.edit = False
       self.defcol = False
       self.maximo = 0
       self.columns = []
       self.merge_rows = []
       self.del_spaces = []
       if self.csv_win and self.csv_win.winfo_exists():
           self.csv_win.close()
       self.pdf_text.delete("1.0", "end-1c")
       data = text.split('\n')
       for row in data:
           self.pdf_text.insert('end', row +'\n')
       self._evaluate_context()

   def add_text(self, text):
       '''Aggiunge del testo nell'area di visualizzazione.'''
       data = text.split('\n')
       for row in data:
           self.pdf_text.insert('end', row +'\n')
       if self.edit: self._def_max_len()
       self._evaluate_context()
   
   def _def_max_len(self):
       text = self.pdf_text.get('1.0', 'end-1c')
       rows = text.split('\n')
       maximo = 0
       for r in rows:
           if len(r) > maximo: maximo = len(r)
       if maximo > self.maximo:
           self.maximo = maximo
           self.sc_columns.configure(from_=0, to=self.maximo,
                                     tickinterval=self.maximo//10)
       # ricodifica il testo aggiungendo spazi per raggiungere sempre il massimo
       self.pdf_text.delete("1.0", "end-1c")
       for r in rows:
           r += ' ' * (self.maximo-len(r))
           self.pdf_text.insert('end', r + '\n')
           
   def _verify_sep(self):
       text = self.pdf_text.get("1.0", "end-1c")
       sep = self.e_sep.get()
       if len(sep) > 1: raise ValueError('Carattere separatore composto da più caratteri')
       if not sep: raise ValueError('Carattere separatore nullo')
       if sep in text: raise ValueError('Carattere separatore contenuto nel testo')


   def _evaluate_context(self):
       self.bt_save.configure(state='disabled')
       self.bt_edit.configure(state='disabled')
       self.e_sep.configure(state='disabled')
       self.chk_tab.configure(state='disabled')
       self.bt_defcol.configure(state='disabled')
       self.bt_merge.configure(state='disabled')
       self.bt_spaces.configure(state='disabled')
       self.bt_delrows.configure(state='disabled')
       self.bt_trafrows.configure(state='disabled')
       self.bt_makecsv.configure(state='disabled')
       self.sc_columns.configure(state='disabled')
       self.bt_confcol.configure(state='disabled')
       self.cmb_col.configure(state='disabled')
       self.bt_ignorecol.configure(state='disabled')
       self.bt_removecol.configure(state='disabled')
       self.bt_nocols.configure(state='disabled')
       self.update()
       if len(self.pdf_text.get('1.0', 'end-1c')) == 0:
           self.state = 'no_work'
       else:
           self.bt_save.configure(state='normal')
           self.state = 'work'
       if self.state == 'no_work':
           return
       if self.state == 'work' and self.type == 'text':
           if self.edit:
               self.e_sep.configure(state='normal')
               self.bt_delrows.configure(state='normal')
               self.bt_trafrows.configure(state='normal')
               self.bt_merge.configure(state='normal')
               self.bt_makecsv.configure(state='normal')
               self.bt_close.configure(state='normal')
               if self.defcol:
                   self.sc_columns.configure(state='normal')
                   self.bt_confcol.configure(state='normal')
                   self.cmb_col.configure(state='normal')
                   if len(self.cmb_col['values']) > 0:
                       self.bt_spaces.configure(state='normal')
                       self.bt_ignorecol.configure(state='normal')
                       self.bt_removecol.configure(state='normal')
                       self.bt_nocols.configure(state='normal')
               else:
                   self.bt_defcol.configure(state='normal')
           else:
               self.bt_edit.configure(state='normal')
       elif self.type == 'csv' and self.state == 'work':
               self.bt_save.configure(state='normal')
               self.chk_tab.configure(state='normal')
   
   def _get_rows_intervall(self):
       if self.pdf_text.tag_ranges(tk.SEL):
           init_index = self.pdf_text.index(tk.SEL_FIRST)
           end_index = self.pdf_text.index(tk.SEL_LAST)
       else:
           init_index = self.pdf_text.index(tk.INSERT)
           end_index = init_index
       first = int(init_index.split('.')[0])
       last = int(end_index.split('.')[0])
       return [x for x in range(first, last+1)]
   
   def _repaint_row_columns(self):
       self.pdf_text.tag_remove('even', '1.0', 'end')
       self.pdf_text.tag_remove('odd', '1.0', 'end')
       self.pdf_text.tag_remove('r_merge', '1.0', 'end')
       self.pdf_text.tag_remove('not', '1.0', 'end')
       self.pdf_text.tag_remove('no_space', '1.0', 'end')
       self.pdf_text.tag_remove('defcol', '1.0', 'end')
       last_row = int(self.pdf_text.index('end').split('.')[0])
       for i in range(len(self.columns)):
           start = self.columns[i][0]
           end = self.columns[i][1] + 1
           for r in range(1, last_row+1):
               if self.columns[i][2]:
                   if i % 2:
                       self.pdf_text.tag_add('even', '%d.%d' % (r, start), '%d.%d' %(r, end))
                   else:
                       self.pdf_text.tag_add('odd', '%d.%d' % (r, start), '%d.%d' %(r, end))
               else:
                   self.pdf_text.tag_add('not', '%d.%d' % (r, start), '%d.%d' %(r, end))
       for c in self.del_spaces:
           start = self.columns[c][0]
           end = self.columns[c][1] + 1
           self.pdf_text.tag_add('no_space', '%d.%d' % (2, start), '%d.%d' %(2, end))
       for coll in self.merge_rows:
           self.pdf_text.tag_add('r_merge', '%d.0' % coll[0], '%d.end' % coll[0])
           for i in range(1, len(coll)):
               r = coll[i]
               self.pdf_text.tag_add('r_merge', '%d.0' % r, '%d.1' % r)

   def _update_combo(self):
       self.cmb_col.delete(0, tk.END)
       col_list = []
       for c in self.columns:
           item = str(c[0]) + ' ' + str(c[1])
           col_list.append(item)
       self.cmb_col['values'] = col_list

   def _elab_minor(self):
       if self.csv_win == None or not self.csv_win.winfo_exists():
           return
       rows = self._get_rows_intervall()
       if not rows: return
       sep = self.e_sep.get()
       if len(sep) > 1: return
       if not sep: return
       for n in rows:
           text = self.pdf_text.get(str(n)+'.0', str(n)+'.end')
           words = text.split()
           text = sep.join(words)
           self.csv_win.add_text(text)
       rows.reverse()
       for n in rows:
           self.pdf_text.delete(str(n)+'.0', str(n)+'.end + 1 char')

   def _elab_major(self):
       if self.csv_win == None or not self.csv_win.winfo_exists():
           return
       sep = self.e_sep.get()
       if len(sep) > 1: return
       text_rows = self.pdf_text.get('1.0', tk.END).splitlines()
       if not text_rows: return
       index = 0
       while index < len(text_rows):
           # verifica se l'indice corrente ricade in una fusione di righe
           merge_index = None
           index += 1
           cell_text = []
           for i in range(len(self.merge_rows)):
               if index in self.merge_rows[i]:
                   merge_index = i
                   break
           if merge_index != None:
               cell_x = self.merge_rows[merge_index]
               index = self.merge_rows[merge_index][-1]
           else:
               cell_x = [index]
           for y in range(len(self.columns)):
               cell_text.append(self._get_cell_text(cell_x, y))
           for item in cell_text:
               if item == None:
                   cell_text.remove(item)
           csv_text = sep.join(cell_text)
           self.csv_win.add_text(csv_text)

   def _get_cell_text(self, cell_x, y):
       response = ''
       for r in cell_x:
           text = self.pdf_text.get(str(r)+'.0', str(r)+'.end')
           if self.columns[y][2]:
               text = text[self.columns[y][0]:self.columns[y][1]+1]
               text = text.lstrip()
               text = text.rstrip()
           else:
               return None
           response += ' ' + text
       response = response.lstrip()
       response = response.rstrip()
       if y in self.del_spaces:
           response = response.replace(' ', '')
       return response
   
   def set_separator(self, sep):
       old_state = self.e_sep['state']
       self.e_sep.configure(state='normal')
       self.e_sep.delete(0, tk.END)
       self.e_sep.insert(0, sep)
       self.e_sep.configure(state=old_state)
   
   def _write_text(self):
       f_types = [('file testo', '*.txt')]
       f_name = fdlg.asksaveasfilename(parent=self,
                                       title='Registrazione testo',
                                       confirmoverwrite=False,
                                       filetypes=f_types)
       if not f_name: return
       text = self.pdf_text.get('1.0', 'end-1c')
       try:
           with open(f_name, 'w') as f:
               f.write(text)
       except OSError:
           message = 'Errore scrittura file'
           msg = mydialog.Message(self).show_warning(message,
                                  title='Salvataggio fallito')
           mydialog.center_win(msg)

   def _write_csv(self):
       f_types = [('Comma separed values', '*.csv')]
       f_name = fdlg.asksaveasfilename(parent=self,
                                       title='Creazione CSV',
                                       confirmoverwrite=False,
                                       filetypes=f_types)
       if not f_name: return
       text = self.pdf_text.get('1.0', 'end-1c')
       if self.chk_var.get():
           sep = self.e_sep.get()
           text = text.replace(sep, '\U00000009')
       try:
           with open(f_name, 'w') as f:
               f.write(text)
       except OSError:
           message = 'Errore scrittura file'
           msg = mydialog.Message(self).show_warning(message,
                                  title='Salvataggio fallito')
           mydialog.center_win(msg)

   def close(self):
       self.destroy()


La parte tkinter finisce qui ... ora provo a vedere cosa mi riesce di fare con le wxPython! ... sarà in prossimo post ;)

Ciao

Edited by nuzzopippo - 9/7/2021, 12:30
view post Posted: 29/6/2021, 08:25 [Python] GUI & PDF a passetti 2 : testo e csv - Appunti di apprendimento
È giunto il momento clou di questo secondo post, ossia fornire una possibile soluzione ad una particolare domanda del post scatenante la serie, che nel corso di quella discussione definì "complessa", ossia :
permettere all'utente di definire delle colonne individuanti i campi dati da elaborarsi per definire il file CSV.

ossia, di fare qualcosa così, in sostanza.

png


dove è stabilita una serie di colonne per l'estrazione dati con esclusa la 3a ed eliminazione degli spazi interni della 7a colonna.

Come potete arguire dal numero di "bottoni" presenti, ciò che ci si presta ad esporre è un qualcosa di una certa complessità, che probabilmente mi porterà a spezzetare questo post in più parti (il numero di caratteri inseribile in un singolo ost è alto ma, comunque, limitato). Già in fase di ideazione trovai limitato considerare le sole colonne quale discriminante : e se ci fossero dati su più righe?
Inoltre, in fase avanzata di implementazione mi trovai la necessità di estrarre alcuni consistenti brani da dei pdf da scansione ...

Alla fine, le possibilità implementate in questa seconda serie di prototipi sono giunte a poter estrarre e salvare, in tutto od in parte, testo proveniente da più pdf contemporaneamente ovvero di elaborare detto testo per produrre un csv con :
  1. dati definiti considerando i soli spazi
  2. dati definiti per incolonnamenti stabiliti dall'utente
  3. dati definiti da una griglia di celle con incolonnamenti e fusione di righe stabilite dall'utente.
... il tutto con utilizzo di un'unica classe (TextUtility), non molto complessa di per se, a dire il vero, ma piuttosto astrusa da descrivere per le interazioni possibili nelle varie fasi, questo terzo post dovrò probabilmente scriverlo in diverse sessioni, con magari qualche modifica tra l'una e l'altra, producendo ed inserendo un bel po' di immagini.

Comunque, anche in questo caso utilizzo uno specifico modulo di risorse serializzate per icone e tooltip, text_ico_and_tip.py, il codice relativo (402 righe) :
CODICE
# -*- coding: utf-8 -*-

icoclose = '''
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
'''

icoconf_col = '''
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
'''

icodef_col = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAADj0lEQVR42mIgCoyCURD6n1w9lAP+3A9
gWr3263yztl+BhNQD1QSA1ML0UgQ40iEGCOd/PKZa9fkuunwRoLhygJkliaLwrBnnhWvFXsXG2rZtI8
46fObYxrNtB6v46ce43T09Z+tWtvtHda0ne5ObVp3Tp75q5AxqQcfG/kwaz+Mf1bNhjW8veFU5xMxw/
RcKbv3GwB0LHDwdcfFewfUZ0/7TURd3zHNoDB9LGtLS9efI65/Uc1EbnMC7yn5m+uvfIPATaXyPf1qP
h71Z0jOgx27+2rz3zzQ3sTE0NsBDXqtWbQg8f/8iC391/ObN2wM97l4oEAgev2bNhuO12mrU69RrsG5
lDbf+AHfdyjoajTWs1/Jeu3bjcdlEtm3bdXzDhi3weuumjfgkPuFu3bQJGzdu9ZvGCeJisQaqwcCBpm
mA08cNX6pwtCbGx8dx6tRJ3uVyQ4p09eoN8Mp1BwBMXP2ZClh9dLtdtNttTE5O0CREj0ymCCrbtqEoC
lyjgys+VmAqEzhz5gzGxsagqgoymbI0QLW6GlTD4RCWZQNDHZd/1GdebTSbTXbzSfImwqJHPJ75PbkL
wzAAq4c57/UBs8NF/X6fndeRSGSlAQqFKqg8HyIw590eYHW5Xtd1TrhUqoseK1bE/fREAdBx6RtMPFB
4gF6vB9PUEQ4npAHSaU7R9wEcXPJal3vRslI7jsMolkSPJUvCvtBLf+5LTOzym1PzNVy6NCINkEzmML
NcnPNSxw+gqioLYAVTXLRo+Syxg9CzHT8ArV+r1cLixWFPLKM4jQCYR5smQwFoGcgriCIRiMwSDxF6o
sM2Cp85PUSdTgfLlkU9sZTi9Ao9TgEsWkbS0zaY4rJlMVH8cAuAxoU0e3qVaJaCWErR87A5fvLRNJVR
XCF6RCIJUXxvk/D560/baDQJOYGI6HEPeTj0BpCeAjACARRjsZREPGAiToGvYTyeFsQCRcFjCNM0OX5
d17B8eQDFRCIjiu9qec8EMzBg2xZSqawollOkAN4HjiZCJIIplkrV46lUjt+AuphJ4I5vx13a0nE6nU
Mmk0exWJH+C+QU6RM/IArUIsVCoRxoeM4rmphUPp4oSgN4HzjHsUHjQn+lLnpDR+hvVCqVly7j9J8Uk
RxJgEqldjybLfIbUJfzadzOlpG23rlcrohyuXb8vwgwgmX8BwFG6nHhG6pMPHqPC19tT9/+bx4jr98A
ooD8AIDL8E4AAAAASUVORK5CYII=
'''

icodel_row = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAFMklEQVR42u2TA3BcXxvGT23btm3bDGp
mM7GdGrGmzabcTG0zmC+pbdvadLO4a9znO/dOiptyM5P/qL+Z37z7nmf33qMl+cn+P+R7SD6yI6deGj
nkxNFSRcO+HztesWzYuYF9TpD85ljd6qmq8GXIWhyILYVJFKFsKUSiZCsXQbUoEIeqlTtC8pOd5Uuve
eHvhnuTbfHAWwT/0oVvPfB3xj1bezxxsMW2ssVWkfxiY07dULRA0j1ne1wfPwn3ZtniXJfuuGkzEOuL
FUgiHCdPniT5jbhSiXunG1ZGavkKuNS+IcSVS94jlFgqT1paGo4dO/ZT85IdOXIEt+/cwcnMTN26CWO
YO10a4RoheNCiNl/vdGuEiLIlksgXDhw4gF+xdevWPGUZp8/gfNhKw4vOrfGBEFzq0wXd6tV8fqlPZ3
yk/eserRFer2Yi4di1axc4TGazQA6JRJKnLC0zE0fmzTXJGzfA4z494Ny/XzqAjv2KFI5/NqA31K1bI
K51k6OE48tKjEaTQI61a9fmKTtKj4LyKW3+XPmG6dOuAXCgDiAU10oVomI7tUojX0jatAkceqE8UWKx
1ZmFuvvoUbDAJxOQagbiDICI1r7kO0JqVyY8WLoUei9P6F2coXem+vjQzy4wUBEUBIOnB9/z+vnx3+G
z4GAYvLygd3WF3oN+x9+f/w6Xw9+f1QcGsrqwMKVOnHRXt23bTv2u3Z5wdeXfKsBkZwfs24cvsAf2w3
LvHjgWrl4NbN+KLxjpS1i1GnyWlASEhoCDzc6GeehQsODBppMngd27LVzEcnfj8EGWNkcAdCa50TdrB
g5TQgLUeiMYM6DftAW4fAVLl68ArlyFjmaMBVAbTNCKHGH5IKXZcliuXIPOwQEMaPb2PUwdO8JoZrGF
XmxLWLhWk7zpJmNmExmZ3EO1aIkI5csSdalSRICGEHAo3n6AMToaSq0ecvoiSJIRumIFjACYjExoY2K
hNFmg1BnA0kmEhofzmTL9f9DNnAk5ANXLN0Dbtkjeuxc64KN6+cp7quRNO+R68xyFVNZbTkhZkhuGm4
BOh69IJPiCa0QEcPs2ONinT2HeuBFfcKMTAN16nkePwDo6gkenw6qDB1nkwJ47Bzx7ChZIB9CD5MZCC
LJ/opwKvlqV8ZoIYbOpsqrVFNkdO92VDR+xQ25j50EXW4nkxmvZMmgByITyY3MDAqzO1NTYzZu4+on2
6dQEqkiZ8zdkSpcmAnx9faHRmZAlUwrkxkQODlZnjFqPuLhYrkppn0FNpDorGd0A8jM83N3AaI349Fk
hkNGaMHv2LL5akynoBKIiI6HQ6KW0z6AmUp2zVdqfT8DJyQksALXWKJAbmz5tmtWZ0QyEh4fBZMEnRm
NIpyaoNAaR1mDhj8DNzY0IcHFxga3tZNjYTPpeOmYDJydnvlqTTZo4AWKxmJ0xYwZ8ff3Uy5YtfxwbF
3dwg0TiTxdbjeQmJCQEBjOgYPQCzSwwYfw4Wq3LdEZg8eJF0NNTlat06dQEuVIrUmlMfXMWTAR4e3tB
ptDgzfssgXKVHiNGDOerNVmWXI2QkGB8lmuktM+gJlKdP2Ypfn4HHB0d+R+9eivFq3e8/GeZUovBgwf
x9a8zqlTGIMDPD9JsRkqzDDqWSHV+L/3FBKZPn85vm0yhFcgdS79+fWm1LlPrQM/eGxoDpLTPoCZSnR
VqAz8BGxubHycwauRIjBSKUaNGgn6ZVuuyYcOGIioqkqsG2l+hSqheo0ePHmRnZye8hPb29uQ/RrAD/
/jH/wGPYJ6zxUytSgAAAABJRU5ErkJggg==
'''

icoedit_csv = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAADfklEQVR42uzUA5BbXRwF8Oxn26xt2+a
o7qC27TZYW1nbZpw1Xta2PajbUfHvaefWeKl5Zn7Lh5MrwYf4enoudLS2VugyONVrKRAfF5dhfPIkaR
Sq6zm63DX9+vUzeKUFoqKi7Pw9PclDKqXSomKqqa4e/0oLmFtYzEtLTCTJsWOkVamppaHB45UWGDZs2
CcatbrNzsyM3F1cqL6m9qJaq/32lZaIjokxjg0PJysjI8rmsqi2unrNKy2wffv2HmkpKdeNTpyguOgY
6mhrSxe86qhUqlQPJyfydnenmsoq0nFcr1dawMfXd3WCSkUYBcrPyaXG+nqTV1pg9OjR3yQnJV20EIt
JFhtLHa1t7f379/+E5zYD+AK+AVz7nJHJZO7hgYGEk5GqKiooPz9/GvvXx/AHdLrHf/An/Azfwk/sml
/g2SIUicamp6SQ5PhxysvOIVlcvBv+3AO6wnfAl49gIvDlM/gUHsp3sbGxTU4YgYiQEKqtqgnRtzx7+
Uw2Kg/md+gFvaE7/APDYLGA5SfoD/+HhoUdVGANmAqF1Nrcell4Qvi9gD9fw3w2BbfzB5um/+HLB67v
AydhCqDJzXYsy5cv/zMlOfkqdgMVFxZQri53I8/Lu8As9pLv71kfnz1mlObCYfj3zgZgv/zCHiCQy+V
xkZgCpUyG7dhY8JgXf86GfBB0ZkV+hMflG9gGS1m5xwfrYGlhQQHJoqOppbmFUpNThzxwSQ9YCSOgJ9
8DWcGjMAD4s3Pnzs85jjujjI+nIhSpqa6xZ//6kn2CAzCQDSlfhsAeNjr6R6vV2qclJZECJTAKZ37++
efbDxoOBqBPJsDqZzqkXFxcBus4jiJDQ28dSgG+gWvYi/XNVJgHz57gwMA8Y2xHV6mUSopKwgX6ZxyM
gefLKaFwo6ebG4mPH8eWLOqYM2eOPkM5mC2258/AgQM/jomObj5x6BBlpGeQ1FG6kOeWrtD3gX3fA0a
y9fD08fHxEVsaG5Onqyvp0nU3hq+KFgViS2gZbw3EoDQTDMqmUL4CWQ6IjY2VrKuuftPe2Pj/4b0HC/
BUy3XQ7FkBLR8UQTQ8u1ICsjMy/LJSU//dun5zO5oUMzSlTwPiUmhNaAPFSgzUBP5eXpO2btp8DsaHJ
rR+aDCzMdAaGOvrcwLrhwu8vLxSQG4kEAeCLaYnqCopsU6IT5BiGE4AAM//b2y90yMPAAAAAElFTkSu
QmCC
'''

icoignore_col = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAFh0lEQVR42u1UA3AsDRPcz7Zt29azbdu
MbfO/2M6zbdtWeBfrzL277f9mK9qtfT+/FF9XdfVmemZ6cmK6Euv+jb+6K8OXt+qJfj23b37k/vDOta
1PPx5+9K9ftzNdja2vv7hLGx6EJn93FN7LRFOt8B4mujnMD1o/D2x84YnNTFdi+ZOPppW7LcSVESNxz
Wkm3B677+I113m4OmoMbs0YheLHH0jssvDsVs26/67UK/PG4NyQ4bg6eRSOfvcjLozshswH705lCDt2
7GC6GinPPnT10NvPYtdTT+Hkl28j5dmHr1I9jmnFrl27sHXrVmzZskXAddu2Aenp4GQycLGx4EJCYA8
Kgp3U0xPw9sa6nTvFc/yuXbt3Y8eBA8gePxaXv30bZxkG1z58ldfL37+DyMcfSu34uqxbh9vB4uUFOJ
ZCoQAPlgVyckAwjBqF22H5qtU4FRuDim8+QR3D4PhPX2Horz/i+I9f8H/Lf/wUEW+8nMwQVq5cCQJrt
QkJwDRvHiCXg7VzYJuaYJfJwHLgYejVC6zEHKFgxXJsmz0Lynffxs2fvoPMaSkIMmcn3Pr5O+g++RDx
n763hSEUFRWBYLawQgIwjhwJ3qtvgC06BmY7x9e5jRuh//13mCXmCBkZGSDsW7gA6729QbC0Huc3eCB
iv/ig48OXm5sLgp0TkgNg6NEDMJuB6GjYAZ7IyQVqaqD74ANwEnOEpKREdEJ7HyE3Px+U6/3qswyPvL
w8xMXFQczw5GSge3eEpacjzvEcl5gIXlNT+RrefRfhaWmSs1lZWaSSpDymM5KSklo/A3YBLQB0jhAC/
7JnZMG+/yBfR0kJNI8+CovEHCEyMhK4jUd5ggPiHVeZLDaoNHohAWiefQYEfWwctHv3Q2W2QrtzN2xr
1kDFMHyPeI52hQQHk0p6lCc4ICoqCgYTixaVVkg64IEHYHW8ZOpde9BsZKHZth26mDgQlAzD94jnaJe
fny+ppEd5ggNCQkJg4wC9gRVQB0DNMMC1q9CyHCwbNsEqS4DWhvYDqEc8R7u8PD1JJT3KExwQFhYGFx
cnODsLOc/XF45OzAkIgJOfH5wcXydnd3de5wQG8t58P1/xHL8rODiYVNKjPMEBXl5eMLOAWmOCWsuTf
1a1/pe4cgWslw9UJhtURivs48YDVit5fI94jnYtXrSQVNKjPMEBLi4uaNEYUV3XLGAV23qA42K5yoDq
Zg1Mffqi+uJVEMijHvGc0rFrzpzZvIo9yqE8wQGLFy9Gs0oPRU2jgJVmOx9CqKhXwvzrb6i5cBkVdS3
A+XPk8T3iOdo1ffpUUkmP8gQHzJ49G2o9i7pGtYA1NvBfNR5ffInGSzdQ16KDOr8YcHIij+8Rz9Gu8e
PGkkp6lCc4ID0tDRER4YgIDxUwMFEGjmE4r+RkhDoYERmBEJkMYbJ/gGp2h0c94jnalZyUxJFKeZQnO
MDxnbXckCsLz9+oWd7O69XLTyutBUrmLhaK6paTN2oKSgtW79QMHHrp5M3aAti5ZiXDsNRDvZ1nHbsK
fH18QMrXhF4h5QkOCPD3O3ihpNb1xMUSzw7e8jxSp3Wpf+31S1rg8PnMwojqiVNWHa1odDu5/4SvITq
mrOHtdy9SD/V2nnXscg4Lj1CQ8jWh50p5ggMCA/yjL5fU9zp1oaTvqQulPOn5WI2up+L7HxNU5Yrkss
kzFx8vb+597sDZQRVjJjkrgWjFdz8mUI947kpJQ/eYmPj1pGKPcihPcEBwcNBIeZ352fNX5C905pkG4
/OVvQd+q9+2Y8ihasMz58/derFq7pJPj16SP6cH/qjs0e9b6hHPKerNz8TGxc8iFXuUQ3nin+JXmX8B
AFK1u/7VTGBg4CuShjgvNDRUoH8HpHdKe3dwB/8E5E6ZPNRCNx0AAAAASUVORK5CYII=
'''

icomake_csv = '''
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
'''

icomerge_rows = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAETklEQVR42sWWA5TsSBSG17Zt27Zt27Z
t27Zte7ufMWjbtjG6e/9MXk6lN9PJLPucr5Wkvr/q3mA2IvpfUf3g15rMkf8ya3YLcGSpXKZsNkvZXI
5y+TzlCwUqgGKRiqUSYTsoz6JSkahUqwpVUKv9iUazSXB0DZDN5iieSFIimaJkKk2pdIbSmdFASqjOY
HI4UBJCdoatcQj9ALk8i1MsTrM4QxlhNUBelqayeTrnOatEJl+ESL0iGtTrdf0AEGjJxRnnCkW66EUb
bXnNFImLX7Lz7MtiCcBfKwEkkKuWXFjqfLFE173ppO1umKbi+rdcVK7WeJlleLagLtA0EgAi9ZKrG/D
uDz20883TNbn3Y9+orNGghgatVks/ACSSWEP+xFd+2vP2GV156psgZqqGxaDdbusHQCNBjlIUBfnLPw
Zpv7tmqtjnToDval77JUItWSgyMDBgLEDnzN8zR+nge3sVjn/UQgfe3UMHsAzgO/4T9/l4chJCFYODg
wYCVCoq+RfT4nTEg30KV7/homi6TIfe26MikavRNW+6Vft+15sZfwA+jRT5T70JOu7Rfjr2kVGe/z7E
p9loNx/1YK8KCBrNNr34Y0TZH8ea7DlJDIaGhvQD4HyFfII9Tac+aaWTHrfQmc/Y6FdrGp0syVHfE3l
wAWWGwOzI4RgcK40x3Vs0HgDnMPrglvc8dPrTNrruLTcFEiWVHA11xlNWFRCIM41m63TDO15pjNs+8E
n/DQ8PGwuAy2aPN0ufTYlToVztlEuzveB5m4giFqm3BuirGWlyRKuQGwxQr1NFvozioqIlB5e/7BARx
RB1YjwApLiOI0h9DDlmez2fDQJjikdGRkT0A0Ba05h95+l0+3seka7ScQWAVG/24ImvgiJjicdfggZL
hdlryrHcr/0clXj9lxjoFGMf5ThMArSNXIqbcoBus8fgn05OKnw2JdUpxhg4naV7Si6Xk4glUy127KM
bQKi9phz83J+R+MWSBYocgSFOp9Pkdnuop7eXenp6yePzDx174olPs2Nx/QA6yw+muAsSUz1FgPpiP+
mZ0O/3k8lspqlTp1EgEKBAMEgnnXrqWzz+CvAYCtBt+YEtXFaBbTh9Y7EY/W4yyYHaFA6H6aQzTvuKx
14dDiMBxqy/eKFJFJrkT9UAvmN/6Tmi32KhSZMmKfIzzzvXxONuxMxuLECrJQbQnH3n+Y7/cO3A7E3m
CeT1ekX5lswcosdwAMiNBMA23D98Pq69yUx+rvuJp0nLvpEo/1cC4IVt6Hx0vcXhHDrhZKnhVheX3Wi
AA/hG1NJbeq0VKJUr5HD7Wsccf/zTWt1uNMByzKHMicwpzOnMWcy5zPnMhczFzKXMZczlAvh9JrM1s5
a8AiszyzNLM4sxCzHzimWZJZ6dmVPeuDCzOLMMs6I80DrMxnJDbc/swuzF7C+zD7MHsxOzDbM5syGzN
rMqVkQIsQAz96wQfwDZFf57mN2+ywAAAABJRU5ErkJggg==
'''

icono_cols = '''
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
'''

icono_row_merge = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAFM0lEQVR42r2WA3BrTRSAn23btm3btm3
btm3bZvBsNk7tNs5tyvOfc2ezk/erbme+2ay/3e6eu6kAINF80n3Kiek/oPLY+qbqf+VK2jHbpBNGbH
2u6bH4lqbRuKOTsTBtfAR+qb7UxLRmRETEH1B5rAJDNj6bsPOOUlD7muGdMgjGbbovVOy7Y5KzAf6lR
VLzPPvtym/llx6Y9nA47JyoqKge339TeWw7sPq++qeHEcz2CJC7BcGdd55Qqc8ODzZZBaQmUhnJjORB
iiA5mFgWJMNP5ZcZADBDEKwc3IEZn75/mBGrQIfZl9SyX/7w4mcgXHrlCbfeeEC5nludAuWYQCUkE5L
bRSCNU+Dbr0/bo6Ojt1tsFg4KbH//Sb49VoHaww5OHrnujnD7nTg59J53XijZZdPU/9hynnfl47e312
JiYq5ZLCaOI9xx7dXbl9diFaADV6bH5kkVem3zoJXT5K1arUoXn0P45qP8U3RM9Cej2cBxOByfXsief
YpVAAASjfT1i2D8FwQbjMGcsDAh+NHzB8EpIvBM8hhQAEJCgziCYId7j25DisSBh0/viQJBIQEcu2CD
m3evQVLEgdTOlF29NAx+GO88uCkKBAT5cWx2K1y5cRESGwcqIRWRdEgZpBS7hvmRnEwq3fXbV1AgCvw
CfThWmwXOXz4DiY0DZRlp2eSuAjmcApeunYcoFPDx9+JYrGY4dfY4JCoOxJWzF08Bhl7w9vXgmC0mOH
riEKRIHDhx5qgo4Omj55gsRjhweC+kyDU8fOyAKODupeVgMILd+3Ykj8Dx43uLHD56YMLhw7tLUH7fw
d0oEAk6DzXHYAqFbTs3iwI7dqwrumnrhvEbN24sl6g4sGrVqnSHju6/L5G/iNZ76OH6rStRuMprO/ds
g0gU0LgrOaHGENi4ZT1s2LxWduny+RiNVgX3Ht6NXrdh9TkcJ0ec44Are/btHOPp5QFCmB1MuMXhEeG
gd9fClu0bRQG1zo0TagiGNetXgUanFttRewzPoNVpYOnyRZPiGgfKIxXYNSy9Z/8ufZgjDLdYBUrNL9
DoFSgjgA4lIiMjqYxD4ViLk1M9taMy6kf9l61a+j6uceAPgc3bNrynAT289eCm/iGi0v2mHSEBynOsN
jOVUz0vU6OIj68XzF0w+0eC4sC6DWvmfPj4Vjxgv1Xf4ZfyG0Gro8l4nggMCaBynqf2vgHecOnKeZg2
c8rYeMcBdgjzLV+1RCF7/VIcjFZFAxNqrRulHIX6J/9N7aj9s5ePYdKU8YpRo0ZlSvA1nL9qfqGFi+e
hhBQCg/zx/6vEQ6f4T6ie2j2XPIVxE8YoRk0ZVSjRcWD+/CmFZs6doZCjBB02TzwTHl66f0DlVP9S8h
xGjRlBKy+U6PcA+xSn79OnT8nJUyeq5K+kYMQz4efvDb5+XhzKU/lL6QsYNmKIqkuXNiWpH/WPz3uAw
d8DGdjzPBuSo23bZhXGjB+tlr+WiV+/4JBACAoOoFTMS+USGDSkv7pBgwYV2NczK5KJiaSObxwoR79Z
WgYpiRSoWbNm7aEjhmjkchnY8RFiNIaKqVQmgX79+2iqVatWg32+c7tIZETSxTMOcIGyTgE2cNGqtar
W6j+wr1YqfSm+BSW47X3799ZWrVq1FtYXRPK5CGThAvGJA7RlbOsysVVkZ6+iwiRXsmTJ1n369nLfu2
8PUEp5Jl2YtcvuOjmNl6D3gPMVxAbKzFZXEqlUokSJdj16dZNQSnkqZ/WZXSdO3DX8753JzLY4D0szu
x62/+IvE4Q1eXkwnqwAAAAASUVORK5CYII=
'''

icoremove_col = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAABmFBMVEUAAABVVVW4uLgAWPAAWfAAX+4
AY/kAZPEAa/8QbfITb/Efd/IgdvEhePQ0g/Q1hfVEQ0FISEdMTEtMk/dNTEtPTUxQT05UVFRVVVVWVl
ZXmPJZWFdZWVhZWVlZmPJZm/hbVEhcVEddnfldoP5fX19gWU1mpPhqaWltbW1vb290dHN0dHR3rfl4c
mp6enqEhISGhoaHuPuKiomPi4ePjo6Pj4+Tk5OUkYyUkY2Yw/uZwvuampqenp6fn5+fyf+jo6OlpaWo
qKio0f+urq60tLS1zfC5ubm5z/DI2fHK2vHS3/HT4PHX2+PZ3eTf5O3h5e3h5e7i5eri5+7i5+/k5+z
l6O/p7PPp7vfp7vjr7vPt6OXt7/Lt8PXv8fTv8fbv8vrw7enw8fHw8vrx7+zx8PHx8fHz9fnz9v319/
r29PL38u/39vT3+fz3+f749fD4+fr59vH59vP5+Pn5+fn69vD6+/z6+/37+PX7/f/8+PD8+/j9+/j9/
v/++PH++/j+////+fD/+vD/+/D//PH//vn//vz///H///9x83UFAAAAA3RSTlMAAAD6dsTeAAABm0lE
QVR42mIYEoCJoCygmXHargUIgmhs2+aNbds5tm3bnN++3cd+Tj1U9dqz1zw/YrBLFhulB4K5xSrbBxT
uiMPhIFfYpUvuULgJmUwmckGgSzd0g8Il8fv95IR4fT6fl5xm9iRFL1G4Ojw6Ojq4P0/l7PosfdwfAD
28QuGYOJ3OxB4pzl4CKDlG4VAvEAh0m3Fr3GpLB674pg6o/hCFfTWPx9OsBN4nzIp0zBPvgRUNUPU+C
jtupVIZnH3qeAt77HKF3O4Jv3U8zQaBundQ2P0H2Vps7ursGVywK+wLgz2dXc2LW4h3Udg2CIVCV+9L
x2vuh9eOl14XUMM2Cht8Op0u645+zBgV6RhnPqLdMqD8DRTWORQKRdwekRglGQGuSLsYKGcdhVUVk8m
0tCZjhUm2WoCqVlFYm5qcnFxua2rENLVktm0Z6NQaCksiBoMhndNyMdr5zM5JgYqWUJimfn190YZp0K
VLnUZh9Ofz8/O3/xe6dH9GURhhfX9/s/vY0KXLGkFhfGhgYGBoDLt0h8bhvb6uVurr/kL+A2G1wSXGp
7iQAAAAAElFTkSuQmCC
'''

icosalva = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAA
DdgAAA3YBfdWCzAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAARsSURBVHjaxV
bfbxtFEJ77bcd2Yrd2EwoRIgWpou+8IPEU3vg3gGfUlwqJ8gStVPh/kIoEqiq1b0U0hCKI1CJMjJPac
bHv7LvbW2ZGHvt6d+ZCpNJN1jO7dzfz7Tff7p2ttYaX2ey5NbDrlwlAX/3k6u1Eq1258M6778HOm5fB
sSyADEuGgXixm6YB/GdI54ts8RLPxVEMez/9CPfv3gFprda5b69/fv39NADw/cnuw72HsLm5BRc6HTj
fuQDb26+D5zqFyI35D1lOnvUZhAH+LIR+/y+YBlOQNkie7mYZgCRJoFargWPb7JugeYW/90+KkmcYeX
5gpAC+1m6g0RwznasQQHOjCZ7nLW6wLAPeerXFvs4m50TiiuU5YYDHwTSkS+UAaDc0Gg3x+WELGXBtC
1bVYDUAiYnJqEPCMVO5ChnIxNcIAG+wJZpcKC6DngemhBp/0OVxrBSP0/GVUuUAEo248cGfHx9DUdOZ
kdbiMRKyPNg6VwOV5EpQDoCGET65vbn+vMg0/+foJKMZyIIN7rNQ0YpPp4F0U3ECMXUrDawAiKxekrJ
d+jEuQmn93zUQJwoi2o6RWl0CWb24OQaANaASfYYS4NNRrKB3PJapMi1I7cXl1lr3kE1VLkKdAaBwTP
Q1GxXZV/lEmWQ8u2CAHYrBydPxdSEDOQ1oFqFRsnSdBiXjpQbIYQ2k4yen0cC9u99Dr9eDwPdL6C8Wp
jBTXVuDJ789ymqgvARPfv2FevoNmLXFoFYrvqQEOPmCW8lJ+P99GRVrQL9YBs5egg8//gje2NkpquXK
Og8GA/j02jUwTRPjat7Sm53Ov4swjuNcYK/isYrH47EILyswmpMuc/xhU6lUwLIs3gmz2SwfXwAUCUN
a+gS78cWXdKzK5xvcuHmTViig6D6yyxcPgzKJb/qyovkzAJgHpfb0+BikDU9OKAkBoJWRz89L18sXgp
hTACigKJxOKXgWIFFL80Qt2yiKZOXCAmcVMJqAISPVahVq9ToMB4MCDRQgtGy7EEAURxAEgSQgK0yQ5
R4iKHsONKYYdK8BEOCiojg+HQPguhx8ig/FcSTlICvCotVLCSQ5X49CZIdFqPmFZKC1TQuejUbQWF8v
AqB6YMBW9sQK/AAcx4ExWn8yYV00m00Uoi9Jhf6FDcMQgdF8shCoOd+e7XYb+v0+GNg0NgLAg8uXLn1
Qqda+wVK10/tV6L711S0BRZ22JgXPiVD8KAxZKwLAMk2e73a7LF5sVUwbCAPGo4ODPzc2Nr7efuXiZ3
jDGoswCjmRPddCasVEvdCfPowouGgqXTLRkIxDNOexdwUA3fn3aDT6DpXf8jzvIhHw9pUr1g8PHuy4r
uu5juOiKF0E42ESh8onh4DmLzgVIpgwwjYcDn3bsQ8ODw9neNVY5mC6FTr30Jth5xJwDIzlo7sXzGaP
sTs0/Ue3m1Sq1RBPtUTQI0uAQIkVjcA01bVerxt4+hmE5wTPiP39/eTo6MidTCYUx8x/NXDyZ5T3H9+
a0pP7Q7enAAAAAElFTkSuQmCC
'''

icosave_space = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAArlBMVEUAAAAMau8Tbu9Jj/SBs/gODg4
AAAAAAAAAAAAAAAAAAAAAAAAAAAAtgPYAAAA8ifjy9fXLzNPb3OHc3eHr7e/r7fD5/Pynp7Wzsr7CxM
rDxMrS09mwsL+7u8fk5eny9PfLzNPLzdTz9ffb3OB0d3d2d3Z2d3doaGtwcXE+PkZHkflISFA4hvX//
/8Pb/UPb/YrfvU5hvVIkPhXnPxlo/1zrf6Btf+wr6+wsLD///8OwP+NAAAALnRSTlMAAAAAAAUHFyQx
PktPbXZ6gYiIiIiIiImLi4uLjY6Ojo+Pj5CrsLCysrq8weLpRxdpaAAAAK9JREFUeNrsj1OCA2EMgLM
a21zv1u0Y9z/YMLXb134/4wRWPHiC5+Z+WckoPDcGZA4gjmGLsdipkQXw0+7zurpgyjfqtUMQRnGSZt
CRpUkchQE6nM9wvofzU9zP2zkDMSvKMt/bxYxAB9L4laQD2yDRgfr+0/Sh3mztf/PzQaED/T5QXNVtt
rL1+aHRgfmSHbvD2fp8MujA+tZBfBYdOM88iMehgzCaHKSeJt+wyRMAmwYeYF9t7OIAAAAASUVORK5C
YII=
'''

icotrasf_row = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAFkUlEQVR42u2UA3QmzRKG69r2/W3bNte
2bdtWrFWcLGLbWdu29zOGn+b9eyZe2885z6mufgfVE9C9TURl9aX2f4j4w7o30960/TnqL5vJj3pq+6
F0Cwig1IarGiP2VKRyRDiMJSeilDbr24ACqfRWvLzjIzmPYOzRfui39juo9F/zA8Yd7YunC58G+zID6
aayiDb8IeZXyrtxf8E7IQSV91l9P/5P+FPMbxSWbyWV3NxcuinEktkrzwsXIrggBBRDXPW1+fn5yMrK
QmZmZh3VvWvJCjIK8GzGs6C5hH97Ef44iKDyZ1b/602gWaRQIu2qHiA5ORkXY8mSJdeUDcgdAEojPJx
HeCyUoPLEQrYuIFAGM4EmVA8QFxcHFZfbXUeViIiIa8pKs0vxavmrHkoikB9h8fpFIF8CJTMz6RgR/a
bmxxUbCxWn01VHlYULF15Tlp2VDd1Rg/HjsE8MP0v5mZPUk6eRTJOphIheoYfpZzX/M0LnowJXXRnBA
V7XlGWmJoKhO3n0cHnIDP/Urj90jGrxaRP1sz9B9POfU218i4EpKTzGJwua09J4TEgRNOcWApPT2H5l
PzOjZj23CJiWXtFPYdfMqMi0Z8zKg+KT71RCy522+I3u3VnbXfEFu93D/UrxLzqX8SkeRJ/xwOWQNOO
OuLDmjAMqgV4zsXgPAJcEWZYxvEjGcbOkZcHeMzEi3wN4JBwzSqi/0M6ukVjvQWbKcoSudFgUl1x8xi
QtHBVvXyDIji/pQoxMcsIqSgg44IbIi3BKIqL2OrHOAMyYNg3rdUDARhluti+JIvpkSTjNVWQbTgJ92
IkVh4jjBhGf+trgcHoQtywWPoUOMWYVt9UpS0FGizRgRjrXkz4F/aGvnqoB0C5lsxtQHDhlEeG9xw2e
FyCLAiIOADOnjGeZguIjInzWymw4AYIgoFcOMGfqBHZYBSX7BHSL4+F2CDiqE/Cxj4SEpZGA4tZ55dj
2LVnNJThkoQ8U4dOf97B8Xfvl7ydscB2idmZkbJXhYqewCgKC9rvYmgfgwYyxA1F2FoCbx7qTAvw2SI
CrJttv9Gj92sM8ui+za9edMDmQEB0MTnDqoPD5i0tsafFr7ZO7hNom/LaPFX8ewDUmlR0n3ev+OciKx
0balL8PsCJnhxPf+Ql4czKHlyep8vjED3hzCo+XJ1bsvTOVw0sTa7LXK7NXWPb+DJ5lLGfZ+/OgfDyL
U5oFc9aeUfYdA5ZwOf8basPjo2yeJ8fY8cwY+/PUIVSwrjvs2so2seuUG51CBZzLkL5dUM1VZIuD5im
oRa8YESv2Ocpfm8Lhran8B+9Nt5cRgHHMJW+xU6l4XDKMZhv0RqsmLzrRu1cvtVbvXUlm4yT4+fqqVa
czWgp0BouPR7KwaQ0v/uAvgWoDILBegACVMwYLdEZrtRx7eJcunbXK+ivOrGwAr3nz1AH0bIBSZgizH
2B+q3Ok6/wBukdJuBhd2za6psx/zgQwlEo9TJG5ZESS+/wBpmQ64F8gg+qZQI3NtbSAekKrrL+KjNlZ
UagJq83PgNoeUv7ebffZp/psSJqZi/MGCA4sVQBFhmgzQLIbq4VHRtum32iV9VeeOXl4TR8NuASdWzA
WQDL5wGntBlheWLbp/AG+XbuqDFa7iOOnDThRS7NNRr16P2j1xFVkejOH8ePHwmDm9awvYQYz+57Smb
V/xXEb3dUv12rXrl21m46d0tfRaBXw7bdfa/VqMp3RjtGjRkJnsutZX8IMZvY9pbd8QReiffv2EB2A0
SLUUXYBn3/+GatXl/GSguHDh0GQFT3rS5jBzL5mu6wN0KJFC6pDu3btUO/HH/Ejs1491Zo1u5jVq8u+
//5bzJ07V60O1m9g++HMIfXr1/+qZcuW/6HatG7dmm4tNV/gAQ/4CURiEpbcw7InAAAAAElFTkSuQmC
C
'''

setico = {"close" : [icoclose, "Chiude la finestra, i dati non salvati saranno persi"],
         "conf_col" : [icoconf_col, "Conferma punto inizio colonna"],
         "def_col" : [icodef_col, "Attiva definizione colonne dati"],
         "del_row" : [icodel_row, "Elimina testo righe selezionate"],
         "edit_csv" : [icoedit_csv, "Attiva la modalità di definizione dati CSV"],
         "ignore_col" : [icoignore_col, "Ignora la colonna selezionata nella  definizione dati"],
         "make_csv" : [icomake_csv, "Trasforma le righe selezionate in dati CSV"],
         "merge_rows" : [icomerge_rows, "Associa le righe selezionate alla prima"],
         "no_cols" : [icono_cols, "Azzera le impostazioni di colonna effettuate"],
         "no_row_merge" : [icono_row_merge, "Scioglie una associazione di righe"],
         "remove_col" : [icoremove_col, "Rimuove la colonna corrente dalle definizioni"],
         "salva" : [icosalva, "Salva il testo corrente"],
         "save_space" : [icosave_space, "Conserva/elimina spazi interni alla colonna"],
         "trasf_row" : [icotrasf_row, "Trasferisce righe direttamente, senza elaborarle"]
         }


class IcoDispencer:
   def __init__(self):
       self.icoset = setico

   def getIco(self, chiave):
       return self.icoset[chiave][0]

   def getDescr(self, chiave):
       try:
           descr = self.icoset[chiave][1]
           return descr
       except:
           return None

   def printData(self):
       for chiave in self.icoset:
           print(chiave, ':', self.icoset[chiave][1])

   def getData(self):
       return self.icoset


La classe TextUtility


A parte gli import, la classe TextUtility è l'unico elemento al momento presente nel modulo "textutility.py", ben 625 righe tutte sue. È un oggetto tkinter.Toplevel che a parte la miriade di oggetti presenti nel paio di tool-bars visibili sopra in figura presenta solo un controllo tkinter.Text ed un paio di scroll-bar.

TextUtility può essere utilizzata in due differenti modalità, da definire al momento dello istanziamento, la prima modalità "text", valore di default nel parametro type di inizializzazione, viene data all'utente la possibilità di editare e salvare il testo come tale, ovvero di avviare una impostazione per trasformare il testo in una tabella dati di tipo CSV.
La seconda modalità è, appunto, "csv", in tale modalità la maggior parte dei comandi saranno disabilitati, l'utente avrà ancora la possibilità di editare il testo per eventuali finiture e salvarlo, potrà inoltre stabilire di utilizzare il carattere di tabulazione in vece del separatore impostato ... e niente altro. L'area di testo avrà un colore giallino, il file prodotto sarà codificato utf-8, non in ascii.

Si è sfruttata la capacità dell'oggetto tkinter.Text di definire alcune caratteristiche di gruppi di caratteri, tramite dei tags, per costruire una interfaccia visuale per l'utente finalizzata a facilitare l'individuazione delle impostazioni dallo stesso effettuate per successiva elaborazione, cosa non sempre semplice.
Detti tags si limitano a definire i colori di background da applicarsi a delle "aree" di caratteri del controllo, vengono definiti alla inizializzazione della finestra
CODICE
class TextUtility(tk.Toplevel):
   colours = ['white', '#f4ef93', '#D5E711', 'lightgray', '#92BDFA', '#FA92A5']
   def __init__(self, master, type='text'):
   ...
       self.pdf_text = tk.Text(p_text, font='courier 10',
                               selectbackground="yellow",
                               wrap='none', padx=5, pady=5)
       self.pdf_text.tag_config('even', background=self.colours[0])
       self.pdf_text.tag_config('odd', background=self.colours[1])
       self.pdf_text.tag_config('r_merge', background=self.colours[4])
       self.pdf_text.tag_config('not', background=self.colours[3])
       self.pdf_text.tag_config('no_space', background=self.colours[5])
       self.pdf_text.tag_config('defcol', background=self.colours[2])
       self.pdf_text.tag_raise('sel')
       if self.type == 'csv':
           self.pdf_text.configure(bg='#f4ef93')
   ...

Lo stralcio di codice vi renderà evidente che self.pdf_text è l'oggetto "Text" destinato alla manipolazione/definizione dati da estrarsi dal file pdf originario. Non indulgerò qui a spiegare come saranno utilizzati i vari tags configurati, se me ne ricorderò li citerò nei momenti opportuni, altrimenti basterà valutare il sorgente, salterà agli occhi.

L'approccio utente ipotizzato


Questo è un aspetto che mi ha fatto perdere un po' di tempo nella definizione della user-interface (sarà giusto come termine?) che si sta trattando, per esperienza so che nessuna metodologia ipotizzabile può mettere freno alle corbellerie combinate dall'utonto comune. Alla fine ho optato di stabilire una serie di "step" logici cui attenermi nello sviluppo.

Detti "step's" possono essere così riassunti :
  • è presente del testo : l'utente può editare e salvare il testo, inoltre, potrà stabilire di passare ad una editazione CSV, cosa che renderà disponibili quasi tutti i controlli nella toolbar superiore.
  • è stato stabilito di produrre un CSV : viene aperta una nuova istanza di TextUtility di tipo "csv", destinazione del testo elaborato, l'utente può ancora :
    • editare e salvare il testo;
    • selezionare una serie di righe e cancellarle;
    • selezionare una serie di righe e stabilirne la fusione (senza immediato effetto);
    • potrà selezionare delle righe ed ordinarne l'elaborazione diretta;
    • Attivare la definizione delle colonne di elaborazione.
  • si è deciso di definire delle colonne dati : vengono attivati i controlli nella seconda toolbar, dedicati alla definizione delle colonne dati, l'utente potrà :
    • definire un punto di divisione colonna, che creerà due nuove colonne sstituenti la precedente;
    • eliminare una colonna già definita;
    • definire una colonna come "ignorata" nella elaborazione;
    • Stabilire l'eliminazione degli spazi interni al testo in una colonna.

I passi sopra possono sussistere contemporaneamente (tranne l'elaborazione diretta di righe quando attivata la definizione di colonne) e sono controllati tanto dalla presenza o meno di testo nel controllo self.pdf_text, che potrebbe anche essere cancellato, quanto da una serie di variabili di istanza per controllo di stato o contenzione dati
CODICE
self.type = type
       self.state = 'no_work'
       self.edit = False
       self.defcol = False
       self.maximo = 0
       self.columns = []
       self.merge_rows = []
       self.del_spaces = []
       self.csv_win = None

variabili che vengono manipolate nei vari processi e valutate in un metodo di "auto-valutazione" della classe che provvede ad adattare i comandi disponibili nel contesto corrente
CODICE
def _evaluate_context(self):
       self.bt_save.configure(state='disabled')
       self.bt_edit.configure(state='disabled')
       self.e_sep.configure(state='disabled')
       self.chk_tab.configure(state='disabled')
       self.bt_defcol.configure(state='disabled')
       self.bt_merge.configure(state='disabled')
       self.bt_spaces.configure(state='disabled')
       self.bt_delrows.configure(state='disabled')
       self.bt_trafrows.configure(state='disabled')
       self.bt_makecsv.configure(state='disabled')
       self.sc_columns.configure(state='disabled')
       self.bt_confcol.configure(state='disabled')
       self.cmb_col.configure(state='disabled')
       self.bt_ignorecol.configure(state='disabled')
       self.bt_removecol.configure(state='disabled')
       self.bt_nocols.configure(state='disabled')
       self.update()
       if len(self.pdf_text.get('1.0', 'end-1c')) == 0:
           self.state = 'no_work'
       else:
           self.bt_save.configure(state='normal')
           self.state = 'work'
       if self.state == 'no_work':
           return
       if self.state == 'work' and self.type == 'text':
           if self.edit:
               self.e_sep.configure(state='normal')
               self.bt_delrows.configure(state='normal')
               self.bt_trafrows.configure(state='normal')
               self.bt_merge.configure(state='normal')
               self.bt_makecsv.configure(state='normal')
               self.bt_close.configure(state='normal')
               if self.defcol:
                   self.sc_columns.configure(state='normal')
                   self.bt_confcol.configure(state='normal')
                   self.cmb_col.configure(state='normal')
                   if len(self.cmb_col['values']) > 0:
                       self.bt_spaces.configure(state='normal')
                       self.bt_ignorecol.configure(state='normal')
                       self.bt_removecol.configure(state='normal')
                       self.bt_nocols.configure(state='normal')
               else:
                   self.bt_defcol.configure(state='normal')
           else:
               self.bt_edit.configure(state='normal')
       elif self.type == 'csv' and self.state == 'work':
               self.bt_save.configure(state='normal')
               self.chk_tab.configure(state='normal')


Volendo visualizzare la seguenza di attivazione :

Stato iniziale dei pulsanti
png


Testo presente, attivi solo il pulsante di salvataggio ed avvio impostazioni CSV

Impostazioni CSV attive, definizione colonne non avviato
png


Manipolatori di riga attivati, è possibile trasferire direttamente singole righe, p.e. di intestazione, impostare carattere separatore ed elaborare intrvalli di righe, avviare la definizione di colonne

Definizione colonne attivata, almeno una colonna definita
png


L'attivazione dei controlli di manipolazione è completa, sono disattivi i controlli che permettono di avviare le varie "fasi"


... certo, quanto sopra è molto schematico, solo la punta di un iceberg, sarà bene vedere singolarmente le funzioni dei vari controlli disponibili e, quindi, come sarebbe bene procedere nel loro uso (magari è spunto per una futura "guida utente") ed infine come interagiscano nel loro contesto.

Intanto vediamo i controlli interessanti

Controlli e loro funzione
ControlloFunzione
pngPulsante Salva dati disponibile nelle modalità "text" e "csv" se vi è del testo, permette di procedere al salvataggio del testo in un file
pngPulsante Attiva CSV disponibile in modalità "text" se non è attiva una sessione di definizione dati csv ed è presente del testo. Avvia una sessione di definizione dati csv, il testo presente viene "regolarizzato" al numero di caratteri presenti nella riga con più testo
pngEntry Separatore disponibile in modalità "text" permette di inserire il separatore dati da utilizzare
pngCheck-Button Tabulazione disponibile in modalità "csv" se selezionato il carattere separatore verrà convertito in carattere di tabulazione formato UTF-32
pngPulsante Attiva colonne disponibile in modalità "text" con attivata una sessione di definizione dati csv e modalità di definizione colonne non ancora attivata, avvia la modalità di definizione colonne dati
pngPulsante Cancella righe disponibile in modalità "text" con attivata una sessione di definizione dati csv, cancella le righe selezionate, o la riga corrente, senza chiedere alcuna conferma
pngPulsante Trasferisci righe disponibile in modalità "text" con attivata una sessione di definizione dati csv, trasferisce le righe selezionate, o la riga corrente, senza alcuna elaborazione, le righe trasferite vengono rimosse dalla finestra di definizione dati.Da utilizzarsi principalmente per le intestazioni con spazi interni quando i dati sono semplici e regolari
pngPulsante Unisci righe disponibile in modalità "text" con attivata una sessione di definizione dati csv, accorpa un gruppo di righe contigue e selezionate il cui contenuto verrà fuso come unico dato, ha effetto solo in concomitanza di una definizione di colonne
pngPulsante Dividi righe disponibile in modalità "text" con attivata una sessione di definizione dati csv, scioglie una unione di righe già definita
pngPulsante Elabora CSV disponibile in modalità "text" con attivata una sessione di definizione dati csv, esegue l'elaborazione dei dati per impostarli in modalità CSV in due modalità differenti:
  1. con modalità "definizione colonne" non attivata vengono elaborate le righe selezionate considerando gli spazi quali punti di divisione dati, le righe elaborate vengono rimosse dall'area di testo;
  2. con modalità "definizione colonne" attiva vengono elaborati i dati, per celle costituite da singola riga o per gruppi di righe ed i limiti di colonna come impostati; gli spazi interni vengono conservati, gli esterni eliminati, in caso di unione di righe viene inserito uno spazio.Il testo elaborato viene conservato per eventuali verifiche
pngPulsante Chiudi finestra sempre disponibile, chiude la finestra senza alcuna richiesta di conferma
pngPulsante Inizio colonna disponibile in modalità "text" con attivata una sessione di definizione dati csv ed attiva la definizione di colonne, fissa un punto di divisione colonne corrispondente al valore nello "scale" limitrofo, segnala errore in caso valore corrisponda al primo od all'ultimo carattere
pngPulsante Attiva/Disattiva conservazione spazi interni disponibile in modalità "text" con attivata una sessione di definizione dati csv, attiva la definizione di colonne ed almeno una colonna definita, è uno switch, vengono indicate con una barra rossiccia le colonne in cui i caratteri interni verranno eliminati (anche nelle intestazioni)
pngPulsante Ignora colonna disponibile in modalità "text" con attivata una sessione di definizione dati csv, attiva la definizione di colonne ed almeno una colonna definita, la colonna su cui è posizionato il cursore viene segnalata come da ignorare nella elaborazione, detta colonna assume colorazione grigia
pngPulsante Rimuovi colonna disponibile in modalità "text" con attivata una sessione di definizione dati csv, attiva la definizione di colonne ed almeno una colonna definita, la colonna su cui è posizionato il cursore viene eliminata ed accorpata alla precedente
pngPulsante Azzera impostazioni disponibile in modalità "text" con attivata una sessione di definizione dati csv, attiva la definizione di colonne ed almeno una colonna definita, Tutte le impostazioni di colonna definite vengono annullate ... in caso di pasticci per ricominciare ;)


... beh, direi che questo post è già molto lungo, interrompiamo qui, nel prossimo riprenderemo da quella che "sarebbe" la modaità di utilizzo.

Ciao :)

Edited by nuzzopippo - 2/7/2021, 08:14
view post Posted: 20/6/2021, 09:41 [Python] GUI & PDF a passetti 2 : testo e csv - Appunti di apprendimento
Come detto nel precedente post, ora si sta mirando a definire dei prototipi di GUI capaci di estrarre il testo da un pdf, per poi cercare di affrontare la "complessità" del far impostare ad un generico utente le condizioni di manipolazione del testo estratto finalizzandola a creare dei files tipo csv.
Per realizzare quanto sopra ci si appoggerà alle librerie "pdf2image", "pdftotext" e "pytesseract" alla cui documentazione si rimanda per la installazione/impostazione nel Vostro sistema.

Un venv minimale


Un virtual-environment minimale sufficiente alle esigenze del momento potrebbe essere analogo a quello sotto esposto:
CODICE
(pdftk_v) NzP:~$ python -m pip list
Package       Version
------------- -------
appdirs       1.4.4
pdf2image     1.14.0
pdftotext     2.1.5
Pillow        8.2.0
pip           21.1.2
pkg-resources 0.0.0
pytesseract   0.3.7
setuptools    44.0.0
wheel         0.36.2
(pdftk_v) NzP:~$

...non dimenticate che io opero in ambiente linux, fate attenzione per eventuali esigenze "altre" che possano essere necessarie per il vostro sistema operativo.

Comunque, il venv sopra esposto è mirato all'uso con il framework tkinter, non dimenticate, inoltre, di creare manualmente la struttura di sub-directory "my_tmp/pdfcache" nella direttrice della vostra versione (ammesso vi sia qualcuno che provi), al momento non viene gestita dallo script, il perché lo vedrette quando si affronteranno "operazioni massive" e la strutturazione del codice sarà molto diversa, per ora la situazione file/directory funzionale è la seguente:
NzP:~$ tree
.
├── global_ico_and_tip.py
├── mydialog.py
├── my_tk_object.py
├── my_tmp
│ └── pdfcache
├── pdf_viewer.py
├── text_ico_and_tip.py
├── textutility.py
└── viewer_ico_and_tip.py

2 directories, 7 files
NzP:~$

dei files contenuti "global_ico_and_tip.py" ed "mydialog.py" li trovate sopra mentre "my_tk_object.py" lo trovate nella precedente serie di post collegata, è lo stesso file, copiate il codice da li.


Il "nuovo" viewer


È, invece, cambiato, rispetto al primo post della serie, il file di risorse collegato al visualizzatore di PDF, è un elemento che cambierà frequentemente, così come cambierà il visualizzatore stesso, che verrà "arricchito" (od anche "impoverito" di) da funzionalità occorrenti in un determinato momento.
Il file di risorse del momento per il viewer, "viewer_ico_and_tip.py" ha il seguente contenuto:
CODICE
# -*- coding: utf-8 -*-

icoexec_ocr = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAFcklEQVR42qRSB4saYRDNDwj9BPwJ0sQ
Tk6igWCkKSLMg0qUK/tMLnIJu0JwUKUbXtu6ujZd5w+4RQgwfyYPH8C1T3rzZDyGenp4+SngWpv+BX4
QFg7znSCTCOX/EJ/wHrtcrTCBzPj8SkINgs17D3mywFe63W/xYrXDY7XA8HPB9Ptf4zv1e6RyPmM9m+
u0gb5K1e9tWspctfQMB+b8K2LJIBrIJG1LA0XHgui5Gr6/wJJ7k/Ss9z4M1nTKHNRTE+LsgMwFMZlP3
dIIvDVcigAPuYvF4PMYjWJMJLr6PtWwailQhZCDESIDaKVZPZaOZ2Pr15QWLtzd1JRqNIpvNIp1OKzO
ZjDIej2Mi+cz5ZlnYyba7wMXQSdJYADfg1tfLBcvlEpfzGUQikUC320Wn03lnu91GvV5Xkff7HZYIOE
s+63kSOnkSOo5jfgJuoD+jbWOxWGgzIhaLoVqtolgsolwuo1Kp6Dufz+tgYjQa6UA9o9R5FCHkNyMBV
MwiPxiay+XQbDa5pd7zEVKplDqSTCZB+L6vpHjSNRYQ2O9JMdFoNDAYDNDr9dTG2+0WkpZrJEqlEobD
IQqFAp88AxkKoRtmAjg8LCJqtRr6/T5arRaIn51XgUeccRh229CpoaJaxUIUQkvszF0LqEMxDINyVdW
OuxEKBZRK/0VINAIdJEQntG7doeKm73bftbtrlRHTbeN79z6v78e3067vvpfH6lu+9/m97/M+7++T5D
wRSK6AQCtmZmbQnnsJ3DklkGJxqUAyOB5QXAG0aH5+/h8Cv1nEeA9+LouAYq5EhTAMg0oFdLK4uKgII
DlgrYQzAltbW+IH2WyWhoaGKBgMAqKLUCgkmJ6epkAgQAsLC+Tz+aQqP2DFPFEQLrQDbdkigMSqzIhY
LEYq5ubmaGVlRYGWl5dpaWlJfgaR1dVVjGTxFJQ3hhcXF3R6ekrJZJLS6TTt7e3B1VBOMaGxsTGamJg
QTE5O0tTUlFRjZGRENOD1egmBUwPKhFAJWwRQfqhciWtgYEASDA8PU6l4yxqYnZ0V3xAClh0Ai4a52d
cAlw5ADA4OUjgcVgSEoAAlNp1O/R3agAoYPB3Yqljr11dXslFzuVwZY2gC4ff7pdRQuSJg7e2tWdr+/
n4aHR0lj8cj/5fL5yUpdkkmk6HU+bki4ClNADZsqQAsFqfr7e2le8Mcz+7ubiGLffGLW3jDp//GevrC
E3RyckLx42ODdZXlPM8eJHDLvcMLsNuxZNBHkOro6AAhSfaiq4s6OzsFra2ttLOzQ7u7u7S2tkb19fX
U1tZGLS0tlEgksN4NXmyFqqoqf11dncuWD0D1cDqUUAVmHNUAIE7YdF9fnxD7XxgcKU37466oeMPJHy
PRg8sII5PnHmIVR6NROjo6Ej+orq6m5uZmamhoIL7dEr9QTutyuQihtIEKXrPwvmqakeDSc4Xe1dTUP
EESWwTkRQyM4hWrGP8CxYEZ13WdIpEIqerh/gjl6+m0kTw7Q9UCxcltrWMARHAnLHBL8ByAqcCYkARG
9ZmvbOvr64TA3N/w84yuGxqLj/fCOPe9RPJSFxKzErgVFSzXK7QHBDDbQoBbs7GxQepKn+GTp1Mp3Jb
eu91uSV4+AYaqBE4FAngO4He42vfLS7kHxlgfHzc3CYHkOuvmdU9PsLKy0pLcQQUEZlLrvQ4E4HKXEK
mm0afDQ4psb4vc2WwwomEWqCR3REB9E9xZiYCA+RECT4DF5nM5WdMHBweYEAPlb29v/8BXd5XcOQEBT
m+B+tAAAQgQLoevoej+vvGTnzc1NYUYjpMjXlKZge+FeDx+wyUfb2xsdJ4cUVtb+xRfyAxfCXgtwO+v
GM/ZoB45zfsXMe155nlHkWIAAAAASUVORK5CYII=
'''

icoexec_txt = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAFrElEQVR42p1XA7AkTRP8/8+2bRtn27Z
t274LnG3btm2bz7bfW0x+lR1TZ8xuRWT07qAryz3/U3nnnXdekeUvQU6HyM/VR/z17rvvUs9DJRt8kM
zMTPgjoif7owjkg0hcbCzi4+KQGB9v1qTERCQmJCBZ1mR7TUtLw5nTp5GWmorYmBikJCcTvKfgO2YPI
kH2iZd9KfTcYwkkyAtUyg2Dg4KQLsqMgpQUpBKiNEusP7B/P1xZWQgJDkZGerohw3t8JoUgISIpySBR
9nREgOz5Ije6du0aMmVzbkwFtJyE3C4X9u3dC4/bjcsXLxpCvE7wGX02OjLyDhmnBPhiZkaGURIWGop
HydmzZ0G5ceMGHiUpYnlWVhaJkcgTCRSwLAvnz53DoUOHcPTIEfTu3Ru1atdGvXr1UKlSJZQoUQL58+
dHnjx58PPPPyNfvnz47bffkDt3boMCBQqgaNGiKFmyJMqVK4c+ffvi4qVLCAoMdB6CDLGesaXMnz8fw
4YNw4gRI7B+/Xps2bIFGzdtwvbt242CTp06oUqVKtixYweGDh2KIUOGoGXLlqhTpw4GDx6M5cuXA5ZF
j9KzzgiYWErcvR4PZs2ahbFjx8Lr9d4DCgnQQ9WqVUNUVBQ88rzKRckLem3RokW8boxKd0qAyvkCwzF
16lQES5a7JdkoCVJaqqhq1aq02FhL4fV4qSCVCRMmYPHixWafTCap7OmIQKJdBS5JwjFjxpgK4OYhIS
E4ePCguU6pW7cuRo0ahSZNmkCJ8756g3nE+1TOBCQcVwE3oyJaz5Wg60mMK6Vp06aYMmUK2rRpAwqvU
wlXYt++fUiSKsiw4++YAF9g6dDtQUFBtIYEzH9CQ9CuXTvMmzcPXbp00RDwvlEeKfV/9epVhoTvcj96
wjcCBGOuHribBKVbt25YuXIl+kqpUXidFoeHhyMmJsYQiY2N5XtUTq86J6CMuSruJkGh5STQp08fUO6
+r1aTAFcqZxgcE6ByIktw2wMMy10EWrVqhTlz5pheQFHSfF/BBKZiro5zIJMeIAm1/G7rbRKUxo0bY9
KkSabxUDiQCJ0FZijZw4jDjVPReQhs65WAksiyXUupLS2aHbJBgwagqJVJ9uRLsMc5x3VMdDQiIiJ8C
AFxHxFdCUrlypUxaNAg0wkpVMiYU1m09AJWApWGhYWZHnLr5k0lkOPxBOhKOweUhAkHYV+jlC5d2lRC
mTJlzP0wUcLzQ6AMngDBrVu3cFMm5TUpxwsXL/IAY8nkDBc9HzrygJJQxUw+lpd6oFChQmjdurVZef3
EiROm+x04cMB0xCOHD5uJevLkSQTcumVJWDJfeumlku+9997/n0hAK4He4Ko9gZvTzZRcuXKhfv36yJ
kzJ86fP49HCYdBYECA+/nnnqsoB9KnqcjRMKJihfYGtmJ64vjx44YAR/E///yDFStWgKLP8rlYyYVAs
fzcmTPW119/XfOtt956hkqeRICKtOWSvAIqPAGVKlWKBEweZMuWzRxU1HusBGZ+SFCQdf3KFR5eGr75
5ptGuSMC7OH75cC5efNmbJbDxybBxo0bTdP58ssvIZbg448/xttvv41PP/0U8j3BzAaFdR8nykODg60
AyfqCBQs2ffmVV1S5MwI8hNILPHASxhv62+4DXHft2oU4qfM1a9aYlqxHeioX63k0a/nCCy84Vn7PiS
hDcDsXbPA6kWKfmHk8i5Aa3yBHtZV2DtDtQgD58uZtJRmvyv0gQOWEEhHoQNFvBJ4LQ6X2d+7YYUIkY
knS4e+//24nYVHlvhNgHzcklIgSsL8N2Nv51XNCKiEgIMCU5qlTpyx2wu+++679hx9+qMr9JCDW3f0R
otABQwKMdYTM/auS5fKFZJHgRx991FZA5X5LdvgoXknMc+fOxUlFNPXbchXZ5FVZsgnyPgZ5FPb/XIL
Ppc6f8lfvf1ZuvChTv9atAAAAAElFTkSuQmCC
'''

icoinfo = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAFn0lEQVR42s2XA5QjSRjH1zrbeDibD2f
bvls9r22Ogk46zrxMDde2bds7WI2TQU/S3dMZ131fXnV2s0lnNYd67xd++FfVv9WGUvqf8v8VkJCQcK
/Naf7YZDH9wnEpfxnNuu7x4HjDX7yN/9nisHyk1+vvuiEBViv3enZ2xh++Kl/3pqam7pTSqwFi67uXl
ZV1z8hO+8PpNLxyXQIMBsPjs2ZN+QMbB4Nyd0EQgKqrIhAQQjmYOx1q8Dz/2DULcLgtX8pyAAtFNcg9
B7NbV9A9Ye7x7hPTV/ezEcLZnDY3Z9KRK2Ey69N4u4m3OvjBOElNAbjfOAOcjSBEsuFIcfd1Bwu7z1k
wZ9KUKVkeX6WPQCyBvKsBYhuI1+slGVlpbpuN+zGmADQUvIOAQEy2bNsydPbcGZ7m5mYSDNYSv18ggl
B9VYiiEMrB3Bkzp7g58JqmAFmWY5KeSaySJEIhKapB3nkvyVqfTxLmnCBjph8h7lV5ZMuR4qg4zBWhh
t3JT9AUAB6ICe4lzkAU/Tj7CDZBs7UHC8m5Eh8p9FaShTvPkeHZh0l+sS8iDnOxBnjDc7GxMeVtMMgm
3moSjSZdC0DjwZn11JVqV6ZOzy4+dvzYIlEUyeV4KwUyFASs3l8Y9R/0DJkz1NxmN9typmQoFZU+2tj
YSK9yQGwD9XrLadaU9MalyxcfliSJqAh+kbhW5JGUBSeIr9of/p0RFoBL/vvMWdOCsCRUUWqp319D4Z
C7KgKBmlAO5s6aM71x565tG9BkgigR69LTIXyCiMaLIizA5uBBuQiFZFpTUx1B7oVyCqaiiXOOUzAVT
V2ZS7ccLYqKw1xRCtDMbFIVDAbJ+sMlZNzMYyEh8D0mYQGwl40tLS0wGz/OPoJNR4oomIqeLamgheWV
FExFh+ccpvlFvog4zMUaJt7QXFdXR+btPE8MC08S+KxJWAAaCocoBq5IeaVAh4GA1QcKo/7DgbXwZCP
BDP1SkMBnLaIFyLIUFzAVda3IpSnzT1AwVdT/TAD4oYnYluURDlYAPmvCBHhUAWgmTWoCMrUsPR3CJw
RixlwU0Iz7Hzr84HNMsDkeijabOSUsAIyhybrDpRSK0hpR1oqJEBAHdk1oInA6dyckjH8zLKC+vk6Te
TsuUDBV3BgcrFZcAecLz3vsTqtLp5v0WZs2bTqhAPWkoomkBKlfUuLGqAIWLJrvbmhoILGE4OHHW426
b7/99j5o3gXoGDoMMRmMoYlteS41LjoVNwYH1GoaOLDvxxYr58jNPeVBEZcKQWF2J5/ImncOCXC4rEc
URcHjWBPYfzz8tP5nh7FILVbTOSh61zvvvPOA0ajvmzMlyy3VShEirHaTVW0OdMAbkF5z581WoFQ8EX
Gbg6ngVDwjOGbMyN5Q9HbgFuCmISP7vWy2GM379u1JVQ3IWznPc8891wmbA+2ANu1hyVzTpucocK1GF
9PLRzwB5y+cbwJT1SYkjR8Hte4AbsPmQBfkqaeeukVn1P3uTnO6q6srCYG7ob59+2JcO7wQhgQAncaN
G/WRxW7eyttMVerlmJlKgb2LKQS3DmZ07ptvvnkam6szZ0vcidERGT58+GMWmzkBz34pfEr4ZhVHOxb
UhSXfyordDdw3cuSQ/tBEzMs73Xj5iqAwu9NymuXcDHRlzdsz2jHaIpDaluN0H+gt+icjbkrVVWAFsN
Btl4i45/3333/ayOkWT5mapci1coQIMFWV2pzVaHfNT0ZMYQemXhVxKzMUCrkT3T1k+IDvYTXK9+7bU
68aEL43Pvjgg93UmV/fo1mkiE5sO7oxIbegGJUXX3zxXr1J50z1uJSqqkqanuVRBg4c+LDa/HoFaAnp
zMR0RUEI+9xlzJjhr1nt5pNoVANveK3VH06ZkHbMHx3CJ46L5mqbkJDQDgQMNJiT379eAX8D/yxsjEx
hE6MAAAAASUVORK5CYII=
'''

icoopen_pdf = '''
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
'''

icopdf_close = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAA
DdgAAA3YBfdWCzAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAVOSURBVHjarZ
dLixVHHMVPVfe93sfoyJAZh2EUxNe4iCFhwJ1ZJLhKHJKVklU0ZKE4fgMXfgFRcBViIBuzig+ykmThM
iKE4CLxEURmGIwBHXXuu7sq51DdnSbOMA7egt/9V1d31zn1uFXVxnuPdZMx5rIxdQd8bIAjAPaTbWQS
IT0hf5M/PHDDAj8f976DN6hcBtYTnqbwWQscjYCRGEBELDEIyRNHUpKEuMLrHyxwjkYWZWTDBn60tvE
sCM9XgXqj2cT04cPYsnMnGtu3o05qRKm7sIAOaZOXjx5h8eZNtFst9IGOAy6O0cjnzrXf2MB31k6z9G
oEzG6dmMDuY8cwNTeHysiIOiW8SMrJl+JgZQVL167h4ZUrWH76VD1yxwCffenc4roGLlv7PsNPVWOm3
j11CtuPHEEUx7BhRCSszNoGvA9DwpimKRauX8fdS5fQ934JwCfHnfttTQNquQN+rVs7deDMGUweOoSI
YjbDEIiSkSJJOI8ZjiTkya1buHvhAtrOLVngoHriNQMa82XgVtXa2fdOnsTEwYOIAUTWQuJgNORNDAj
vHJxgfsD49PZt/K6ecO7OVuDDfE5YAsP0HDhLwdmJXbswNjMD8+oVDCcSiG+3/6PTAYjvdoESvA7lJH
8WRHVYMrZ/P7bt3g1pSEua0o4JvuVfjWF+E4DxvXuhF9RaH0XwjGA0BFneZ8Ph/98DBIJjD+eg6IlhP
mJ8Z98+PH/wQMMyT81LABZiOfnGmLMUrzcAbNmxA+AslpDwcRzyitYWRrzyJQMSl6jwElZMEkAGGA3j
KOtu8J2e9/Ve6IWvYzqpAziq1itj2W3qAQn6SgVGwiJNZSSnmA+GSNwQn7W6gMI5ZjCA5TBxgqOteeH
9UWqf0RB8pBVOBmq8mdy/D3/gABzFLfEEmRGjvMTz3ggGiu43anXWWk9B5RUdUUwfPkSd79f4bCdNR1
Jqxw6Yk3iFFQm3uAi/Zw+enT6NYaax8+eRLi2hQuPSiUnq/RwvMUNQoauYV255GYPHjzHslHCZBuuO2
QNVikeheCb2wKQuYhUSQ/r37qHRaGCYacChNdKwNl/cNGyTcb6lGpHdUH58fBzDTP+oborbkoa041W2
YIhmszlcA6X6y8mSJwQ+UCwmQ0+l+h1RXtrW8Cc7SIQbgeGLOweRisyEtK0D/iQYkERbaPbgsFMunpA
+gJSA2rEFrifACRnQruWy9fuv6WlEtVqBzaKpVmGJ0eJkbb4SSiAsOv0+PEm56jmiKFRnSqQxyLZpUD
sG8IvOcFyfR7osrPGBSpLAEp+hlQyZmA29pPX9dQMsQzARYL6og/RpoEN6fFaa0o5uAMmnxuwE8EEEo
CLCmlAIIKe834eeyg2KvAdWpUdavP+Sz7fUC8D3X3l/NfZMPIadY8EXbe5Sm3izyocibTp8qbTj5Ttd
aH3YkMpjnBsKRkSvh4R1iC6vW7zXDkPQ0YlZ2ozACR6dPXCxB6DlPVqsSC8kJGUlohhP0emsTem5RAZ
IlwZWWNeK6tU8o5Y0Vz2SVYDZUWMwyhZujmPUONliEhEjWKbWi2J4NCSrDEtCOhKmgWWWvyB974sj2d
qHUmCqaS1kpEkhmahKmNHm4ta+PgSlf1Gfwj0aUctfsFyt73hfHErXP5bLBA1sJg2ZIBUS04jNxUs94
ASFxIDCXQq2yUtet0j5WL6hD5M6AJ1idJSqMVYyysd01ZFm9CnWJR2ik49i+cNkw59mNDFfoQft4Zsy
YiBsqQjJCR2ztJUzdomiZnta+jQb3sepMWt+nKoXkg18nL7V57nJzhL+LT7P/wWz+yo33cvmpwAAAAB
JRU5ErkJggg==
'''

icopg_first = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAA
DdgAAA3YBfdWCzAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAATRSURBVHja7Z
dbbBRVHMa//+yZnd2ZLkW27ZbStNweClJYQCoJCZBAAC0CklKCIcHEBwkJDyZGNEok8oAJvuHtUdQnH
7Q2xgeJl0feNMZEExFNJMASZLe7O9vZmTnnOLdw0i6XrG1iYtyTL5OZ7+z5fvv/n92cJSkl/s2XFui/
AUBPEqPnKdWGP38AtJd2g+H348Xjf9B4awjta/HnD4CeolNm1pw4/ezp/kaz0Q8PC2b7mXRm4uS+k/f
02T8OLlIaA/hwsDA4fuLICbK6LJz94CyQmun3LeobPzZ6jLILsrjw/gWAgDkD0CEaxAAmRlaNFMf2jk
HqEjW7pvz9tAyD+LS4vFjcs3UPOHHU7TpgAHDnBJD0E7h4YOuBwsjmEdjchpgW4IIj+XQ7kcLbOzft7
Fm7ai1szwb3OYjTXABUP420cebo6NFMz0APyo0yhBTgkkMIEQUYhvHR6JZRI9+VR2W6AuGLSJrQYoBa
2wCqn4VHCuMHnzhIrIOh6lRVeHCVUqKruws7Ht9hsDRDrVmD4IHv8giACRYDoC2ApN9LMbFyYGVx++b
tcHUXTtOJ+iogovBoCIFtW7ahGYxpbzoO9zmEJyLpQm8fgJ6m3dBxcd2qdYXh1cOwhQ3hCvBgCBIKIh
yqFWH4XQDuxRAGjPYA6DC9zCz99Y0rNmSW9C1BzamBpziEJkLFAFoIkoQjDudiBkR8z2NI0jVIiBaA1
n4P0cemZY1tGFxPVs6KwxmHYEloSsTCvauQ7IsYRMSVICKQTpAPqgA9Q0uxhia7OvLDQ91DIKIoXOhC
LcZUIEdYkQSCRKLkOZKW8Ph9TGMq7V4A4W90T2/hu269e7BDWGi6zRm7PBySJIgRpCajCkgmIwBoiDw
pAwl5916TGggUXRtuA5QODPD7V2DX2l3Z0o0Svrn0NWRWAiaQyqVAJoEsgjAEREaAGxzIIN5U6WQVLR
AFklGGkheLeQxkavevgPxE8p4Xe7bcrt/53Bw2V/s3XDQpqkIyIQlhSagBBaEHSiUAIpAfyE3my/gZS
QJ0evB54NZbt67Iy3y9LeqTtILJjnwuXDgWS6SHALMgsoFMpeg+AzVPR7QBSdMefiCRP0hXvif2O/b0
G84i10UvhbNiKRgFYkABWOF1VjiDErVxIpLv8jN+1T8E0u7A1KAzPVpAwcxqhzkrXIcC1hB/DbWHtKA
Vwp9ElW9CSvulw+qEpVvKpFBqX7AMg8lMIK1CofJiANLaPxPKd+RV/OQXy9XylyR1uUjPAwJKPJYudG
QudyBXW6CeCyUSIUB7FVAQk7Ipz/mj9Ur1nD3leH2sH+QR4CW73QHIJjgNB873nofrWujF8kPNDUCBv
MlfbZadwzdKpcoSPgDDNYBpADZA9bjE3jX3uQBgCjdT0F09BAwVAZNsvwWtEOf9z2TZ23itdP3KwmoX
OpudQB2gGiEc+JV/gbL/GBrab51eHjmRA5oAufNQAQUhr+Kyt6ZUKX1Fd9Ky11sMLQaI/bPyCr71Hr1
dv31JY4YsZHuhhQByfgDUvnjF212ZKp+v/FX3lqWWI58xZ/oveLumapXzU6btLesPfMOcNwAV9Jp3yq
k1jvx48+fS4oXGn9AxNcN/KfCrgS8CP9fqz+ufUyIK15Pt+P//O/4b3OWd5QW/m80AAAAASUVORK5CY
II=
'''

icopg_last = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAA
DdgAAA3YBfdWCzAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAATzSURBVHja7V
dbb1RVGF3f3ufMmXamlHIbLkotjQkV1EmMgA/eSBXSSBlISnkwqPAgCU88qTERjQ+Q9FXxB5gYrxExM
VEf5AnhQSzRmEYqgXBLQ6dzPTNz5lw+9+xzkpmmF7lMog+u05Xv7L2mWet8357JDDEz/k0Ixf9WAHqd
JA2RgQiL6O0PQPtI4hbGd27ZeZOGaQcUFtRfUnrbO+BiCYDNw08Pr4yb8VO0i96YT98/tH9lZ0en1ts
bINrJeTkcHTkaX9uz9rjqxKeUphhaUOmo4NjhY/HeVG9Tb1sACygUC/AtHwcyByjdnx5FL87TbupDhE
KpACSBI68eoS0DW0axXukj1Nu2AHbVRrlShu3Z2P7MdgxuHUxD4hyAQRBQqpZQrio9sJHZlUHm2UwaD
s7rc9OWADW7YaCN8tU8+h7uw94X9q6yktbHDb3sKK2mQtTKyFVzGEgP4NCeQykrZulz0Y4A2qDklEIz
VTuWdiAzmLFWrFwBu26H+5FerBXRmerEwZGD8dSy1F2di1mfhLSHerABM0NrhuAKFyImIEwBaUoIQ91
LdQ8ZrqVakyLCKlnqe3IJZ86eweTVyXGYyPAXfBWLwJi3A54NhxwIigxJIrSKQvhqHah7oYjwNYJ11f
vbtm5DIplIX7x08bx6qFf4a/7+jgOQKVD1qqigog2klBB+ZOZrk9BURPuInp5FyECtA4n+Df2Ix+OpX
/68cIpG6T3+jE/c0QjEE3Jmk/8IilyEjEsISzRHYUhNHYZmP70INMPu+FH1BOyijQuXf+WKXfkSE/wy
j3N98QBb5Uxf7SHk/TxEh9ABpCXDaujZh6SW1gfaVFN6obFwo7UrwS5j4tYEpovZ3xDwMH/CVxYegSF
Q8SowYgbIJE02GDChK0vWtYFAXY0/9lkHIZ9QD+ogj8JAdR1AB+pf0o9VydSj2Vr2DO2jfv6c/fkDCM
Lt+m146oIEIBBWF00EihxVT7Gu6ADSUWaO0OQKa/plH7ABqpI6nE/hucefT5z85oPFDiEBmgBiipZiP
Kqx6D9EFMCPgglo+IEP3/MxCwRYCQvGgzGcnfr5j6vOld366RcMIAQQtjwKodkMYbYE8BRlS1f8aM/Q
1FqypwtePGC7VP4W53jk2vi1+uKfA6QoIxqac0PIyNBVpJZRuFprjm45oVav172ie5w/Ct7FHCxwBtA
gNeevabaEMCPTWjNIZKppGiZc6QMuzXhF7zU+6Z++869kJEAgtKI1TKfohJEwAKvlPJCmRsJIICm6AR
YTKPpP8klPm99FAAIxAQFm0we6qksQv5CE6Zl6HbL5mmViOcg2OZfPfYffvTR/yJfxDzDmCwCPwmhey
2yzArUZ1/UBk8oU7jnhW5Bcwhp/HbJTOdcpVsf4hP/2vX8rZgFqBKhD0/RNIC+B26Lg3qgf0iMqEVAC
YAOWY2GdvR63rk/lnVxtdI75PXXADUfQleyCJRKYrmb/Qs7diUvI0gMN8zBgt7riuSSuZ29OKn0HjzV
bfh8dIJBDSHWthkhaPG1P/4if3E38Pk9qvXEVCaudNaCpGE/NTP2Ac+7mpvl9BkBAeGxgIwrdtlso5c
f4qPsin2Znlh7fiPx02c0XcmP8lrtD6y249xGYKJT90qXs0hs9tWuVw/ym99UcnZVeUXpJ6e8o/T4x5
8cpKfDszfn1NuH/X8d/AxvQTaxLJ9wMAAAAAElFTkSuQmCC
'''

icopg_left = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAA
DdgAAA3YBfdWCzAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAN4SURBVHja7Z
fPaxxlGMe/zzvv7GxmN2Zpkk3SLE3bQAkhDRtFDwr20NAFNbaCzaI3T+YPEKoopPVSoV7Vo1Q8eTHm4
EFRPPYoIuihVEExbtHsZnd2M7/e93F238iIhwayuymIzzPfmYGBeT7zfIeZ5yVmxsMMkei/CUDPkKRX
yXooAPQcVSDx00Z542daJ+tYAWiNrrkj7tbmK5ulTtApIcIjeEDIgRUuUwan8NHs5Oz6RnWDiieLuPH
hDcAChg5AV2kuKb61PL9crl6uQmYlvI4HE0MGoOepAuD26hOrUxeevoCQQ/j7PmxhAzRUAOO3Le3rV1
avZOfPzaPpN6FZQ7GCIxwg2RANHCD1u5AvrK9V1qgwXkAraP1dvHcMrXAoAMbv09gqzZTKF5+8CDki4
QUeFCnobnJvjyxnDYA3QAB6gSqwcXvx3OJUeamMkEL4oQ+VpCadQiTJxAYAAwKgKr0uc/bmytlytjRb
QjtoQ1kKWuiuDIAwIJo1SBIoI8DQfQEYvxfo41w+/+Kjcyvk5l14vgclFbQ8KGppI6RdsMjqQXA/HaC
X6TSWaHtydOL8wuQCutHyW9C2htY9GRCTxgphICTL/izofsOL01PfTMiJuZx20dnvgDMMpZUpzArdEF
IAFnodYMm9c2EJCEeAkiOgjt6BS8uXRmo7NXz95VfQWQ3KEaxRC1beArkE7WjETgzlKCAL88QZc1cZS
ZAUR+8Af8Kq+FrxqT+83c/c8+5ivBMioACaNSIdARoAJZIHRR2kEDZANgEW9TcP3H/3/l2+o1ba2tum
ecn58VHAgpE8kN0F+BfECEAuGQv6nQf4Ww75A33Zb++/7Z8IQ0wTIGCUwqQgjgGAa64NbCDh99X1uBl
fBYlduAK2tI0FKcw/7TAdIBocgIGIt9FUj8MSP+ZzY8jZufQiIQWSADlHs+BwiPf4Hr6Py/Vm/XNim0
/Y44BGKmVEipDkcIZS3uaAb8bPeo3mzfaeH52UJVBE5s8XJvIBavdvweEg76g3g7pf3anVGrPqFJzQA
fYBtAHyugBi+GM534o/5Xr02K+13+4WmhMYC8YAD6DWAC04HILv4U60VGvUvqDdDE9HMxDHCJC+F29E
lcZe/VbjTy86Y53FeNY9/pURvxVd81udl777/YfaTMH5BTb28IAY6uKUkuBDCvy/Ov4LbxV7Dx5uBrE
AAAAASUVORK5CYII=
'''

icopg_right = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAA
DdgAAA3YBfdWCzAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANrSURBVHja7Z
ffaxRXFMe/585kd93dtPnhYiTBBFrRShqiaKMUhBZhrdqkCmtiX4pSUPwH2lAQn1TIq8R/oQilVR/60
Jc+tRby4EtLoaVUrCmBJrvO3NnMzL13bi93FkaEgnR3kj70LB/u7r58P3POgd1LWmtsZzHDf1+ALpND
p8jdFgE6Tw7+xKOTb51cpTNU3/oOCLwCYPLC6Qu18o7yV/Q+fbItOxCXYyx+vFgarY3epFn6nKapsKU
CHvdQGCjg6kdXaer1qXnswQ/UoPGtESAgCAPwTY6YYjTONnBi5sQ0IiMxS/X8BYpAEAXwQx885PBCD4
dnDqPxXmNXn9tn9yJ3AT824RGHH2XnyPgIFj5YKA30D2R7kZdAW7bBJQcX3MoYbDcK1QLmzszR2NjYP
CbSvchFYFNtwhcmWFpSEWFPuxfH3z6OA/sPTIOMxFmq91SACgyBDsA1B1cdZIqRsAQiwL69+3Bk8sgu
t2r2Yp4+7Z2AS2hTG37iW3jC0/cq64YdScwxNDiEY28cLVX6qzdogd21e9GLEXBtQsiEkDnhp91Ism5
YiSjdDTDg0PhBqg3vbGCSVuhDmsA/lPtSHXAYWMlADEgAnWgopaCRnpGKAAE40gGLGRzhgARhojqBne
XamxvRxrd0nl7Td7X6dwIuYcVfgVTSCkAaYkMEOJEDN3bBIgbdNkKBguJGjmuwkOHozDG8M/1uZfne7
W46QEDFoJAisuGpxARKBWhYbBFQrBbh7ingu7Xvf3oc/T5nn76bEaCs03BpcJBWguw7t4MDVAf7IUuJ
Dnz+AA9148mjJ3FXO2ADK50gYSAbnn12OjDDMCGM41h64qa+k1xHVl2MgAgoUxoY2vAsmKWY3wQIRwG
CNqQnL+pldR8vVJcjSOzSQXZCyWKr4lZQYDvQ1K2f4cnTeln/BlO9EwABwqCQkaQMsWHEgdLNVvNr/K
jO6fs6ArLq3Qi4QSDtQgyQIOxWo1hfa4rI21zSt9Rn6FQOAgzwrYClKIqoBSN4urra0s3kkl5SX8JUf
gKgVCAGXjWvUrOKP9ZXf0VT1PVSOu/8BTzCCO1G2JJ6ja99g4dyNpt33n/JEsJUaT9af3HRetZc0oui
/nx4vh3owzOu/V/W208HQ799RV+TX6CH9VKXUzKlc7rF/n87/hsFAMu0HcYh/QAAAABJRU5ErkJggg=
=
'''

icor_left = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAFiElEQVR42r2XA5BcWRiF17YRd084trl
j21MZs9NjhD22bcYZBD12M+mObSfrLawxZ9+NjR68qq/q1r3nP/95fu8FABIRabFHPtpMvC/KTNQuqQ
fhuQucnbe8HG0uWpXkJP67b8sJUCEwYwHijPdLUQ0F+ZEiHBBewpnj34FpIpz+AJTqxUhTUXispfC3L
VUHcPzA1TuEGY4RToUbcYcYRvzkSFOB4pQGiLLifx1pzO9L8edhvOckDu6+eB/8odPY0bYfDbk85ESN
IsF5DBGGnOORxgKtSQeIMhXZMk35v9Rn7oZo/CxEnHPPxMYKMaJtxv5jGPHyWPojb0gcgDqs51uLReA
PnnpuRtnHURDLB8OYe5zaEVWJAgQbDNsEGrB/KVkzgpFdxyjT52dL9T5EmnH+IiEkugY81dpnOSs1DI
fYNKO9cQ/6Ow/fh7F8PPzt8rGOuQHNJSMPrRNqMvhYYcw/Tk6HxHeBmXR2nIl02h9ZCV1gb9p/B7mvf
aA0OzhEfpZvq+zXnj+Guhaio2XPfRpCRigHTGNh3qSeA/q0ldLq85iHPM2ysLGGh50b9mHx5464vb5s
lv9H0l+6srWW+E5UZPags0V8h/ZGEZKcBP9Fmou0JvUk1H+B9YrslwGFqvN9/81K2gLax5Z4UCPzlVu
4+mJvtJZzsLlWeIfKZC55cB2fTIA7LP7CXY/+se0x2sdWgketL/ncvs/XLgtt5TwK7g1aSqjTYD6KSF
Ox4qQDPDXg1z4fS3/l+lPBmp1oKhpDQ8FNkrz6EWMqTp72AAT5Wcvbon3qUJ8/iprsYVRlDiGd0U2dB
sHwpAO4qdfqu6rXnSKQ8aM0SrMDQj1MslCbM4KqjCGUpfQjO64bYYbDpycdgDQ+ILwMAhk/SqNBC6Mb
ycajJmsYFakDYIW3I8iqHoF63b89cwBqe3EyAQylE1G8thdJfpuxwqUFwTaN8NXpfLYA5uYlr1t/xXp
L0lOgsYDh56CVhbUh25DouwnRHm0ItW2Ct/bmc08NUMLofp3h0DZLn5ZJl+w0kQCxVUHW1UiJ6MTqwK
2I99l4I4CneqvgiQEYVPNsRvusNYEdet8szgpUunMUnh2lBQnva9MSrpHGGZHbsS60/cZp8DWug7tGY
9FjA5DmLKp5TtwOvYLVPVGBlo1dBlIp9c8bQHNBXKuvRSUKV/UgO3bnraOwBU4apXBTbdR5ZADnZazX
GA61szIYO/TyEtlRJay+rrLkAdgpF47oSK11fNbmWrQ4V3P59Iky1gBK1vUhL4GNdOZ2RDg3wkYh/+J
jH8XL9RvfsFZI0UuO6IgqWNnTVZ4ygLqckdGCpD7YKhdO6NFZ9Wp01ntPOuxa8xObLRQyJvITu6naUR
APyos6Au2wlE+bcFAqsXziu8BcJt3czSCnq2h1LyrTBkcb8sawvoyPDRV8MF1boS+VfFmHvq5Qe16iu
+b8VXMJWnPjPbXnJxXr0lddiXBpwoZy/o0aqpZ4gHhRnrCUyWh76utYfx7rDYMl8VdjfBpHydOrqWAc
GysE6GgQg73xAFqKuUiP3I5wJ+pwquTeINSpAWmRnWgu5tzQEC2pIbXEI8anAcTTRDb37acGIFgqpBk
bLIn8N9G/CU2FHGyqFKCzSYyeTQcx3HUMvL7TEI+dx2HhFcKNMZkja0RDtKSG1BIP4mWukG7+XJ9kpt
LJlhr00Ouuxiw05I88dwBSQ2qJB/GS6KNUfxnrHd2FzHatxQETabEbbxzewc6j4HSfxO6hs9g7foFwY
0zmyBrREC2pIbXEY9J/RkaLYpyU5nl/byAfhBU+BajOY6OjmY/RnccIN8ZkjqwRDdGSmin/NzRYEmeh
QQvZoDjP65TsLOcfl31t9zeBjMkcWSOaSfyazTz/A4R29YWpIMzKAAAAAElFTkSuQmCC
'''

icor_right = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAFiUlEQVR42r2XA3RcaRzF17ZRz6SMbW5
sO6cxpxOjnNi2WTcoJsZEb9JMWNtY78EauTtf7XZm2r5z7jkf/vd+v+f3XgAgtMINJ1rDjSZGQk3GpY
XNEA3AaAJdO48hzm7i7wjDybX29jtefq4ATIMxnDr6HWbGLiA7lAc+EDdKf1rsmQGEGnJlGXpUfLDeS
F+Q7uAJvnB05vIt7SibQaTp2G+hhrxgfvKLTw0gVJ+rFqI7fDTGfhAZYRzUZI5iT9M0qL6TmN1//i4N
dRxHgvcoQvWprjAz6muRAFjaA28w9Eazwi0G/9taMgHe8Jkn09BpVKeNg2lI/RJqNG4jFECYIU+RoT9
yNCeSAod9FFTvCYHVmM8D/3SdFRiALB5qNPzXjvIp/uJHBNbAviMo2MCBrw77F3+dflOBAMhhX6NPHa
1IodDdevA+1RcMYBNzC7ytsqEvHX3ffHPtOAIs6mEvV9PvqtQ8T+BrgKk/lpUSOAz2tum71NIwjkDHX
Eh+7fqj9DzPRrn5/gFSX3vcVZMW0wYD8aQ/jMTTo4S6C0KNeWpxdtz/mmt5aG2YuKWS1A6orfCcE//S
kb1qnvdHN+uXf26LvVumsLViFK5GaVBexDygTVsrLuxzgDxYjpbGj2B75dgtNRYPQ3m5OyS+cgq+10z
72BRpa3dAcbHnv1Jfeudov8B6RegnYajhhCzTmIOGgmE0FY/c0Cg8rdKw4nPrrgeZaR+ZUfSPzM8s/8
JZS+R3QYThRHycWzdqcgavqS5vEDkb9kL8K8efln/t8TGpeRa64/Bz+5MZ7ShL7UNFej+qszkI96iC9
LzVTWT+mQME6fafTI9qR1FCN8pS+lCZMQAXgzTIzfcJfJDRSblS21G56gQRaYsM4KvV/pufWTVYwc0o
SexBRVo/9CSjoUILoj/ISBaeGbsIItIWGcBTo/U3f4tarHFoQJzXduRv7ISueKxIAPztxScGcFfffib
Qsg7hLk2I9dyGjQG7YKOWBpUlDC9hT4H5V6y3jI0LXn8iAFflRi4BiPbYivW+O5EQ0go/83I+QGSZsI
dXm5ZKZ9g0zStgtL/+WABnldo8T/2qa4d/U2AzUkJ3XwNRp8VckVsS876gi8vx9/6b5Wm+G3xbtNIZz
fMYD4G4fUgVazXsVAr5i+64tvfpkXuRu64DnialUF0S1SgogI5YQrWvaW1bzvqOsIyoPVqsh0Dc1bGQ
yT4fYl+LZOZuZMWwUbCpC0WsHhhLJ8+p0aIcn3RxDbGNtlbyuQNF8T0oYHW1ZcWyw1IYe7QYNpXz7Fe
xXnsogI1cgampdNJcQkgzctZ2oDihB1UZHGTHtsNEJmVObXFs/aNOhxKd9Z4WnVVtKZ87lxPXxfcOcE
gGP6stPqQlzFwmQWu1du0bj3wdm0qkNDnpZCBvfSdKk3pRkzWIzUUUthRTCHGogyZ93SX1xXH5agujX
VUXr1tIpL4o1lmDvilXWyz+ItOxEVtKKOIhXpLBIVn8zDZjiWTjx34PGEhmvq2zIvpyhEfNtSdiXc4Q
tpZw0VIzAfbWGdTnDyMptBWBdjWwUMi8pmC7WiSH7kZD/si1GlJLPMRLMiI8ajkkU3sR640n+iQzlkk
21lkR+m+sdx3qcoexrZSL1roJdGybRX/bEYx2ncTE4FkcHLt0TaRNxsgcqSG1xEO8JINkmcok6Qv0UW
ooHm+qQg+86qjPQk32gMAAxEO8JINkCfVZrr2K9Y7mUmaz2nKfuaTIrdcOb2/rYQy3H8f+vtOYHDp3T
aRNxsgcqSG1xEO8JEPkHxO9ZRF2covcv9eR9sMajxyUZ7HRUk+Bs/fINZE2GSNzpIbUEs9T/zXTWRFl
okIL2CK7yO2E5Dz7H1d9bfU3EWmTMTJHakT4N3z++h+JqPWERw4ITwAAAABJRU5ErkJggg==
'''

icowin_close = '''
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
'''

icozoom_in = '''
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
jWL6JoAAAAASUVORK5CYII=
'''

icozoom_out = '''
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
nwAAAAASUVORK5CYII=
'''

icozoom_view = '''
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
pTOqn8X8C7S8NXA11u60AAAAASUVORK5CYII=
'''

setico = {"exec-ocr" : [icoexec_ocr, "Esegue lo OCR della pagina o del documento corrente"],
         "exec-txt" : [icoexec_txt, "Estrae il testo dalla pagina o dal documento corrente"],
         "info" : [icoinfo, "Informazioni sul documento corrente"],
         "open-pdf" : [icoopen_pdf, "Apre un file pdf"],
         "pdf-close" : [icopdf_close, "Chiude il pdf corrente"],
         "pg_first" : [icopg_first, "Sposta alla prima pagina"],
         "pg_last" : [icopg_last, "Sposta all'ultima pagina"],
         "pg_left" : [icopg_left, "Sposta alla pagina precedente"],
         "pg_right" : [icopg_right, "Sposta alla pagina successiva"],
         "r_left" : [icor_left, "Ruota a sinistra (90°)"],
         "r_right" : [icor_right, "Ruota a destra (90°)"],
         "win_close" : [icowin_close, "Chiudi la finestra"],
         "zoom_in" : [icozoom_in, "Aumenta dimensione immagine"],
         "zoom_out" : [icozoom_out, "Riduce dimensione immagine"],
         "zoom_view" : [icozoom_view, "Adatta l'immagine alla finestra"]
         }


class IcoDispencer:
   def __init__(self):
       self.icoset = setico

   def getIco(self, chiave):
       return self.icoset[chiave][0]

   def getDescr(self, chiave):
       try:
           descr = self.icoset[chiave][1]
           return descr
       except:
           return None

   def printData(self):
       for chiave in self.icoset:
           print(chiave, ':', self.icoset[chiave][1])

   def getData(self):
       return self.icoset

... quasi 500 righe di codice per rendere disponibili le icone presenti nella immagine sottostante:
png

Se comparate l'immagine sopra con l'analoga immagine del precedente post, noterete la presenza di due "toolbars" contenenti tutti i pulsanti già prima esistenti, che mantengono le stesse funzionalità, più alcuni nuovi elementi
... tale "rimaneggiamento" è temporaneo, stiamo parlando di prototipi, ricordate?, in futuro, probabilmente, manterrò la barra inferiore e trasferirò il resto in un sistema di menu che verrà articolato man mano che verranno esplorate nuove ipotetiche possibilità da implementare.

I nuovi controlli


In questa versione del viewer sono stati aggiunti sei nuovi controlli, cioè i quattro pulsanti e le due check-box in figura sotto

png



Le relative funzioni, seguendo la numerazione, sono :
  1. Apre un file PDF;
  2. chiude un file PDF aperto;
  3. esegue lo OCR del file PDF aperto;
  4. estrae il testo dal file PDF aperto;
  5. se selezionato lo OCR o l'estrazione del testo viene effettuato su tutte le pagine del documento aperto (di default solo sulla corrente);
  6. se selezionato un nuovo OCR od estrazione aggiunge il testo alle precedenti operazioni (di default sostituisce).

In sostanza, è ora data la possibilità ad un eventuale utente di scegliere ed aprire un file pdf, estrarne il testo, che verrà trasferito ad una "nuova" finestra, e ripetere l'operazione su un successivo file. Sempre a discrezione dell'utente operazioni successive potranno essere aggiuntive una all'altra oppure sostituire ex-novo l'operazione precedente.
In detta nuova finestra, che tratteremo nel prossimo post, sarà possibile editare direttamente il testo e salvarlo od anche impostare l'ambiente per codificarlo in formato CSV, per quest'ultimo tipo di operazioni verrà aperta una ulteriore finestra di destinazione dei dati codificati che renderà possibile salvarli.
L'immagine sotto riporta una "panoramica" di uno scenario in fase di impostazione.

png


... se qualcuno ha dato 'na guardata all'immagine soprastante, probabilmente avrà pensato che, a parte il colore, le due nuove finestre sembrano uguali ... in effeti è così, sono due diverse istanze della stessa classe ma ne parleremo nel prossimo post.

Al momento, guardiamo un po' le differenze tra il corrente viewer e la precedente versione.

Le differenze in questione riconducono, essenzialmente, a nuovi metodi e proprietà implementate nel viewer per realizzare la capacità di aprire e chiudere documenti per estrarne il testo da rendere disponibile all'utilizzatore. Tali proprietà e capacità, ovviamente, riconducono ai nuovi controlli prima elencati, conservando, nel resto, quanto visto nel primo post della serie.

Apertura di un documento


Mentre nel prototipo iniziale era prevista l'apertura di un solo specifico file di esempio impostato nello script, circostanza che rende lo script stesso praticamente inutile, ora l'azione del pulsante "1" in figura, ossia della variabile di istanza "self.bt_openpdf", tramite il metodo di callback "_openpdf(self)", rende possibile selezionare un file con estensione "pdf" o "PDF" (in ambiente linux sono "cose" diverse) a discrezione dell'utilizzatore per "farci su qualcosa".
Il calback invocato da detto pulsante si limita ad ottenere il nome del file da aprire, instanziando un oggetto "tkinter.filedialog" ed utilizzandone il metodo "askopenfilename(...)", ottenuto il nome del file che si vuole aprire lo si da in pasto al metodo "set_file(...)", già esistente dal primo prototipo. Il codice del callback:
CODICE
def _openpdf(self):
       f_types = [('File pdf', '*.pdf .PDF')]
       f = fdlg.askopenfilename(parent=self,
                                title='Selezione PDF',
                                filetypes=f_types)
       if f:
           self.set_file(f)


Naturalmente, delle "manipolazioni" non è detto si limitino ad un singolo pdf, giusto quando sviluppavo le estrazioni di testo implementate in questo secondo prototipo mi si è presentata l'esigenza di citare in un elaborato ampi straci di testo proveniente da vari pdf, faccenda che mi ha stimolato ad aggiungere minimali, al momento, capacità di OCR unitamente alla possibilità di "appendere" più estrazioni di testo in successione.

Chiusura di un documento


È abbastanza intuitivo che la capacità di "chiudere" un documento aperto è associata al calback del pulsante "2" nella figura dei pulsanti precedente, banalmente "self.bt_closepdf" il pulsante e "self._closepdf()" il callback.
Il processo si limita ad azzerare le variabili di ambiente dell'istanza di pdfviewer e procedere alla auto-valutazione (metodo "self._evaluate_self()") dello stato della classe. Il codice del callback :
CODICE
def _closepdf(self):
       self.doc = ''
       self.pages = 0
       self.page = 0
       self.adapt = True
       self.zoom = 0.0
       self.rotation = 0
       self.info = None
       self.state = ''
       self.cnv_img.delete('all')
       self._evaluate_self()

La auto-valutazione del contesto renderà disponibile la possibilità di aprire un nuovo pdf per poi eventualmente integrarne i dati con quelli già acquisiti.

Estrazione del testo


L'idea iniziale per l'implemetazione di questo prototipo era di "fornire" un metodo per permettere ad un generico utente di impostare una suddivisione per "righe e colonne" del testo estratto da un pdf, in tarda risposta a un quesito posto in forumpython.it nella discussione che ha dato avvio a questa serie.
Lo OCR del testo, pur previsto per una fase successiva, è stato incidentale e, al momento, è trattato in meniera elementare, rimandando a successivi approfondimenti sulla libreria una migliore implementazione.
Comunque, al momento è possibile estrarre testo da un file PDF tanto in forma, per così dire, "diretta" utilizzando la libreria "pdftotext", quanto da "immagine" utilizzando la libreria "pytesseract" ... in linea di massima la soluzione migliore è utilizzare la prima delle librerie ma non sempre è possibile, infatti pdftotext non restituirà nulla per file pdf derivanti da scansione, quindi da immagini, in tal caso sarà necessario eseguire un OCR.
L'estrazione tramite OCR è qualitativamente molto inferiore, giusto per fare un esempio guardate le immagini giù, si tratta di estrazione testo dal pdf di esempio nella prima immagine di questo post.
Nell'immagine che segue, il testo è stato estratto utilizzando pdftotext, si nota la distribuzione regolare del testo estratto che presenta eccellente incolonnamento e distanziamento dei dati.

png



Tale risultato si ottiene dalla pressione del pulsante "4" nella precedente figura dei nuovi controlli aggiunti (quello con la "T" ben definita.

Per contro l'output di un processo OCR, avviabile tramite la pressione del pulsante "3" dei nuovi controlli (quello con la "a" sfumata), presenta una strutturazione tutt'altro che regolare, guardate giù

png



Il testo ottenuto non presenta alcuna regolarità nella sua distribuzione, anche se tale regolarità è presente nella pagina trattata, numerose esperienze nell'uso di tesseract, applicazione di cui la libreria "pytesseract" utilizzata nell'estrazione è wrapper, mi hanno ampiamente dimostrato che il testo estratto non è del tutto attendibile, mancando spesso gli accenti delle lettere accentate e presentando spesso dei caratteri "strani" intercalati in maniera imprevedibile nel testo.
Eventi non strani, tutto sommato, dato che il processo OCR in sostanza è una interpretazione di gruppi di bit formanti una immagine finalizzata a "riconoscere" la presenza di un carattere, molto dipendente dalla qualità iniziale dell'immagine, dal contrasto nei colori, etc.

Comunque, entrambi i procedimenti effettuano le loro operazioni o sulla sola pagina corrente o su tutte le pagine del documento dipendentemente dallo stato di selezione della check-box al n° "5" dei nuovi controlli (label = "Tutte le pagine"; ed in fine inviano il loro output ad un oggetto "textutility.TextUtility" memorizzato nella variabile di istanza "self.text_win", che tratteremo nel prossimo episodio di questo post, per successivi "trattamenti", tale invio sarà effettuato invocando metodi che sostituiscono un eventuale testo già presente in "self.text_win" oppure lo aggiungono in coda, ciò dipendentemente dallo stato di selezione di una seconda check-box al n° 6 dei nuovi controlli (label = "Aggiungi").

Potrebbe essere interessante esaminare e comparare la strutturazione dei processi in gioco per le due modalità di estrazione del testo dal PDF.

Riguardo la modlaità OCR, il calback "_exec_ocr(self)"
CODICE
def _exec_ocr(self):
       if self.text_win == None or not self.text_win.winfo_exists():
           self.text_win = TextUtility(self)
           self.config(cursor='watch')
           self.update()
       if self.all_page.get():
           text = self._make_ocr_text()
           self.text_win.set_text(text)
       else:
           text = self._make_ocr_text(self.page)
           if self.append_page.get():
               self.text_win.add_text(text)
           else:
               self.text_win.set_text(text)
       self.config(cursor='')
       self.update()

in primo luogo esamina lo stato di esistenza dell'oggetto "self.text_win" di destinazione, che potrebbe non essere mai stato istanziato prima ovvero essere stata chiusa dall'utente, istanziandolo nel caso.
Successivamente viene esaminato lo stato di selezione delle check-box "5" e "6" ed invocato in modo opportuno il metodo di classe "_make_ocr_text(self, page=None)"
CODICE
def _make_ocr_text(self, page=None):
       if not self.pdf_images:
           return
       if not page == None:
           image = self.pdf_images[page]
           # eventuale rotazione
           if self.rotation:
               image = image.rotate(self.rotation, expand=True)
           return pytesseract.image_to_string(image)
       else:
           text = ''
           for i in range(len(self.pdf_images)):
               image = self.pdf_images[i]
               # eventuale rotazione
               if self.rotation:
                   image = image.rotate(self.rotation, expand=True)
               text += pytesseract.image_to_string(image)
           return text

per ottenerne il testo estratto da inviare a self.text_win.

Sostanzialemnte, del tutto analoga è la logica applicata per la estrazione "diretta" del testo, il cui callback "_getext(self)" applica un algoritmo del tutto analogo al precedente, invocando però metodo di classe "_make_text(self, page=None)"
CODICE
def _getext(self):
       if self.text_win == None or not self.text_win.winfo_exists():
           self.text_win = TextUtility(self)
       if self.all_page.get():
           text = self._make_text()
           self.text_win.set_text(text)
       else:
           text = self._make_text(self.page)
           if self.append_page.get():
               self.text_win.add_text(text)
           else:
               self.text_win.set_text(text)

. . .

   def _make_text(self, page=None):
       if not self.doc: return
       with open(self.doc, 'rb') as f:
           pdf = pdftotext.PDF(f)
       if not pdf: return
       if not page == None:
           return '\n'.join(pdf[page].splitlines())
       else:
           alltext=''
           for i in range(self.pages):
               alltext += '\n'.join(pdf[i].splitlines())
           return alltext

Comparando i due metodi "_make_ocr_text" e "_make_text" salteranno agli occhi due notevoli differenze :
  1. nel processo OCR viene utilizzata direttamente la lista di immagini ottenuta alla apertura del documento corrente, mentre nella estrazione viene riaperto il documento e letto il testo contenuto
  2. nel processo OCR è tenuto conto della rotazione definita per il documento, nella estrazione del testo no.

Il secondo punto evidenzia una mia considerazione banale ma, forse, non scontata : se il documento contiene del testo estraibile esso è pure leggibile ... può darsi, però, che mi sbagli.
Per altro, se il contenuto del pdf sono immagini pdftotext non restituirà testo, al più una serie di righe vuote.
Per contro, l'esecuzione di un OCR di norma restituirà più o meno sempre qualcosa interpretato come testo, anche quando il contenuto sarà posto in verticale mentre è orizzontale, od anche sarà capovolto ... certo, sarà spazzatura quella restituita in tali casi, per tanto la rotazione della pagina diventa importante.

Vi sono molte altre considerazioni da tenere in conto già sulla sola rotazione, oltre a numerosi piccoli aspetti applicativi utilizzati, per le prime si vedrà in futuro, per i secondi si esamini e comprenda il codice sottostante del modulo trattato.

Codice corrente di pdf_viewer.py (528 righe):
CODICE
# -*- coding: utf-8 -*-

import tkinter as tk
import mydialog
import tkinter.filedialog as fdlg
import viewer_ico_and_tip as IaT
from my_tk_object import CreaToolTip as ctt
from textutility import TextUtility
import pdf2image as p2i
import pdftotext
import pytesseract
import os
import glob
from PIL import ImageTk, Image
from io import BytesIO


class PdfViewer(tk.Tk):
   def __init__(self, *args, **kwargs):
       super().__init__(*args, **kwargs)
       self.title('tkPDFViewer => Nessun File')
       self.doc = ''
       self.pages = 0
       self.page = 0
       self.adapt = True
       self.zoom = 0.0
       self.rotation = 0
       self.info = None
       self.state = ''
       self.text_win = None
       self._populate()
       self.update()
       self.minsize(self.winfo_reqwidth(), self.winfo_reqheight())
       center_win(self)
       self._evaluate_self()

   def _populate(self):
       # TOOLS-BAR COMMANDS
       ids = IaT.IcoDispencer()
       p_cmd = tk.Frame(self)
       p_cmd.grid(row=0, column=0, sticky='ew')
       self.ico_openpdf = tk.PhotoImage(data=ids.getIco('open-pdf'))
       self.bt_openpdf = tk.Button(
           p_cmd, image=self.ico_openpdf, command=self._openpdf)
       bt_openpdf_ttp = ctt(self.bt_openpdf, ids.getDescr('open-pdf'))
       self.bt_openpdf.grid(row=0, column=0, padx=5, pady=5, sticky='nsew')
       self.ico_closepdf = tk.PhotoImage(data=ids.getIco('pdf-close'))
       self.bt_closepdf = tk.Button(p_cmd, image=self.ico_closepdf, command=self._closepdf)
       bt_closepdf_ttp = ctt(self.bt_closepdf, ids.getDescr('pdf-close'))
       self.bt_closepdf.grid(row=0, column=1, padx=5, pady=5, sticky='nsew')
       self.ico_ocr = tk.PhotoImage(data=ids.getIco('exec-ocr'))
       self.bt_ocr = tk.Button(p_cmd, image=self.ico_ocr, command=self._exec_ocr)
       bt_ocr_ttp = ctt(self.bt_ocr, ids.getDescr('exec-ocr'))
       self.bt_ocr.grid(row=0, column=3, padx=5, pady=5, sticky='nsew')
       self.ico_getext = tk.PhotoImage(data=ids.getIco('exec-txt'))
       self.bt_getext = tk.Button(p_cmd, image=self.ico_getext, command=self._getext)
       bt_getext_ttp = ctt(self.bt_getext, ids.getDescr('exec-txt'))
       self.bt_getext.grid(row=0, column=4, padx=5, pady=5, sticky='nsew')
       self.all_page = tk.BooleanVar()
       self.ck_all = tk.Checkbutton(p_cmd, text='Tutte le pagine', onvalue=True,
                                    offvalue=False, variable=self.all_page, command=self._on_all)
       message = "Effettua l'operazione su tutte le pagine del documento"
       ck_all_ttp = ctt(self.ck_all, message)
       self.ck_all.grid(row=0, column=5, padx=5, pady=5, sticky='ew')
       self.append_page = tk.BooleanVar()
       self.ck_append = tk.Checkbutton(p_cmd, text='Aggiungi', onvalue=True,
                                       offvalue=False, variable=self.append_page)
       message = "Aggiunge l'output di una operazione al precedente (operazioni a singola pagina)"
       ck_append_ttp = ctt(self.ck_append, message)
       self.ck_append.grid(row=0, column=6, padx=5, pady=5, sticky='ew')
       self.ico_info = tk.PhotoImage(data=ids.getIco('info'))
       self.bt_info = tk.Button(
           p_cmd, image=self.ico_info, command=self._show_info)
       bt_info_tpp = ctt(self.bt_info, ids.getDescr('info'))
       self.bt_info.grid(row=0, column=8, padx=5, pady=5, sticky='nsew')
       self.ico_close = tk.PhotoImage(data=ids.getIco('win_close'))
       self.bt_close = tk.Button(
           p_cmd, image=self.ico_close, command=self._on_closing)
       bt_close_tpp = ctt(self.bt_close, ids.getDescr('win_close'))
       self.bt_close.grid(row=0, column=10, padx=5, pady=5, sticky='nsew')
       p_cmd.grid_columnconfigure(2, weight=1)
       p_cmd.grid_columnconfigure(7, weight=1)
       p_cmd.grid_columnconfigure(9, weight=1)
       # TOOLS-BAR NAVIGAZIONE
       p_tools = tk.Frame(self)
       p_tools.grid(row=1, column=0, sticky='ew')
       lbl = tk.Label(p_tools, text='Pagina:', justify='left')
       lbl.grid(row=0, column=0, padx=5, pady=5)
       # controlli navigazione pagine
       self.ico_pgfirst = tk.PhotoImage(data=ids.getIco('pg_first'))
       self.bt_first = tk.Button(
           p_tools, image=self.ico_pgfirst, name='bt_first')
       bt_first_ttp = ctt(self.bt_first, ids.getDescr('pg_first'))
       self.bt_first.grid(row=0, column=1, padx=5, pady=5, sticky='nsew')
       self.ico_pgleft = tk.PhotoImage(data=ids.getIco('pg_left'))
       self.bt_previous = tk.Button(
           p_tools, image=self.ico_pgleft, name='bt_previous')
       bt_previous_ttp = ctt(self.bt_previous, ids.getDescr('pg_left'))
       self.bt_previous.grid(row=0, column=2, padx=5, pady=5, sticky='nsew')
       self.e_page = tk.Entry(p_tools, width=5, name='e_page')
       dida = 'Inserire la pagina da visualizzare'
       e_page_ttp = ctt(self.e_page, dida)
       self.e_page.grid(row=0, column=3, padx=5, pady=5, sticky='ew')
       self.lbl_pages = tk.Label(p_tools, text='di 100.000')
       self.lbl_pages.grid(row=0, column=4, padx=5, pady=5)
       self.ico_pgright = tk.PhotoImage(data=ids.getIco('pg_right'))
       self.bt_next = tk.Button(
           p_tools, image=self.ico_pgright, name='bt_next')
       bt_next_ttp = ctt(self.bt_next, ids.getDescr('pg_right'))
       self.bt_next.grid(row=0, column=5, padx=5, pady=5, sticky='nsew')
       self.ico_pglast = tk.PhotoImage(data=ids.getIco('pg_last'))
       self.bt_last = tk.Button(
           p_tools, image=self.ico_pglast, name='bt_last')
       bt_last_ttp = ctt(self.bt_last, ids.getDescr('pg_last'))
       self.bt_last.grid(row=0, column=6, padx=5, pady=5, sticky='nsew')
       # controlli rotazione pagine
       self.ico_rleft = tk.PhotoImage(data=ids.getIco('r_left'))
       self.bt_r_left = tk.Button(
           p_tools, image=self.ico_rleft, name='bt_r_left')
       bt_r_left_ttp = ctt(self.bt_r_left, ids.getDescr('r_left'))
       self.bt_r_left.grid(row=0, column=8, padx=5, pady=5, sticky='nsew')
       self.ico_rright = tk.PhotoImage(data=ids.getIco('r_right'))
       self.bt_r_right = tk.Button(
           p_tools, image=self.ico_rright, name='bt_r_right')
       bt_r_right_ttp = ctt(self.bt_r_right, ids.getDescr('r_right'))
       self.bt_r_right.grid(row=0, column=9, padx=5, pady=5, sticky='nsew')
       # controlli zoom pagine
       self.ico_zout = tk.PhotoImage(data=ids.getIco('zoom_out'))
       self.bt_zout = tk.Button(
           p_tools, image=self.ico_zout, state='disabled', name='bt_zout')
       bt_zout_tpp = ctt(self.bt_zout, ids.getDescr('zoom_out'))
       self.bt_zout.grid(row=0, column=11, padx=5, pady=5, sticky='nsew')
       self.ico_zin = tk.PhotoImage(data=ids.getIco('zoom_in'))
       self.bt_zin = tk.Button(
           p_tools, image=self.ico_zin, state='disabled', name='bt_zin')
       bt_zin_tpp = ctt(self.bt_zin, ids.getDescr('zoom_in'))
       self.bt_zin.grid(row=0, column=12, padx=5, pady=5, sticky='nsew')
       self.ico_zview = tk.PhotoImage(data=ids.getIco('zoom_view'))
       self.bt_zview = tk.Button(
           p_tools, image=self.ico_zview, command=self._adapt)
       bt_zview_tpp = ctt(self.bt_zview, ids.getDescr('zoom_view'))
       self.bt_zview.grid(row=0, column=13, padx=5, pady=5, sticky='nsew')
       p_tools.grid_columnconfigure(7, weight=1)
       p_tools.grid_columnconfigure(10, weight=1)
       p_img = tk.Frame(self)
       p_img.grid(row=2, column=0, sticky='nsew')
       self.cnv_img = tk.Canvas(p_img)
       self.cnv_img.grid(row=0, column=0, sticky='nsew')
       s_v = tk.Scrollbar(p_img, orient=tk.VERTICAL,
                          command=self.cnv_img.yview)
       s_v.grid(row=0, column=1, sticky='ns')
       s_h = tk.Scrollbar(p_img, orient=tk.HORIZONTAL,
                          command=self.cnv_img.xview)
       s_h.grid(row=1, column=0, sticky='ew')
       self.cnv_img.configure(yscrollcommand=s_v.set, xscrollcommand=s_h.set)
       p_img.grid_rowconfigure(0, weight=1)
       p_img.grid_columnconfigure(0, weight=1)
       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(2, weight=1)
       
       self.bt_first.bind('<Button-1>', self._set_page)
       self.bt_first.bind('<Return>', self._set_page)
       self.bt_first.bind('<KP_Enter>', self._set_page)
       self.bt_previous.bind('<Button-1>', self._set_page)
       self.bt_previous.bind('<Return>', self._set_page)
       self.bt_previous.bind('<KP_Enter>', self._set_page)
       self.e_page.bind('<Return>', self._set_page)
       self.e_page.bind('<KP_Enter>', self._set_page)
       self.bt_next.bind('<Button-1>', self._set_page)
       self.bt_next.bind('<Return>', self._set_page)
       self.bt_next.bind('<KP_Enter>', self._set_page)
       self.bt_last.bind('<Button-1>', self._set_page)
       self.bt_last.bind('<Return>', self._set_page)
       self.bt_last.bind('<KP_Enter>', self._set_page)

       self.bt_r_left.bind('<Button-1>', self._rotate)
       self.bt_r_left.bind('<Return>', self._rotate)
       self.bt_r_left.bind('<KP_Enter>', self._rotate)
       self.bt_r_right.bind('<Button-1>', self._rotate)
       self.bt_r_right.bind('<Return>', self._rotate)
       self.bt_r_right.bind('<KP_Enter>', self._rotate)

       self.bt_zout.bind('<Button-1>', self._set_zoom)
       self.bt_zout.bind('<Return>', self._set_zoom)
       self.bt_zout.bind('<KP_Enter>', self._set_zoom)
       self.bt_zin.bind('<Button-1>', self._set_zoom)
       self.bt_zin.bind('<Return>', self._set_zoom)
       self.bt_zin.bind('<KP_Enter>', self._set_zoom)

       self.bind('<Configure>', self._on_resize)
       self.protocol('WM_DELETE_WINDOW', self._on_closing)
       self.update()

   # *** CALLBACK BINDING ***
   def _on_resize(self, evt):
       if self.doc and self.pdf_images:
           self._show_page()
       else:
           return

   def _on_closing(self):
       self._clear_cache()
       self.option_clear()
       self.destroy()

   def _set_page(self, evt):
       if not self.doc or not self.pdf_images:
           return
       w_name = str(evt.widget).split('.')[-1]
       if w_name == 'bt_first':
           self.page = 0
       elif w_name == 'bt_previous':
           self.page -= 1
           if self.page < 0:
               self.page = 0
       elif w_name == 'e_page':
           try:
               value = int(self.e_page.get())
               if 0 <= (value - 1) <= self.pages - 1:
                   self.page = value - 1
               else:
                   raise ValueError('Inserimento non valido')
           except ValueError:
               message = 'Numero di pagina non valido'
               msg = mydialog.Message(self).show_warning(message,
                                                         title='Attenzione')
               center_win(msg)
               return
       elif w_name == 'bt_next':
           self.page += 1
           if self.page > self.pages-1:
               self.page = self.pages - 1
       elif w_name == 'bt_last':
           self.page = self.pages - 1
       self._show_page()

   def _adapt(self):
       self.adapt = not self.adapt
       if self.adapt:
           self.zoom = 0.0
           self.bt_zout.configure(state='disabled')
           self.bt_zin.configure(state='disabled')
       else:
           self.bt_zout.configure(state='normal')
           self.bt_zin.configure(state='normal')
       self._show_page()

   def _set_zoom(self, evt):
       w_name = str(evt.widget).split('.')[-1]
       if w_name == 'bt_zout':
           self.zoom -= 0.1
           if self.zoom < -0.9:
               self.zoom = -0.9
               return
       elif w_name == 'bt_zin':
           self.zoom += 0.1
           if self.zoom > 3.00:
               self.zoom = 3.00
               return
       self.after(200, self._show_page)

   def _rotate(self, evt):
       w_name = str(evt.widget).split('.')[-1]
       if w_name == 'bt_r_left':
           self.rotation += 90
       elif w_name == 'bt_r_right':
           self.rotation -= 90
       if self.rotation > 270:
           self.rotation = 0
       elif self.rotation < 0:
           self.rotation = 270
       self.after(200, self._show_page)

   # *** METODI ***
   def _clear_cache(self):
       files = glob.glob('my_tmp/pdfcache/*.*')
       for f in files:
           try:
               os.remove(f)
           except OSError:
               pass

   def set_file(self, f_pathname):
       if not os.path.exists(f_pathname) or not os.path.isfile(f_pathname):
           return
       self.doc = f_pathname
       f_name = os.path.basename(f_pathname)
       self.title('tkPDFViewer => ' + f_name)
       self.config(cursor='watch')
       self.update()
       self._clear_cache()
       self.info = p2i.pdfinfo_from_path(self.doc)
       self.pages = self.info['Pages']
       self.lbl_pages.configure(text='di %d' % self.pages)
       self.page = 0
       self.e_page.delete(0, tk.END)
       self.e_page.insert(0, '%d' % (self.page + 1))
       self._pdf_cache()
       self.config(cursor='')
       self.state = 'opened'
       self._evaluate_self()
       self.update()

   def _pdf_cache(self):
       self.pdf_images = []
       for p in range(0, self.pages+1, 10):
           images = p2i.convert_from_path(self.doc,
                                          first_page=p, last_page=min(
                                              p+10-1, self.pages),
                                          fmt='jpeg', output_folder='my_tmp/pdfcache')
           for i in images:
               self.pdf_images.append(i)

   def _show_page(self):
       if not self.pages:
           return
       self.config(cursor='watch')
       self.update()
       self.cnv_img.delete('all')
       image = self.pdf_images[self.page]
       # eventuale rotazione
       if self.rotation:
           image = image.rotate(self.rotation, expand=True)
       img_x, img_y = image.size
       if self.adapt:
           # calcola i rapporti di scala
           r_img = img_x / img_y
           r_cnv = self.cnv_img.winfo_width() / self.cnv_img.winfo_height()
           if r_cnv <= r_img:
               f_scala = self.cnv_img.winfo_width() / img_x
           else:
               f_scala = self.cnv_img.winfo_height() / img_y
           fx = int(img_x * f_scala)
           fy = int(img_y * f_scala)
           image = image.resize((fx, fy))
           x = self.cnv_img.winfo_width() // 2
           y = self.cnv_img.winfo_height() // 2
           x_region = self.cnv_img.winfo_width()
           y_region = self.cnv_img.winfo_height()
       elif self.zoom != 0.0:
           f_x = int(img_x * (1 + self.zoom))
           f_y = int(img_y * (1 + self.zoom))
           image = image.resize((f_x, f_y))
           x = f_x // 2
           y = f_y // 2
           x_region = f_x
           y_region = f_y
       else:
           x = img_x // 2
           y = img_y // 2
           x_region = img_x
           y_region = img_y
       self.img = ImageTk.PhotoImage(image)
       self.cnv_img.create_image(x, y, image=self.img, anchor='center')
       self.cnv_img.configure(scrollregion=(0, 0, x_region, y_region))
       self.cnv_img.update()
       self.e_page.delete(0, tk.END)
       self.e_page.insert(0, '%d' % (self.page + 1))
       self.e_page.update()
       self.config(cursor='')
       self.update()

   def _show_info(self):
       if not self.info:
           return
       message = ''
       maxsize = 0
       for key in self.info.keys():
           if len(key) > maxsize:
               maxsize = len(key)
       for key in self.info.keys():
           message += key + ' '*(maxsize-len(key)) + \
               ' : ' + str(self.info[key]) + '\n'
       msg = mydialog.Message(self).show_message(message,
                                                 title='File aperto con successo',
                                                 font='Courier 10')
       center_win(msg)

   def _openpdf(self):
       f_types = [('File pdf', '*.pdf .PDF')]
       f = fdlg.askopenfilename(parent=self,
                                title='Selezione PDF',
                                filetypes=f_types)
       if f:
           self.set_file(f)

   def _closepdf(self):
       self.doc = ''
       self.pages = 0
       self.page = 0
       self.adapt = True
       self.zoom = 0.0
       self.rotation = 0
       self.info = None
       self.state = ''
       self.cnv_img.delete('all')
       self._evaluate_self()

   def _exec_ocr(self):
       if self.text_win == None or not self.text_win.winfo_exists():
           self.text_win = TextUtility(self)
           self.config(cursor='watch')
           self.update()
       if self.all_page.get():
           text = self._make_ocr_text()
           self.text_win.set_text(text)
       else:
           text = self._make_ocr_text(self.page)
           if self.append_page.get():
               self.text_win.add_text(text)
           else:
               self.text_win.set_text(text)
       self.config(cursor='')
       self.update()

   def _getext(self):
       if self.text_win == None or not self.text_win.winfo_exists():
           self.text_win = TextUtility(self)
       if self.all_page.get():
           text = self._make_text()
           self.text_win.set_text(text)
       else:
           text = self._make_text(self.page)
           if self.append_page.get():
               self.text_win.add_text(text)
           else:
               self.text_win.set_text(text)

   def _on_all(self):
       value = self.all_page.get()
       if value:
           self.ck_append.configure(state='disabled')
       else:
           self.ck_append.configure(state='normal')

   def _evaluate_self(self):
       if not self.state:
           self.bt_openpdf.configure(state='normal')
           self.bt_closepdf.configure(state='disabled')
           self.bt_ocr.configure(state='disabled')
           self.bt_getext.configure(state='disabled')
           self.ck_all.configure(state='disabled')
           self.ck_append.configure(state='disabled')
           self.bt_info.configure(state='disabled')
           self.bt_first.configure(state='disabled')
           self.bt_previous.configure(state='disabled')
           self.e_page.configure(state='disabled')
           self.bt_next.configure(state='disabled')
           self.bt_last.configure(state='disabled')
           self.bt_r_left.configure(state='disabled')
           self.bt_r_right.configure(state='disabled')
           self.bt_zout.configure(state='disabled')
           self.bt_zin.configure(state='disabled')
           self.bt_zview.configure(state='disabled')
       else:
           self.bt_openpdf.configure(state='disabled')
           self.bt_closepdf.configure(state='normal')
           self.bt_ocr.configure(state='normal')
           self.bt_getext.configure(state='normal')
           self.ck_all.configure(state='normal')
           self.ck_append.configure(state='normal')
           self.bt_info.configure(state='normal')
           self.bt_first.configure(state='normal')
           self.bt_previous.configure(state='normal')
           self.e_page.configure(state='normal')
           self.bt_next.configure(state='normal')
           self.bt_last.configure(state='normal')
           if self.state:
               self.bt_r_left.configure(state='disabled')
               self.bt_r_right.configure(state='disabled')
           else:
               self.bt_r_left.configure(state='normal')
               self.bt_r_right.configure(state='normal')
           self.bt_zout.configure(state='normal')
           self.bt_zin.configure(state='normal')
           self.bt_zview.configure(state='normal')
       
   def _make_text(self, page=None):
       if not self.doc: return
       with open(self.doc, 'rb') as f:
           pdf = pdftotext.PDF(f)
       if not pdf: return
       if not page == None:
           return '\n'.join(pdf[page].splitlines())
       else:
           alltext=''
           for i in range(self.pages):
               alltext += '\n'.join(pdf[i].splitlines())
           return alltext
   
   def _make_ocr_text(self, page=None):
       if not self.pdf_images:
           return
       if not page == None:
           image = self.pdf_images[page]
           # eventuale rotazione
           if self.rotation:
               image = image.rotate(self.rotation, expand=True)
           return pytesseract.image_to_string(image)
       else:
           text = ''
           for i in range(len(self.pdf_images)):
               image = self.pdf_images[i]
               # eventuale rotazione
               if self.rotation:
                   image = image.rotate(self.rotation, expand=True)
               text += pytesseract.image_to_string(image)
           return text



# ****************************
# *** FUNZIONI DI SERVIZIO ***
# ****************************


def center_win(gui):
   ''' Centra una finestra, passata come parametro, sullo schermo '''
   l = gui.winfo_screenwidth()
   a = gui.winfo_screenheight()
   wx = gui.winfo_reqwidth()
   wy = gui.winfo_reqheight()
   gui.geometry('{}x{}+{}+{}'.format(wx, wy, (l-wx)//2, (a-wy)//2))


if __name__ == '__main__':
   app = PdfViewer()
   app.mainloop()


Nel prossimo post vedremo TextUtility.
Ciao

Edited by nuzzopippo - 9/7/2021, 12:41
view post Posted: 14/6/2021, 10:20 [Python] GUI & PDF a passetti 2 : testo e csv - Appunti di apprendimento
Questa "seconda puntata", pur se più articolata rispetto al precedente post della serie, è ancora nella fase di definizione di prototipi funzionali senza molte pretese, con omesse adeguata strutturazione e la gestione delle eccezioni è decisamente minimale, limitandosi ad eccezioni "di selezione" testo per le manipolazioni impostate.

Qui si provvederà a dare la possibilità di selezionare un file PDF, estrarne il testo e salvarlo direttamente od anche manipolarlo per creare file CSV, rispondendo a questa particolare domanda posta nella discussione che ha dato l'idea per questa "serie".

L'estrazione del testo è prevista tanto quale estrazione diretta tramite pdftotext, con metodologie in linea con quelle già viste in altro post, ovvero tramite OCR utilizzando la libreria pytesseract.
Si faccia attenzione, in merito a pytesseract, tale libreria è un wrapper del programma di ocr "tesseract" che deve essere installato nel sistema. Nei sistemi Linux (ambiente in cui si sta sviluppando) è in genere disponibile nelle varie distribuzioni, nelle quali viene configurato, così potrebbe non essere negli altri sistemi operativi, nei quali potrebbe essere necessaria una configurazione d'uso, si legga la documentazione in merito al proprio s.o.

Informazioni spicciole.


Al fine di contenere le dimensioni del file dedicato alla serializzazione di icone e tooltip, si è optato a creare moduli di risorse specifici per le singole finestre (o genere di finestre) occorrenti, si preciserà in seguito.
Si è anche definita una classe per sostituire le finestre per messaggi di tkinter (che NON mi piacciono) ed una classe specializzata (se fa 'pe di' ;) ) alla "preparazione" del testo per codifica CSV.


I messaggi "personalizzati"


Al momento, una singola classe ("Message"), che può rappresentare, a scelta, un messaggio di informazione, di errore o di warning, è una finestra Toplevel contenente una icona contestuale, una label ed un pulsante; alla sua visualizzazione assume in esclusiva la gestione degli eventi, deve essere chiusa perché le altre finestre possano proseguire nelle loro funzionalità.
Possono essere impostati un messaggio, un titolo ed il font di rappresentazione, per tutti e tre i parametri il passaggio è previsto nelle funzioni di invocazione della tipologia di messaggio da visualizzare. Il messaggio è un parametro obbligatorio, il titolo della finestra ed il font da utilizzare sono opzionali, il font di default è quello di sistema per le label.
Nelle immagini sottostanti vi è esempio delle tre varianti di messaggio.

png
pngpng



Le icone sono selezionate in base al tipo di messaggio tra quelle serializzate nel modulo di risorse
global_ico_and_tip.py
CODICE
# -*- coding: utf-8 -*-

icodlg_error = '''
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
'''

icodlg_info = '''
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
dUA8H/lnyu7kcofUaquAAAAAElFTkSuQmCC
'''

icodlg_warning = '''
iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAA
DdgAAA3YBfdWCzAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAATHSURBVHja1Z
ddbBRVFMf/5947H/vV7rZsv2grpFCk0EIEoiQEMRCjAUwk0UgwvEj0yUR91WCIMcYEE43ogy/6zgM+a
YKY8KIvhjRiAL+iIVrQttDu7sxs5/N6pzt1mt1USWklnuSXczL3njn/c2bnY0lKiXtpTPE/FnCW+Dx3
Ycu/BJ9SEb3F4/BnawhwDvvk7H8n4DzlIp1/HbWfGAMk2OzHl1mbvxs7pLP6l+AiiUhjLwZ256joeBq
i7TCCGX00ssRLAPHVFxAgJ132smh/imB0AJkeiI5jFNl4BReQXzUBaffi1dDr6mK9BwGvOg/rewyhU+
iEECfjPasnoIBiVKcTYs0RgHPArwKBAiFE95MIHHoOQHF1BHxEWlTjb0rqLbHyHkSq+O3pusJBpETw0
k5IWWqPOH8rnsLKC9iCcuSyo6x0EJAuHMtGdcpGZdKBbTmAtMGL+xHa7BlwlFdWwCXVvc/eifhggbeP
AlENgVtDVFc4Fvy5GhBVwXLrIdGV9332XpyzcgJ8rPUdeoKX9gORNV+Ms6oSYCF0auC8CsiYCnj7LiW
KDsFF38oIOEt6YLH3SR/O8kwfEDWKcaEEqOJSTUHoFQA1hTpu5gExkAls9mGce3cCiAgd2BDW6VHeuR
sgS5EUUkVl3VZYSkAVoATMQJSGENh0IM4FES1fwGfQ/YCdQXarzs1MXFhRm0cYlfnupaPiTAVgtYY45
bnpAubaOPcDXIG2TAFEaNO2exbfI8pbAKo0BLAG3KiCPAvkW6pgpXGcJ+s0C63cC7+GPbilbQOWnoLA
UvYJDK8cvMvzwxoXUwB0QDMAEooMIAKY5RyIa4CuA7kIgGwgAU4EmpoQ3uzEGf1z7MXjcO9cwCli2Cc
e9m6wXeZ908DcDwAl86KYOMjD+y0P6dqAUwHcZC022XBadzfqV+RO1sP3ilP0JV6X0Z0JGIFRvxW+zd
p6uCj8/nfh1EfKV5HpanQLrjCRWCpAGNNgpW7mzU6eFiN4CED9Dn4DxMJOfti7nR3Te11ARwMjwUy8B
vQ/W0P/8Vqy3oSu0EKYQxq8SRoN2/iheLL//kFyk7LWFxinNRuGc5t+BljSIUugNHZ+1MEyEuY6f6Hr
2KdEsWewxwcgp67/lN+K7c0fLaz5G88fZ8f8SsdGc10V0BY6SVjU6cyFHG6eLmLijRKscXOh63S/SOA
RMvcT3Elto3+DHQWILS1gEDnnunxN39hHvDAJCKRoqY+L+b8KcMI83i9aWpgvmhg1YNnrMLeuhzr3SV
xFtklA2r07wV4InJ5Bc2gyXmlh8YnbD9ZhdKnuBkIUDtSBCK3IhVgisymAO20O1q+x5+Narb+Bi1Sc+
YauGdse6MluvrS4YApvuhuQ+OgfCBNUbF8bg/vtd392PCKH8aCsphMgInBsc6eMbmPQbkpOCBR+gruI
umIuiT204qe5mSEGv2J2qXhz63OAIdK72ySonyAo7TQ2an7IpD6JE9L9rfkEeFmYG7IAq0etl+Aq5ee
+186Fnrnfmy6Rc/kPrKTxYgHGAJeZ/pnz5qB/JL4dW58DX1EBvtgBTa5Fs4UKjnSzXCJmKg4XqiYxC2
U8YQA3kMdljEm7aQJN74EREFbDrkI2vw/u+d/zvwDcGQDDCcV61AAAAABJRU5ErkJggg==
'''

setico = {"dlg_error" : [icodlg_error, "Messaggio di errore"],
         "dlg_info" : [icodlg_info, "Messaggio di informazione"],
         "dlg_warning" : [icodlg_warning, "messaggio di allerta"]
         }


class IcoDispencer:
   def __init__(self):
       self.icoset = setico

   def getIco(self, chiave):
       return self.icoset[chiave][0]

   def getDescr(self, chiave):
       try:
           descr = self.icoset[chiave][1]
           return descr
       except:
           return None

   def printData(self):
       for chiave in self.icoset:
           print(chiave, ':', self.icoset[chiave][1])

   def getData(self):
       return self.icoset

di 114 righe di codice che, al momento, contiene quelle tre sole icone.

La definizione del particolare tipo di finestra che si vuol visualizzare è "postuma" allo istanziamento della classe, utilizzando lo specifico metodo "show_qualcosa", esempio relativo alla finestra di informazioni, prima in figura :
CODICE
for key in self.info.keys():
           message += key + ' '*(maxsize-len(key)) + \
               ' : ' + str(self.info[key]) + '\n'
       msg = mydialog.Message(self).show_message(message,
                                                 title='File aperto con successo',
                                                 font='Courier 10')
       center_win(msg)

i metodi previsti sono :
  1. show_message(self, message, title=None, font=''), per un generico messaggio informativo;
  2. show_warning(self, message, title=None, font=''), per un avvertimento.
  3. show_error(self, message, title=None, font=''), per segnalare un errore.
il modulo rende anche disponibile una funzione "center_win" propria, per centrare i messaggi sullo schermo evitando di doverla prevedere nei singoli moduli per GUI, come nel caso dell'esempio soprastante.

Il codice delle finestre di messaggio, modulo mydialog.py :
CODICE
import tkinter as tk
import global_ico_and_tip as giat

class Message(tk.Toplevel):
   def __init__(self, master):
       super().__init__(master)
       self.resizable(False, False)
       self.image = None
       self.lbl_image = tk.Label(self)
       self.lbl_image.grid(row=0, column=0, padx=15, pady=15, sticky='nw')
       self.lbl_msg = tk.Label(self, justify='left')
       self.lbl_msg.grid(row=0, column=1, padx=15, pady=15, sticky='nsew')
       btn = tk.Button(self, text=' Ok ', command=self.destroy)
       btn.grid(row=1, column=0, columnspan=2, padx=15, pady=15, sticky='nsew')

   
   def show_message(self, message, title=None, font=''):
       if title:
           self.title(title)
       else:
           self.title('Informazioni')
       image = tk.PhotoImage(data=giat.IcoDispencer().getIco('dlg_info'), master=self)
       self.image=image
       self.lbl_image.configure(image=self.image)
       if font:
           self.lbl_msg.configure(font=font)
       self.lbl_msg.configure(text=message)
       self.update()
       self.grab_set()
       return self
   
   def show_warning(self, message, title=None, font=''):
       if title:
           self.title(title)
       else:
           self.title('Attenzione')
       image = tk.PhotoImage(data=giat.IcoDispencer().getIco('dlg_warning'), master=self)
       self.image=image
       self.lbl_image.configure(image=self.image)
       if font:
           self.lbl_msg.configure(font=font)
       self.lbl_msg.configure(text=message)
       self.update()
       self.grab_set()
       return self
   
   def show_error(self, message, title=None, font=''):
       if title:
           self.title(title)
       else:
           self.title('Informazioni')
       image = tk.PhotoImage(data=giat.IcoDispencer().getIco('dlg_error'), master=self)
       self.image=image
       self.lbl_image.configure(image=self.image)
       if font:
           self.lbl_msg.configure(font=font)
       self.lbl_msg.configure(text=message)
       self.update()
       self.grab_set()
       return self


def center_win(gui):
   ''' Centra una finestra, passata come parametro, sullo schermo '''
   l = gui.winfo_screenwidth()
   a = gui.winfo_screenheight()
   wx = gui.winfo_reqwidth()
   wy = gui.winfo_reqheight()
   gui.geometry('{}x{}+{}+{}'.format(wx, wy, (l-wx)//2, (a-wy)//2))


Ovviamente, in seguito, è prevedibile una diversa "collocazione" per center_win.

Per oggi basta così, il prossimo post verterà su alcune piccole variazioni apportate al modulo pdf_viewer precedentemente trattato.
view post Posted: 23/5/2021, 09:44 [Python] GUI & PDF a passetti piccoli piccoli - Appunti di apprendimento

La versione wx


Come di il nome di questa sezione, ciò che scrivo qui sono solo degli appunti di apprendimento, ciò che segue è il mio primissimo tentativo di costruire una finestra applicativa utilizzando il framework wxPython, tentativo in cui mi è riuscito di ottenere questo

png


... Ammesso qualcuno legga questi post, probabilmente si starà domandando perché mai mi metta a "sviluppare" con due framework diversi una stessa applicazione sostanzialmente identica, sarebbe una giusta domanda.
Come un po' tutti gli iniziandi, anche io mi sono imbattuto nel framework tkinter non appena iniziato ad interessarmi a python, in fin dei conti viene fornito di default con il linguaggio e si trovano in rete molti esempi, seppur elementari, mi è stato facile acquisire una rudimentale capacità di implementare qualcosa con tale framework ma nel contempo ho potuto constatare la presenza di due importanti fattori negativi a suo sfavore :
  1. tkinter è nel complesso rudimentale, carente sotto molti aspetti e mancano alcuni widget di uso comune, tipo tabelle etc.;
  2. fattore più importante, la documentazione di tkinter è molto frammantata e difficilmente reperibile, alcune delle principali fonti informative sono "svanite" nel tempo, inoltre (fattore per me importante) vi è ben poco di buono in lingua italiana
Per contro, il progetto Phoenix, attuale versione delle wxPython, mette a disposizione un ambiente molto sofisticato e maturo, anche se magari un po' complesso, con un'ottima documentazione, ben organizzata e che può essere scaricata e consultata in locale, oltre che di una demo decisamente notevole che espone esempi d'uso di numerosi dei molti controlli disponibili.
... per altro, esiste un'ottima risorsa in italiano, il testo "Capire wxPython, dell'ottimo Riccardo Poligneri, certo è un work in progress da completare ma già così com'è è un ottimo mezzo per apprendere.
Or bene, qui si tratta proprio di "apprendere", i framework grafici sono discorsi complessi che si innestano su quell'altro elemento complesso che è il linguaggio Python, possono essere affrontati ma un passo per volta (da qui i "passetti" che caratterizzeranno queste serie di post).
La parte con tkinter si giustifica col tentativo (in cui spero poco) di portare un po' di iniziandi come me a discutere su quanto qui fatto per migliorarlo tanto nelle tecniche quanto nelle funzioni, la parte wxPython è lo studio che in realtà voglio fare per me, anch'essa aperta a discussioni e miglioramenti ... il tutto, senza alcuna fretta

Veniamo al dunque!


Dopo la chilometrica introduzione sopra, passiamo a considerare il pdf-viewer realizzato tramite le wx, in linea di massima la logica funzionale è tutto sommato analoga a quella implementata per la versione tkinter (e non ci si dilungherà in merito), dei bottoni di comando e delle funzioni di callback che eseguono analoghe "operazioni".
Comunque, una prima differenziazione possiamo già notarla all'avvio della applicazione, come esposto nella sezione "Accortezze" del 2° post, la logica applicata in questo visualizzatore prevede che esista, nella direttrice contenente lo script, una struttura di sub-directory "my_tmp/pdfcache" da crearsi a manina. Non è che non possa farsi altrimenti, intendiamoci, ma la classe "Tk()" di tkinter, che lancia il "mainloop()" è a tutti gli effetti una finestra, non è il caso di "sporcarla" con accorgimenti non pertinenti; il punto di avvio del loop di esecuzione delle wx appartiene alla classe "wx.App" che non è una finestra anche se pur essa avvia tramite "MainLoop()" una thread bloccante per la gestione delle GUI, e possiede svariate proprietà e metodi interessanti, in particolare, nel nostro caso, i metodi "OnInit" ed "OnExit", della sub-classe "AppConsole" ci permetteranno di eseguire prima dell'avvio del MainLoop una verifica ed eventuale creazione creazione delle directory, ridefinendo OnInit, oltre alla pulizia di eventuali residui nella cache al termine del MainLoop, ridefinendo OnExit :
CODICE
class PdfApp(wx.App):
   def OnInit(self):
       app_dir = os.path.abspath(os.path.dirname(sys.argv[0]))
       tmp_dir = os.path.join(app_dir, 'my_tmp')
       if not os.path.exists(tmp_dir) or not os.path.isdir(tmp_dir):
           os.mkdir(tmp_dir)
       self.cache_dir = os.path.join(tmp_dir, 'pdf_cache')
       if not os.path.exists(self.cache_dir) or not os.path.isdir(self.cache_dir):
           os.mkdir(self.cache_dir)
       return True

   def OnExit(self):
       files = glob.glob(os.path.join(self.cache_dir, '*.*'))
       for f in files:
           try:
               os.remove(f)
           except OSError:
               pass
       return True


Altro punto, notevole, di differenza tra i due framework grafici è dato dal fatto che wx mantiene in memoria le immagini applicate ai controlli che possono esporle (mentre tkinter non lo fa), cosa che mi ha spinto ad un ozioso esperimento di creazione "automatizzata" dei pulsanti, definendo una tupla con le chiavi delle icone interessanti
CODICE
...
   ico_keys = ('pg_first', 'pg_left', 'pg_right', 'pg_last', 'r_left',
               'r_right', 'zoom_out', 'zoom_in', 'zoom_view',
               'info', 'win_close')
...

e scrivendo un metodo che "automatizzasse" la creazione di bottoni dotati di icona e tooltip e li restituisse
CODICE
...
   def btn_ico_and_tip(self, pnl, keys):
       buttons = []
       for key in keys:
           img = IaT.IcoDispencer().getIco(key)
           bmpimg = wx.Bitmap(wx.Image(BytesIO(base64.b64decode(img))),
                                       depth=wx.BITMAP_SCREEN_DEPTH)
           bt = GBTB(pnl, wx.ID_ANY, bmpimg, name=key)
           bt.SetToolTip(IaT.IcoDispencer().getDescr(key))
           buttons.append(bt)
       return buttons
...

forse, nel codice, avrete notato che i bottoni vengono definiti tramite uno strano oggetto "GBTB", essa altro non è che una mia abbraviazione di comodo per l'oggetto "GenBitmapButton" della libreria wx così abbreviato nell'importarlo.
Come detto, la libreria wx è molto matura e decisamente più ricca di tkinter, lo testimonia anche il binding dei pulsanti, ove è disponibile un evento specializzato "wx.EVT_BUTTON" che permette di non sottilizzare troppo tra eventi di mouse/tastiera.
Per altro anche la metodologia di definizione della immagine è diversa, forse un attimino meno immediata in wx ma di fondo non più complessa.

Per altro, se leggerete il metodo "_populate" del PdfViewer versione xwPython, noterete che i bottoni NON vengono dichiarati quali variabili di istanza, cosa fattibilissima anche in tkinter, si tratta di una metodologia che di solito non utilizzo, qui ho voluto giocarci un po' (sto apprendendo no? ;) ) ma preferisco "istanziare" (diciamo così), certamente scrivo più codice ma mi confondo meno.

Quanto sopra prevede l'utilizzo dello stesso file "viewer_ico_and_tip.py" utilizzato con tkinter ma non dell'altro modulo per la creazione dei tooltip, wx li prevede già da se come proprietà dei suoi widget.

Anche per la rappresentazione dell'immagine della pagina corrente ho utilizzato un oggetto della libreria wx : ScrolledPanel
È un semplice pannello con scrollbar, niente di che, inizialmente mi ci son perso un attimino in prove varie, ma alla fine ho sub-classato un oggetto specializzato a rappresentante una bitmap che bene o male funziona ;)
CODICE
class ScrollBMP(ScrolledPanel):
   def __init__(self, parent, *args, **kwargs):
       super(ScrollBMP, self).__init__(parent, *args, **kwargs)
       self.bmp_sizer = wx.BoxSizer(wx.VERTICAL)
       self.bmp = None
       self.SetSizer(self.bmp_sizer)
       self.SetupScrolling()
       self.Bind(wx.EVT_SIZE, self.on_resize)
       self.FitInside()
   
   def show_bmp(self, bitmap):
       if self.bmp: self.bmp.Destroy()
       self.Scroll(0, 0)
       self.bmp = wx.StaticBitmap(self, bitmap=bitmap)
       self.bmp_sizer.Add(self.bmp, 1, wx.EXPAND)
   
   def show_pil_img(self, image):
       wx_img = wx.Image(image.width, image.height)
       self.SetVirtualSize(image.width, image.height)
       wx_img.SetData(image.convert('RGB').tobytes())
       bitmap = wx.Bitmap(wx_img)
       self.show_bmp(bitmap)
   
   def on_resize(self, evt):
       self.Layout()


Una differenza "importante" tra wx e tkinter è che mentre il layout delle finestre in tkinter si basa sui gestori di geometri (grid, pack, place) il layout in wx si basa sui "Sizer" di cui fornisce una variegata pleora di classi, oggetti articolati e che richiedono un po' di prove e tempo per capirli ed usarli decentemente (hai non anglofoni suggerisco una lettura del libro di Poligneri in link all'inizio), quanto prodotto funziona "come volevo" ma, certo, può essere migliorato.

Per altro, anche in wx le finestre di dialogo di default in wx mi lasciano un po' insoddisfatto

png


Qui le righe di testo non vanno a capo a c...o come in tkinter ma il font non mi piace! ... vedremo in seguito.

Intanto il codice completo per la versione wx di PdfViewer:
CODICE
# -*- coding: utf-8 -*-

import wx
from wx.lib.buttons import GenBitmapButton as GBTB
from wx.lib.scrolledpanel import ScrolledPanel
import viewer_ico_and_tip as IaT
import pdf2image as p2i
import sys
import os
import glob
from PIL import Image
from io import BytesIO
import base64


class ScrollBMP(ScrolledPanel):
   def __init__(self, parent, *args, **kwargs):
       super(ScrollBMP, self).__init__(parent, *args, **kwargs)
       self.bmp_sizer = wx.BoxSizer(wx.VERTICAL)
       self.bmp = None
       self.SetSizer(self.bmp_sizer)
       self.SetupScrolling()
       self.Bind(wx.EVT_SIZE, self.on_resize)
       self.FitInside()
   
   def show_bmp(self, bitmap):
       if self.bmp: self.bmp.Destroy()
       self.Scroll(0, 0)
       self.bmp = wx.StaticBitmap(self, bitmap=bitmap)
       self.bmp_sizer.Add(self.bmp, 1, wx.EXPAND)
   
   def show_pil_img(self, image):
       wx_img = wx.Image(image.width, image.height)
       self.SetVirtualSize(image.width, image.height)
       wx_img.SetData(image.convert('RGB').tobytes())
       bitmap = wx.Bitmap(wx_img)
       self.show_bmp(bitmap)
   
   def on_resize(self, evt):
       self.Layout()


class PdfViewer(wx.Frame):
   ico_keys = ('pg_first', 'pg_left', 'pg_right', 'pg_last', 'r_left',
               'r_right', 'zoom_out', 'zoom_in', 'zoom_view',
               'info', 'win_close')
   def __init__(self, *args, **kwargs):
       super().__init__(*args, **kwargs)
       self.SetTitle('wxPDFViewer >= Nessun File')
       self.doc = ''
       self.pages = 0
       self.page = 0
       self.pdf_images = []
       self.adapt = True
       self.zoom = 0.0
       self.rotation = 0

       self._populate()
       self.Refresh()
   
   def _populate(self):
       self.SetTitle('wxPDFViewer')
       bg_sizer = wx.BoxSizer(wx.VERTICAL)
       p = wx.Panel(self, style=wx.RAISED_BORDER)
       bts = self.btn_ico_and_tip(p, self.ico_keys)
       bt_sizer = wx.FlexGridSizer(1, 18, 5, 5)
       # navigazione pagine
       lbl = wx.StaticText(p, label='Pagina:')  # column 0
       #bt_sizer.Add(0, 5)
       bt_sizer.Add(lbl, 0, wx.ALIGN_CENTER, border=5)
       bt_sizer.Add(bts[0], border=5)  # column 1
       bt_sizer.Add(bts[1], border=5)  # column 2
       self.t_page = wx.TextCtrl(p, name='e_page')
       l_size = self.t_page.GetSizeFromTextSize(self.t_page.GetTextExtent('99999'))
       self.t_page.SetSize(l_size)
       bt_sizer.Add(self.t_page, 0, wx.ALIGN_CENTER, border=5)  # column 3
       self.lbl_pages = wx.StaticText(p, label='di 100.000')
       bt_sizer.Add(self.lbl_pages, 0, wx.ALIGN_CENTER, border=5)  # column 4
       bt_sizer.Add(bts[2], border=5)  # column 5
       bt_sizer.Add(bts[3], border=5)  # column 6
       bt_sizer.Add(wx.StaticText(p), 0, wx.EXPAND, border=5)  # column 7
       bt_sizer.Add(bts[4], border=5)  # column 8
       bt_sizer.Add(bts[5], border=5)  # column 9
       bt_sizer.Add(wx.StaticText(p), 0, wx.EXPAND, border=5)  # column 10
       bt_sizer.Add(bts[6], border=5)  # column 11
       bt_sizer.Add(bts[7], border=5)  # column 12
       bt_sizer.Add(bts[8], border=5)  # column 13
       bt_sizer.Add(wx.StaticText(p), 0, wx.EXPAND, border=5)  # column 14
       bt_sizer.Add(bts[9], border=5)  # column 15
       bt_sizer.Add(wx.StaticText(p), 0, wx.EXPAND, border=5)  # column 16
       bt_sizer.Add(bts[10], border=5)  # column 17
       p.SetSizer(bt_sizer)
       bt_sizer.AddGrowableCol(7, 1)
       bt_sizer.AddGrowableCol(10, 1)
       bt_sizer.AddGrowableCol(14, 1)
       bt_sizer.AddGrowableCol(16, 1)
       bg_sizer.Add(p, 0, wx.EXPAND|wx.HORIZONTAL)
       self.p_img = ScrollBMP(self, size=wx.Size(-1,400))
       bg_sizer.Add(self.p_img, 1, wx.EXPAND, border=10)
       self.SetSizer(bg_sizer)
       bg_sizer.Fit(self)
       self.Fit()

       dim = self.GetSize()
       self.SetMinSize(dim)

       # BINDINGS e altro
       for i in range(4):
           bts[i].Bind(wx.EVT_BUTTON, self._set_page)
       self.t_page.Bind(wx.EVT_KEY_DOWN, self._set_page)
       bts[4].Bind(wx.EVT_BUTTON, self._rotate)
       bts[5].Bind(wx.EVT_BUTTON, self._rotate)
       # messi qui per controllo progressione
       self.bt_zoom_out = bts[6]
       self.bt_zoom_out.Bind(wx.EVT_BUTTON, self._set_zoom)
       self.bt_zoom_out.Disable()
       self.bt_zoom_in = bts[7]
       self.bt_zoom_in.Bind(wx.EVT_BUTTON, self._set_zoom)
       self.bt_zoom_in.Disable()
       bts[8].Bind(wx.EVT_BUTTON, self._adapt)
       bts[9].Bind(wx.EVT_BUTTON, self._show_info)
       bts[10].Bind(wx.EVT_BUTTON, self._on_closing)

       self.Bind(wx.EVT_SIZE, self._on_resize)
       self.Bind(wx.EVT_CLOSE, self._on_closing)

   def btn_ico_and_tip(self, pnl, keys):
       buttons = []
       for key in keys:
           img = IaT.IcoDispencer().getIco(key)
           bmpimg = wx.Bitmap(wx.Image(BytesIO(base64.b64decode(img))),
                                       depth=wx.BITMAP_SCREEN_DEPTH)
           bt = GBTB(pnl, wx.ID_ANY, bmpimg, name=key)
           bt.SetToolTip(IaT.IcoDispencer().getDescr(key))
           buttons.append(bt)
       return buttons

   # *** CALLBACK BINDING ***
   def _on_resize(self, evt):
       if self.doc and self.pdf_images and self.adapt:
           self._show_page()
       evt.Skip()

   def _on_closing(self, evt):
       self._clear_cache()
       self.Destroy()

   def _set_page(self, evt):
       if not self.doc or not self.pdf_images:
           evt.Skip()
           return
       w_obj = evt.GetEventObject()
       w_name = w_obj.GetName()
       if w_name == 'e_page':
           key = evt.GetKeyCode()
           if not key == wx.WXK_RETURN and not key == wx.WXK_NUMPAD_ENTER:
               evt.Skip()
               return
           try:
               value = int(w_obj.GetValue())
               if 0 <= (value -1) <= self.pages -1:
                   self.page = value -1
               else:
                   raise ValueError('Inserimento non valido')
           except ValueError:
               message = 'Numero di pagina non valido'
               wx.MessageBox(message, 'Set Pagina', wx.OK|wx.ICON_ERROR)
               return
       elif w_name == 'pg_first':
           self.page = 0
       elif w_name == 'pg_left':
           self.page -= 1
           if self.page < 0: self.page = 0
       elif w_name == 'pg_right':
           self.page += 1
           if self.page > self.pages -1: self.page = self.pages -1
       elif w_name == 'pg_last':
           self.page = self.pages -1
       else:
           evt.Skip()
           return
       self._show_page()

   def _adapt(self, evt):
       self.adapt = not self.adapt
       if self.adapt:
           self.zoom = 0.0
           self.bt_zoom_out.Disable()
           self.bt_zoom_in.Disable()
       else:
           self.bt_zoom_out.Enable()
           self.bt_zoom_in.Enable()
       self._show_page()

   def _set_zoom(self, evt):
       if not self.doc or not self.pdf_images:
           evt.Skip()
           return
       w_obj = evt.GetEventObject()
       w_name = w_obj.GetName()
       if w_name == 'zoom_out':
           self.zoom -= 0.1
           if self.zoom < -0.9:
               self.zoom = -0.9
               return
       elif w_name == 'zoom_in':
           self.zoom += 0.1
           if self.zoom > 3.00:
               self.zoom = 3.00
               return
       self._show_page()

   def _rotate(self, evt):
       if not self.doc or not self.pdf_images:
           evt.Skip()
           return
       w_obj = evt.GetEventObject()
       w_name = w_obj.GetName()
       if w_name == 'r_left':
           self.rotation += 90
       elif w_name == 'r_right':
           self.rotation -= 90
       if self.rotation > 270:
           self.rotation = 0
       elif self.rotation < 0:
           self.rotation = 270
       self._show_page()


   def _show_info(self, evt):
       message = ''
       maxsize = 0
       for key in self.info.keys():
           if len(key) > maxsize: maxsize = len(key)
       for key in self.info.keys():
           message += key + ' '*(maxsize-len(key)) + ' : ' + str(self.info[key]) + '\n'
       wx.MessageBox(message, 'PDFInfo',
           wx.OK | wx.ICON_INFORMATION)
   
   # *** METODI ***
   def _clear_cache(self):
       files = glob.glob('my_tmp/pdf_cache/*.*')
       for f in files:
           try:
               os.remove(f)
           except OSError:
               pass


   def set_file(self, f_pathname):
       if not os.path.exists(f_pathname) or not os.path.isfile(f_pathname):
           return
       self._clear_cache()
       self.doc = f_pathname
       f_name = os.path.basename(f_pathname)
       self.SetTitle('wxPDFViewer => ' + f_name)
       self.SetCursor(wx.Cursor(wx.CURSOR_WAIT))
       self._clear_cache()
       self.info = p2i.pdfinfo_from_path(self.doc)
       self.pages = self.info['Pages']
       self.lbl_pages.SetLabel('di %d'%self.pages)
       self.page = 0
       self.t_page.SetValue('%d' % (self.page + 1))
       self._pdf_cache()
       self.SetCursor(wx.Cursor(wx.CURSOR_ARROW))
       self._show_page()

   def _pdf_cache(self):
       self.pdf_images = []
       for p in range(0, self.pages+1, 10):
           images = p2i.convert_from_path(self.doc,
                                          first_page=p, last_page=min(p+10-1, self.pages),
                                          fmt='jpeg', output_folder='my_tmp/pdf_cache')
           for i in images:
               self.pdf_images.append(i)

   def _show_page(self):
       self.SetCursor(wx.Cursor(wx.CURSOR_WAIT))
       image = self.pdf_images[self.page]
       # eventuale rotazione
       if self.rotation:
           image = image.rotate(self.rotation, expand=True)
       img_x, img_y = image.size
       if self.adapt:
           # calcola i rapporti di scala
           r_img = img_x / img_y
           #r_cnv = self.cnv_img.winfo_width() / self.cnv_img.winfo_height()
           r_cnv = self.p_img.GetSize()[0] / self.p_img.GetSize()[1]
           if r_cnv <= r_img:
               f_scala = self.p_img.GetSize()[0] / img_x
           else:
               f_scala = self.p_img.GetSize()[1] / img_y
           fx = int(img_x * f_scala)
           fy = int(img_y * f_scala)
           image = image.resize((fx, fy))
       elif self.zoom != 0.0:
           f_x = int(img_x * (1 + self.zoom))
           f_y = int(img_y * (1 + self.zoom))
           image = image.resize((f_x, f_y))
       self.p_img.show_pil_img(image)
       self.t_page.SetValue('%d' % (self.page + 1))
       self.SetCursor(wx.Cursor(wx.CURSOR_ARROW))





class PdfApp(wx.App):
   def OnInit(self):
       app_dir = os.path.abspath(os.path.dirname(sys.argv[0]))
       tmp_dir = os.path.join(app_dir, 'my_tmp')
       if not os.path.exists(tmp_dir) or not os.path.isdir(tmp_dir):
           os.mkdir(tmp_dir)
       self.cache_dir = os.path.join(tmp_dir, 'pdf_cache')
       if not os.path.exists(self.cache_dir) or not os.path.isdir(self.cache_dir):
           os.mkdir(self.cache_dir)
       return True

   def OnExit(self):
       files = glob.glob(os.path.join(self.cache_dir, '*.*'))
       for f in files:
           try:
               os.remove(f)
           except OSError:
               pass
       return True


if __name__ == '__main__':
   app = PdfApp()
   frame = PdfViewer(parent=None)
   frame.Show()
   frame.set_file('reportlab-sample.pdf')
   app.MainLoop()


Questa serie di post finisce qui, per il prossimo passetto ho intenzione di affrontare l'estrazione del testo da pdf, tanto ordinari quanto da scansione (immagini), a singolo documento, per poi affrontare la creazione di csv scatenante questi post ... sarà un passo intermedio.
view post Posted: 9/5/2021, 06:21 [Python] GUI & PDF a passetti piccoli piccoli - Appunti di apprendimento
Ora che sono state viste le "particolarità" derivate dai miei vezzi, passiamo all'effettivo esame del prototipo di visualizzatore di PDF

L'interfaccia utente


La finestra del visualizzatore, di per se, è molto semplice, si tratta essenzialmente di un canvas, un paio di barre di scorrimento, un insieme di pulsanti, ed una entry, per fare alcune elementari operazioni ed un paio di frame a far da pannelli contenitore.
Riguardo ai controlli dedicati alle operazioni di comando, possono essere così schematizzati :

png

  • Controlli di navigazione - una entry per indicare direttamente il numero di pagina che si vuole visualizzare e quattro pulsante per spostarsi rispettivamente, andando dalla vostra sinistra a destra, alla prima pagina, alla pagina precedente, alla pagina successiva, all'ultima pagina, quando si inserisce un numero nella entry bisogna premere "Invio" perché lo spostamento venga eseguito;
  • Comandi di rotazione - per ruotare di 90° la pagina verso sinistra o verso destra, gli angoli considerati al momento sono 0, 90, 180 e 270, la rotazione è applicata a tutte le pagine del documento;
  • Comandi di zoom - Incrementano/decrementano del 10% le dimensioni delle pagine visualizzate ovvero adattano la pagina all'area disponibile (condizione di default) il range di impostazione va dal 10% al 400%, sempre da sinistra a destra : 1° decremento, 2° incremento, 3° switch Adatta/Dimensioni reali;
  • Pulsante informazioni - fa comparire una finestra di dialogo con le informazioni generali lette dal documento
  • Pulsante chiusura finestra - ... basta la parola ;) .
I comandi relativi alla navigazione, rotazione e zoom hanno immediato riflesso sull'immagine esposta che viene ridefinita e mostrata nel canvas dedicato alla visualizzazione.

Come funzionano le cose


Ribadisco che ora si sta trattando un prototipo preliminare di fattibilità per la sola visualizzazione, al momento molto viene ignorato e tutti i processi avvengono "dentro" la finestra stessa, nel senso che sono implementati all'interno della classe PdfViewer, sub-classamento di un oggetto TopLevel di tkinter, ciò al momento, per un utilizzo "operativo" le cose cambieranno.
PdfViewer espone il metodo "Set_File(f_pathname)" che permette ad un processo esterno di impostare il path-name del file da visualizzare, in tale metodo viene verificata l'effettiva esistenza del file indicato che, se trovato, viene memorizzato quale documento corrente per l'istanza utilizzata, niente vieta che PdfViewer possa avere istanze concorrenti ma, al momento, la cosa darebbe problemi perché viene ripulita la directory dedicata alla cache delle immagini di pagina dei documenti, prima di dare il documento ricevuto in pasto pdf2image per l'estrazione delle informazioni sul documento, tramite la funzione pdf2info.pdfinfo_from_path(path_name), che vengono memorizzate. Immediatamente dopo ripulita una eventual cache ("PdfViewer._clear_cache()") presente e chiamato il metodo della classe "_pdf_cache()"
CODICE
def _pdf_cache(self):
       self.pdf_images = []
       for p in range(0, self.pages+1, 10):
           images = p2i.convert_from_path(self.doc,
                                          first_page=p, last_page=min(p+10-1, self.pages),
                                          fmt='jpeg', output_folder='my_tmp/pdfcache')
           for i in images:
               self.pdf_images.append(i)

È il caso di dare una guardata a questo codice.
In primo luogo possiamo vedere che viene definita una proprietà della istanza corrente di PdfViewer, "self.pdf_images" che è una lista delle immagini generate da pdf2image, Dette immagini sono memorizzare su file, resi temporanei dalla applicazione, memorizzati nella direttrice di cache definita ("<appdir>/my_tmp/pdfcache" al momento).

In merito alla generazione dei files immagine, osservate il ciclo for : vengono generate 10 pagine alla volta ... alla pagina del progetto pdf2image, proprio in fondo, è scritto : Un PDF relativamente grande consumerà tutta la tua memoria e causerà l'arresto del processo (a meno che tu non usi una cartella di output) (scusate la dozzinale traduzione)
Or bene, sono decenni che trasformo pagine PDF in immagini, utilizzando imagemagik per lo più, utilizzando processi analoghi, ho rilevato che quando i files pdf sono "grossi" immancabilmente arriva il crash, la memoria è molto più veloce dell'output su disco, dopo numerose prove ho determinato che operare su una decina di pagine alla volta è un buon compromesso, liberi di fare Vostre scelte ma io mi attengo a tale criterio anche avendo stabilito un output_folder.

Comunque, tralasciando la disgressione, viene utilizzata la funzione "pdf2image.convert_from_path()" per la conversione delle singole pagine in immagini, in vero, si "potrebbe" estrarre una singola pagina mantenendola in memoria, con "pdf2image.convert_from_bytes()", ci ho provato e funziona ma i tempi di attesa sono inaccettabilmente lunghi.

Dal punto di vista funzionale la finestra, al momento, si limita a visualizzare la pagina corrente, riposizionando controlli ed, eventualmente, il contenuto in caso di ridimensionamento della finestra. Tale riposizionamento viene effettuato, nel caso dei controlli, sfruttando la proprietà "weight" di righe e colonne definite tramite il gestore di geometria grid, nel metodo "_populate(self)" della classe PdfViewer :
CODICE
...
       p_tools.grid_columnconfigure(7, weight=1)
       p_tools.grid_columnconfigure(10, weight=1)
       p_tools.grid_columnconfigure(14, weight=1)
       p_tools.grid_columnconfigure(16, weight=1)
...
       p_img.grid_rowconfigure(0, weight=1)
       p_img.grid_columnconfigure(0, weight=1)
       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(1, weight=1)
...

essa viene applicata specificatamente ai vari pannelli (frame) per le proprie esigenze di gestione.

Da tener presente che l'immagine della pagina corrente, in caso si sia nella condizione di adattamento alla dimensione finestra (self.adapt = True), viene ridisegnata adattandola alle dimensioni del canvas contenitore.

... In merito al "ridisegno" della immagine, c'è da dire che in un primo momento lo avevo implementato il maniera tale che si verificasse al ridimensionamento del canvas che la rappresenta, constatando un ritardo di riposta nella riconfigurazione dei controlli della finestra, principalmente riguardo ai pulsanti di zoom e rotazione, tant'è che ho provato a ritardarne il ridisegno nelle rispettive istruzioni di callback (self.after(200, self._show_page)), senza grossi risultati.
Empiricamente, sono giunto alla conclusione che variando le dimensioni della immagine, cosa che avviene tanto zoommando, quanto ruotando, anche le dimensioni del canvas si modificano, scatenando un nuovo evento "<configure>" sul canvas, con conseguente nuovo ridisegno ... sarà giusto?, non ne sono certo ma eliminando detto binder dal canvas e spostandolo sul ridimensionamento della finestra i problemi "di ridisegno" dei controlli sono svaniti. Ho lasciato, commentata nel codice, l'istruzione del binding per il ridimensionamento del canvas, nel caso qualcuno volesse effettuare delle prove.

In merito a rotazioni, zoom ed adattamento, essi sono controllate da una serie di variabili di istanza definite nello "__init__" della classe
CODICE
...
class PdfViewer(tk.Toplevel):
   def __init__(self, *args, **kwargs):
       super().__init__(*args, **kwargs)
       self.title('tkPDFViewer => Nessun File')
       self.doc = ''
       self.pages = 0
       self.page = 0
       self.adapt = True
       self.zoom = 0.0
       self.rotation = 0
       self._populate()
...

Ve ne sono varie, come si può vedere, banalmente, "self.pages" e "self.page" sono destinate a contenere il numero complessivo di pagine e la pagina corrente del documento, "self.adapt" controlla l'adattamento dell'immagine alla finestra, "self.zoom" e "self.roration" stabiliscono, rispettivamente, i fattori di zoom e rotazione da applicare alla immagine originale.
le variabili relative alle pagine vengono definite/utilizzate in svariati punti della classe, le variabili di zoom e rotazione utilizzate solo nel metodo "self._show_page(self)".

Vi è, inoltre, un'altra variabile di istanza "importante" di cui abbiamo già parlato : "self.pdf_images"

Navigazione pagine documento


Piuttosto scontato come metodo, avviene tramite l'utilizzo di quattro pulsanti, "self.bt_first", "self.bt_previous", "self.bt_next", "self.bt_last", ed una casella di immissione testo, "self.e_page", la definizione di tali controlli quale variabili di istanza non è strettamente necessaria ma è comoda sotto vari aspetti, tutti tali controlli hanno il metodo "self._set_page(self, evt)" quale callback dei loro binding
CODICE
...
   def _set_page(self, evt):
       if not self.doc or not self.pdf_images: return
       w_name = str(evt.widget).split('.')[-1]
       if w_name == 'bt_first':
           self.page = 0
       elif w_name == 'bt_previous':
           self.page -= 1
           if self.page < 0: self.page = 0
       elif w_name == 'e_page':
           try:
               value = int(self.e_page.get())
               if 0 <= (value - 1) <= self.pages - 1:
                   self.page = value - 1
               else:
                   raise ValueError('Inserimento non valido')
           except ValueError:
               message = 'Numero di pagina non valido'
               msgb.showinfo('Selezione pagina', message)
               return
       elif w_name == 'bt_next':
           self.page += 1
           if self.page > self.pages-1: self.page = self.pages -1
       elif w_name == 'bt_last':
           self.page = self.pages - 1
       self._show_page()
...

Da notare come l'algoritmo applicato si basi sul nome ricavato dal descrittore dell'oggetto scatenante l'evento per decidere la pagina da rendere "corrente" prima di invocarne il ridisegno. Si è provveduto ad assegnare il "nome" ai controlli al momento della loro istanza.
Data l'intrinseca semplicità del codice non ritengo vi sia bisogno di ulteriori spiegazioni.
L'immagine sottostante mostra l'effetto del pulsante di spostamento alla pagina successiva nelle condizioni di default per la visualizzazione

png



Rotazione pagine


Anche questa è una funzione molto semplice, due pulsanti, "self.bt_r_left" e "self.bt_r_right", per rotazione a destra o a sinistra, in realtà ne sarebbe stato sufficiente uno, entrambi riferenti al metodo "self._rotate(self, evt)" per i callback
CODICE
...
   def _rotate(self, evt):
       w_name = str(evt.widget).split('.')[-1]
       if w_name == 'bt_r_left':
           self.rotation += 90
       elif w_name == 'bt_r_right':
           self.rotation -= 90
       if self.rotation > 270:
           self.rotation = 0
       elif self.rotation < 0:
           self.rotation = 270
       self.after(200, self._show_page)
...

Nuovamente, è il nome del widget scatenante l'evento a determinare l'angolo di rotazione da applicare all'immagine, espresso in gradi sessagesimali, ogni singola pressione varia di +/- 90° l'angolo ri rotazione corrente contenendolo nel range di valori ammessi : 0, 90, 180 e 270.
L'angolo di rotazione definito è, per il momento, applicato alla visualizzazionwe tutte le pagine di un documento.
L'immagine sottostante mostra l'effetto di una rotazione a sinistra applicata alla precedente immagine

png



Adattamento e zoom della visualizzazione


Per lo zoom delle pagine, sono dedicati i pulsanti del terzo gruppo in figura all'inizio di questo post, i tre pulsanti, dalla vostra sinistra andando verso destra, consentono, i primi due rispettivamente, di ridurre del 10% la dimensione dell'immagine e di aumentarla del 10%, entro un range che va dal 10% al 400% della dimensione effettiva.
La funzione di callback che entra in azione è :
CODICE
def _set_zoom(self, evt):
       w_name = str(evt.widget).split('.')[-1]
       if w_name == 'bt_zout':
           self.zoom -= 0.1
           if self.zoom < -0.9:
               self.zoom = -0.9
               return
       elif w_name == 'bt_zin':
           self.zoom += 0.1
           if self.zoom > 3.00:
               self.zoom = 3.00
               return
       self.bt_zout.configure(state='disabled')
       self.bt_zin.configure(state='disabled')
       self.after(200, self._show_page)
       self.bt_zout.configure(state='normal')
       self.bt_zin.configure(state='normal')

ancora una volta, nella definizione dell'azione da intraprendere, entra in gioco il nome del widget ... ma notate le ultime 5 righe, decisamente "strane", esse sono "frutto" di tentativi di controllo del ridisegno dei widget nella predisposizione iniziale con binding sul ridimensionamento del canvas, indicato in precedenza, contenente l'immagine : i pulsanti restavano "premuti" per eccessivo ritardo causato dai molteplici cicli di re-paint che si scatenavano.
Ho lasciato li quel codice nel caso ci si voglia provare ma sparirà, l'istruzione "self.after(200, self._show_page)! (che inserisce un ritardo di 200 millisecondi) dovrebbe essere sufficiente.
Comunque, sotto vedete l'effetto dell'immagine ruotata precedente con qualche fattore di zoom applicato

png



Entrambi i pulsanti appena trattati vengono abilitati o disabilitati dall'azione del terzo pulsante, quest'ultimo è una specie di switch, modifica la condizione della variabile di istanza "self.adapt" da False a True e viceversa, quanto detta variabile è vera i due pulsanti di zoom vengono disabilitati, abilitati alrimenti
CODICE
...
   def _adapt(self):
       self.adapt = not self.adapt
       if self.adapt:
           self.zoom = 0.0
           self.bt_zout.configure(state='disabled')
           self.bt_zin.configure(state='disabled')
       else:
           self.bt_zout.configure(state='normal')
           self.bt_zin.configure(state='normal')
       self._show_page()
...

noterete senz'altro che la variabile di istanza "self.zoom" viene azzerata, non ha molto senso parlare di zoom quando l'immagine viene adattata dinamicamente ad una superficie variabile. Per altro passando alla visualizzazione a "dimensione fissa" (fatte salve le zoommate) l'immagine viene visualizzata alla sua dimensione "effettiva" che è quella della dimensione pagina (probabilmente A4) a 200 DPI di risoluzione

png


... piuttosto grande, non trovate ;)

Applicazione delle impostazioni date


Vi sarete senz'altro accorti che quasi tutti i metodi di callback visti includono una chiamata al metodo di classe "self._show_page()", detto metodo è il "cuore" del visualizzatore
CODICE
def _show_page(self):
       self.config(cursor='watch')
       self.update()
       self.cnv_img.delete('all')
       image = self.pdf_images[self.page]
       # eventuale rotazione
       if self.rotation:
           image = image.rotate(self.rotation, expand=True)
       img_x, img_y = image.size
       if self.adapt:
           # calcola i rapporti di scala
           r_img = img_x / img_y
           r_cnv = self.cnv_img.winfo_width() / self.cnv_img.winfo_height()
           if r_cnv <= r_img:
               f_scala = self.cnv_img.winfo_width() / img_x
           else:
               f_scala = self.cnv_img.winfo_height() / img_y
           fx = int(img_x * f_scala)
           fy = int(img_y * f_scala)
           image = image.resize((fx, fy))
           x = self.cnv_img.winfo_width() // 2
           y = self.cnv_img.winfo_height() // 2
           x_region = self.cnv_img.winfo_width()
           y_region = self.cnv_img.winfo_height()
       elif self.zoom != 0.0:
           f_x = int(img_x * (1 + self.zoom))
           f_y = int(img_y * (1 + self.zoom))
           image = image.resize((f_x, f_y))
           x = f_x // 2
           y = f_y // 2
           x_region = f_x
           y_region = f_y
       else:
           x = img_x // 2
           y = img_y // 2
           x_region = img_x
           y_region = img_y
       self.img = ImageTk.PhotoImage(image)
       self.cnv_img.create_image(x, y, image=self.img, anchor='center')
       self.cnv_img.configure(scrollregion=(0, 0, x_region, y_region))
       self.cnv_img.update()
       self.e_page.delete(0, tk.END)
       self.e_page.insert(0, '%d' % (self.page + 1))
       self.e_page.update()
       self.config(cursor='')
       self.update()

malgrado la sua "lunghezza", occupata più che altro dal calcolo delle impostazioni di visualizzazione, è piuttosto semplice limitandosi ad ottenere l'immagine di indice corrente, ruotarla se è il caso ed, eventualmente, ridimensionarla.
Le "immagini" fornite dal pdf2image hanno tutto il necessaire per le operazioni di ridimensionamento e rotazione occorrenti, essenzialmente presentano le caratteristiche funzionali di un oggetto Image di PIL (anche se non completamente), l'accorgimento da tener presente è la conversione ad immagine compatibile con tkinter tramite l'istruzione "self.img = ImageTk.PhotoImage(image)"

Ultima piccolezza


Gli ultimi due pulsanti sono piuttosto banali, quello etichettato "4" nella prima figura del post espone le informazioni sul documento aperto, raccolte da pdf2image, l'ultimo procede a chiudere la finestra, ripulendo la cache e distruggendo la finestra
... badate bene, chiude la finestra NON l'applicazione, la GUI di cui qui si discute è un prototipo mirato ad essere integrato in un contesto più ampio che si raggiungerà (spero) tra molti post, dopo un lungo percorso.

Torniamo un pochino sul "pulsante 4", con un certo raccapriccio, lo confesso, ho utilizzato i dialoghi standard di tkinter, trovandoli visivamente molto sgradevoli nel mio sistema e molto poco "maneggiabili", definita la funzione "self._show_info(self)" associata al command del pulsante
CODICE
...
   def _show_info(self):
       message = ''
       maxsize = 0
       for key in self.info.keys():
           if len(key) > maxsize: maxsize = len(key)
       for key in self.info.keys():
           message += key + ' '*(maxsize-len(key)) + ' : ' + str(self.info[key]) + '\n'
       msgb.showinfo('File aperto con successo', message)
...

ho avuto questa raccapricciante resa visiva nel mio desk (gnome3 su ubuntu 20.04)

png


Noterete, dal codice, che ho cercato di allineare i valori informativi alla massima lunghezza delle chiavi di informazione ... con risultati ben miseri, il massimo che mi è riuscito ad ottenere è questo :

png


ottenuto dando la direttiva
CODICE
...
       # configuro font e lunghezza messaggi per le msgbox
       self.option_add('*Dialog.msg.font', 'Courier 9')
...

nella inizializzazione della finestra, nel codice che seguirà alla fine del post troverete un paio di insoddisfacenti tentativi per allargare la label del messaggio : niente da fare! Quel maledetto Title va sempre a capo allo stesso punto e non c'è verso (beh, non lo ho trovato) di sub-classare la finestra di dialogo e manipolarne l'output (cosa che in wx si può fare)

In futuro, per tkinter, costruirò delle finestre di dialogo personali!

Per finire, il sorgete intero del pdfviewer:


CODICE
# -*- coding: utf-8 -*-

import tkinter as tk
import tkinter.messagebox as msgb
import viewer_ico_and_tip as IaT
from my_tk_object import CreaToolTip as ctt
import pdf2image as p2i
import os
import glob
from PIL import ImageTk, Image
from io import BytesIO

# images = p2i.convert_from_bytes(open('pdf_file', 'rb').read())

class PdfViewer(tk.Toplevel):
   def __init__(self, *args, **kwargs):
       super().__init__(*args, **kwargs)
       self.title('tkPDFViewer => Nessun File')
       self.doc = ''
       self.pages = 0
       self.page = 0
       self.adapt = True
       self.zoom = 0.0
       self.rotation = 0
       self._populate()
       # configuro font e lunghezza messaggi per le msgbox
       self.option_add('*Dialog.msg.font', 'Courier 9')
       #self.option_add('*Dialog.msg.width', 100)
       #self.option_add('*Dialog.msg.wraplength', '24i')
   
   def _populate(self):
       # TOOLS-BAR
       p_tools = tk.Frame(self)
       p_tools.grid(row=0, column=0, sticky='ew')
       lbl = tk.Label(p_tools, text='Pagina:', justify='left')
       lbl.grid(row=0, column=0, padx=5, pady=5)
       ids = IaT.IcoDispencer()
       # controlli navigazione pagine
       self.ico_pgfirst = tk.PhotoImage(data=ids.getIco('pg_first'))
       self.bt_first = tk.Button(p_tools, image=self.ico_pgfirst, name='bt_first')
       bt_first_ttp = ctt(self.bt_first, ids.getDescr('pg_first'))
       self.bt_first.grid(row=0, column=1, padx=5, pady=5, sticky='nsew')
       self.ico_pgleft = tk.PhotoImage(data=ids.getIco('pg_left'))
       self.bt_previous = tk.Button(p_tools, image=self.ico_pgleft, name='bt_previous')
       bt_previous_ttp = ctt(self.bt_previous, ids.getDescr('pg_left'))
       self.bt_previous.grid(row=0, column=2, padx=5, pady=5, sticky='nsew')
       self.e_page = tk.Entry(p_tools, width=5, name='e_page')
       dida = 'Inserire la pagina da visualizzare'
       e_page_ttp = ctt(self.e_page, dida)
       self.e_page.grid(row=0, column=3, padx=5, pady=5, sticky='ew')
       self.lbl_pages = tk.Label(p_tools, text='di 100.000')
       self.lbl_pages.grid(row=0, column=4, padx=5, pady=5)
       self.ico_pgright = tk.PhotoImage(data=ids.getIco('pg_right'))
       self.bt_next = tk.Button(p_tools, image=self.ico_pgright, name='bt_next')
       bt_next_ttp = ctt(self.bt_next, ids.getDescr('pg_right'))
       self.bt_next.grid(row=0, column=5, padx=5, pady=5, sticky='nsew')
       self.ico_pglast = tk.PhotoImage(data=ids.getIco('pg_last'))
       self.bt_last = tk.Button(p_tools, image=self.ico_pglast, name='bt_last')
       bt_last_ttp = ctt(self.bt_last, ids.getDescr('pg_last'))
       self.bt_last.grid(row=0, column=6, padx=5, pady=5, sticky='nsew')
       # controlli rotazione pagine
       self.ico_rleft = tk.PhotoImage(data=ids.getIco('r_left'))
       self.bt_r_left = tk.Button(p_tools, image=self.ico_rleft, name='bt_r_left')
       bt_r_left_ttp = ctt(self.bt_r_left, ids.getDescr('r_left'))
       self.bt_r_left.grid(row=0, column=8, padx=5, pady=5, sticky='nsew')
       self.ico_rright = tk.PhotoImage(data=ids.getIco('r_right'))
       self.bt_r_right = tk.Button(p_tools, image=self.ico_rright, name='bt_r_right')
       bt_r_right_ttp = ctt(self.bt_r_right, ids.getDescr('r_right'))
       self.bt_r_right.grid(row=0, column=9, padx=5, pady=5, sticky='nsew')
       # controlli zoom pagine
       self.ico_zout = tk.PhotoImage(data=ids.getIco('zoom_out'))
       self.bt_zout = tk.Button(p_tools, image=self.ico_zout, state='disabled', name='bt_zout')
       bt_zout_tpp = ctt(self.bt_zout, ids.getDescr('zoom_out'))
       self.bt_zout.grid(row=0, column=11, padx=5, pady=5, sticky='nsew')
       self.ico_zin = tk.PhotoImage(data=ids.getIco('zoom_in'))
       self.bt_zin = tk.Button(p_tools, image=self.ico_zin, state='disabled', name='bt_zin')
       bt_zin_tpp = ctt(self.bt_zin, ids.getDescr('zoom_in'))
       self.bt_zin.grid(row=0, column=12, padx=5, pady=5, sticky='nsew')
       self.ico_zview = tk.PhotoImage(data=ids.getIco('zoom_view'))
       self.bt_zview = tk.Button(p_tools, image=self.ico_zview, command=self._adapt)
       bt_zview_tpp = ctt(self.bt_zview, ids.getDescr('zoom_view'))
       self.bt_zview.grid(row=0, column=13, padx=5, pady=5, sticky='nsew')
       self.ico_info = tk.PhotoImage(data=ids.getIco('info'))
       self.bt_info = tk.Button(p_tools, image=self.ico_info, command=self._show_info)
       bt_info_tpp = ctt(self.bt_info, ids.getDescr('info'))
       self.bt_info.grid(row=0, column=15, padx=5, pady=5, sticky='nsew')
       self.ico_close = tk.PhotoImage(data=ids.getIco('win_close'))
       self.bt_close = tk.Button(p_tools, image=self.ico_close, command=self._on_closing)
       bt_close_tpp = ctt(self.bt_close, ids.getDescr('win_close'))
       self.bt_close.grid(row=0, column=17, padx=5, pady=5, sticky='nsew')
       p_tools.grid_columnconfigure(7, weight=1)
       p_tools.grid_columnconfigure(10, weight=1)
       p_tools.grid_columnconfigure(14, weight=1)
       p_tools.grid_columnconfigure(16, weight=1)
       p_img = tk.Frame(self)
       p_img.grid(row=1, column=0, sticky='nsew')
       self.cnv_img = tk.Canvas(p_img)
       self.cnv_img.grid(row=0, column=0, sticky='nsew')
       s_v = tk.Scrollbar(p_img, orient=tk.VERTICAL,
                          command=self.cnv_img.yview)
       s_v.grid(row=0, column=1, sticky='ns')
       s_h = tk.Scrollbar(p_img, orient=tk.HORIZONTAL,
                          command=self.cnv_img.xview)
       s_h.grid(row=1, column=0, sticky='ew')
       self.cnv_img.configure(yscrollcommand=s_v.set, xscrollcommand=s_h.set)
       p_img.grid_rowconfigure(0, weight=1)
       p_img.grid_columnconfigure(0, weight=1)
       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(1, weight=1)

       #self.cnv_img.bind('<Configure>', self._on_resize)
       self.bt_first.bind('<Button-1>', self._set_page)
       self.bt_first.bind('<Return>', self._set_page)
       self.bt_first.bind('<KP_Enter>', self._set_page)
       self.bt_previous.bind('<Button-1>', self._set_page)
       self.bt_previous.bind('<Return>', self._set_page)
       self.bt_previous.bind('<KP_Enter>', self._set_page)
       self.e_page.bind('<Return>', self._set_page)
       self.e_page.bind('<KP_Enter>', self._set_page)
       self.bt_next.bind('<Button-1>', self._set_page)
       self.bt_next.bind('<Return>', self._set_page)
       self.bt_next.bind('<KP_Enter>', self._set_page)
       self.bt_last.bind('<Button-1>', self._set_page)
       self.bt_last.bind('<Return>', self._set_page)
       self.bt_last.bind('<KP_Enter>', self._set_page)

       self.bt_r_left.bind('<Button-1>', self._rotate)
       self.bt_r_left.bind('<Return>', self._rotate)
       self.bt_r_left.bind('<KP_Enter>', self._rotate)
       self.bt_r_right.bind('<Button-1>', self._rotate)
       self.bt_r_right.bind('<Return>', self._rotate)
       self.bt_r_right.bind('<KP_Enter>', self._rotate)


       self.bt_zout.bind('<Button-1>', self._set_zoom)
       self.bt_zout.bind('<Return>', self._set_zoom)
       self.bt_zout.bind('<KP_Enter>', self._set_zoom)
       self.bt_zin.bind('<Button-1>', self._set_zoom)
       self.bt_zin.bind('<Return>', self._set_zoom)
       self.bt_zin.bind('<KP_Enter>', self._set_zoom)

       self.bind('<Configure>', self._on_resize)
       self.protocol('WM_DELETE_WINDOW', self._on_closing)
       self.update()
   
   # *** CALLBACK BINDING ***
   def _on_resize(self, evt):
       if self.doc and self.pdf_images:
           self._show_page()
       else:
           return
   
   def _on_closing(self):
       self._clear_cache()
       self.destroy()
   
   def _set_page(self, evt):
       if not self.doc or not self.pdf_images: return
       w_name = str(evt.widget).split('.')[-1]
       if w_name == 'bt_first':
           self.page = 0
       elif w_name == 'bt_previous':
           self.page -= 1
           if self.page < 0: self.page = 0
       elif w_name == 'e_page':
           try:
               value = int(self.e_page.get())
               if 0 <= (value - 1) <= self.pages - 1:
                   self.page = value - 1
               else:
                   raise ValueError('Inserimento non valido')
           except ValueError:
               message = 'Numero di pagina non valido'
               msgb.showinfo('Selezione pagina', message)
               return
       elif w_name == 'bt_next':
           self.page += 1
           if self.page > self.pages-1: self.page = self.pages -1
       elif w_name == 'bt_last':
           self.page = self.pages - 1
       self._show_page()
   
   def _adapt(self):
       self.adapt = not self.adapt
       if self.adapt:
           self.zoom = 0.0
           self.bt_zout.configure(state='disabled')
           self.bt_zin.configure(state='disabled')
       else:
           self.bt_zout.configure(state='normal')
           self.bt_zin.configure(state='normal')
       self._show_page()
   
   def _set_zoom(self, evt):
       w_name = str(evt.widget).split('.')[-1]
       if w_name == 'bt_zout':
           self.zoom -= 0.1
           if self.zoom < -0.9:
               self.zoom = -0.9
               return
       elif w_name == 'bt_zin':
           self.zoom += 0.1
           if self.zoom > 3.00:
               self.zoom = 3.00
               return
       self.bt_zout.configure(state='disabled')
       self.bt_zin.configure(state='disabled')
       self.after(200, self._show_page)
       self.bt_zout.configure(state='normal')
       self.bt_zin.configure(state='normal')
   
   def _rotate(self, evt):
       w_name = str(evt.widget).split('.')[-1]
       if w_name == 'bt_r_left':
           self.rotation += 90
       elif w_name == 'bt_r_right':
           self.rotation -= 90
       if self.rotation > 270:
           self.rotation = 0
       elif self.rotation < 0:
           self.rotation = 270
       self.after(200, self._show_page)
   
   # *** METODI ***
   def _clear_cache(self):
       files = glob.glob('my_tmp/pdfcache/*.*')
       for f in files:
           try:
               os.remove(f)
           except OSError:
               pass

   def set_file(self, f_pathname):
       if not os.path.exists(f_pathname) or not os.path.isfile(f_pathname):
           return
       self.doc = f_pathname
       f_name = os.path.basename(f_pathname)
       self.title('tkPDFViewer => ' + f_name)
       self.config(cursor='watch')
       self.update()
       self._clear_cache()
       self.info = p2i.pdfinfo_from_path(self.doc)
       self.pages = self.info['Pages']
       self.lbl_pages.configure(text='di %d'%self.pages)
       self.page = 0
       self.e_page.delete(0, tk.END)
       self.e_page.insert(0, '%d' % (self.page + 1))
       self._pdf_cache()
       self.config(cursor='')
       self.update()

   def _pdf_cache(self):
       self.pdf_images = []
       for p in range(0, self.pages+1, 10):
           images = p2i.convert_from_path(self.doc,
                                          first_page=p, last_page=min(p+10-1, self.pages),
                                          fmt='jpeg', output_folder='my_tmp/pdfcache')
           for i in images:
               self.pdf_images.append(i)

   def _show_page(self):
       self.config(cursor='watch')
       self.update()
       self.cnv_img.delete('all')
       image = self.pdf_images[self.page]
       # eventuale rotazione
       if self.rotation:
           image = image.rotate(self.rotation, expand=True)
       img_x, img_y = image.size
       if self.adapt:
           # calcola i rapporti di scala
           r_img = img_x / img_y
           r_cnv = self.cnv_img.winfo_width() / self.cnv_img.winfo_height()
           if r_cnv <= r_img:
               f_scala = self.cnv_img.winfo_width() / img_x
           else:
               f_scala = self.cnv_img.winfo_height() / img_y
           fx = int(img_x * f_scala)
           fy = int(img_y * f_scala)
           image = image.resize((fx, fy))
           x = self.cnv_img.winfo_width() // 2
           y = self.cnv_img.winfo_height() // 2
           x_region = self.cnv_img.winfo_width()
           y_region = self.cnv_img.winfo_height()
       elif self.zoom != 0.0:
           f_x = int(img_x * (1 + self.zoom))
           f_y = int(img_y * (1 + self.zoom))
           image = image.resize((f_x, f_y))
           x = f_x // 2
           y = f_y // 2
           x_region = f_x
           y_region = f_y
       else:
           x = img_x // 2
           y = img_y // 2
           x_region = img_x
           y_region = img_y
       self.img = ImageTk.PhotoImage(image)
       self.cnv_img.create_image(x, y, image=self.img, anchor='center')
       self.cnv_img.configure(scrollregion=(0, 0, x_region, y_region))
       self.cnv_img.update()
       self.e_page.delete(0, tk.END)
       self.e_page.insert(0, '%d' % (self.page + 1))
       self.e_page.update()
       self.config(cursor='')
       self.update()
   
   def _show_info(self):
       message = ''
       maxsize = 0
       for key in self.info.keys():
           if len(key) > maxsize: maxsize = len(key)
       for key in self.info.keys():
           message += key + ' '*(maxsize-len(key)) + ' : ' + str(self.info[key]) + '\n'
       msgb.showinfo('File aperto con successo', message)



if __name__ == '__main__':
   app = tk.Tk()
   viewer = PdfViewer()
   viewer.set_file('reportlab-sample.pdf')
   app.mainloop()


Se volete riprodurre quanto sopra ricordate dei due sorgenti nel precedente post.

Il prototipo del viewer versione tkinter è completato, il prossimo post stessa cosa, con wxPython ;)

Edited by nuzzopippo - 12/5/2021, 17:29
138 replies since 5/12/2012