Vai al contenuto
OneKitly

Pulire un testo disordinato: l'ordine delle operazioni che conta davvero

Pubblicato il 08/07/2026 · 15 min di lettura · Strumenti testo e lingua

Daniel Okonkwo

Daniel OkonkwoSviluppatore front-end e redattore Tech presso OneKitly

Performance web · Formati di file

Verificato su 5 fonti

Vedi il profilo
In breve

La pulizia del testo è una pipeline i cui passaggi non commutano: le stesse operazioni in un altro ordine producono un altro testo, e il danno è silenzioso. Eseguito in Node, decodificare le entità HTML prima di togliere i tag trasforma il testo con escape &lt;b&gt; in un vero elemento <b> che il pulitore poi cancella: «scrivi <b> per mostrare un tag» esce come «scrivi per mostrare un tag». Togliere prima i tag e poi decodificare esattamente una volta riproduce ciò che mostra un browser. Comprimere gli spazi con /\s+/ prima di decidere la politica sugli a capo appiattisce tre paragrafi in un'unica sequenza di 80 caratteri, e nessun passaggio successivo ricostruisce i confini: comprimi solo gli spazi orizzontali, con /[^\S\n]+/, dopo le unioni. Deduplicare le righe prima di ripulirne i bordi non trova nulla, perché «alpha» e «alpha» con uno spazio finale sono stringhe diverse: cinque righe restano cinque; ripulisci prima e le stesse cinque scendono a tre. Tre caratteri sopravvivono poi a ogni passaggio ingenuo: U+00A0 (riconosciuto da \s, rimosso da trim), U+200B (riconosciuto da nessuno dei due) e U+FEFF, che \s riconosce ma la proprietà Unicode White_Space no. Sistema prima l'ordine, poi gli invisibili, e solo dopo misura.

Togliere i tag prima di decodificare le entità, ripulire i bordi prima di deduplicare, comprimere gli spazi per ultimo. Tre ordini eseguiti in Node, una pipeline di nove passaggi nella sequenza giusta e i caratteri invisibili — U+00A0, U+200B, U+FEFF — che sopravvivono a ogni pulizia ingenua.

I passaggi di pulizia non commutano

Un pulitore di testo sembra un menu: togliere gli spazi in eccesso, togliere gli a capo, togliere le righe duplicate, togliere i tag HTML. Ogni voce sembra autonoma, così le si spunta nell'ordine in cui l'interfaccia le elenca. Non sono autonome. Ogni passaggio riscrive l'input che vedrà il successivo, e diverse coppie producono un risultato diverso a seconda di quale parte per prima. I guasti sono silenziosi: il testo esce lo stesso, sembra lo stesso plausibile, e quello che hai perso è proprio ciò che non stavi guardando.

Tre coppie causano quasi tutto il danno reale: tag contro entità, spazi contro a capo e deduplicazione contro ripulitura dei bordi. Ognuna è dimostrata sotto su una stringa reale, eseguita anziché ragionata. I nove passaggi ordinati sono semplicemente ciò che discende da questi tre vincoli quando li si rispetta tutti insieme.

Prima i tag, poi le entità, e una sola decodifica

Prendiamo un frammento HTML: <p>Terms &amp;amp; conditions: write &lt;b&gt; to show a bold tag.</p>. Un browser lo rende così: Terms &amp; conditions: write <b> to show a bold tag. Le sequenze con escape sono testo: l'autore voleva una e commerciale letterale e un tag di grassetto letterale e visibile.

Togli prima i tag e decodifica una volta: ottieni esattamente quell'output del browser. Decodifica prima e togli dopo: il &lt;b&gt; è già diventato un vero elemento <b> quando il pulitore parte, e il pulitore lo cancella. Il risultato è «write to show a bold tag», con uno spazio doppio dove stava il soggetto della frase. Togli prima ma decodifica due volte e ottieni il guasto opposto: «write <b> to show a bold tag» contiene ora un tag vivo che nulla mette più con escape, ed è così che un estratto in testo semplice torna a essere markup appena qualcuno lo incolla in una pagina.

La regola dietro tutti e tre gli esiti è breve: un'entità è testo con escape, e la decodifica promuove il testo a markup. Tutto ciò che tratta il markup in modo speciale deve quindi girare prima della promozione. I browser evitano la questione tokenizzando una sola volta, in un unico passaggio, ed è per questo che un vero parser non deve mai deciderlo. Una pipeline di regex sì, e la decisione è: togliere, poi decodificare, esattamente una decodifica.

Gli spazi dopo gli a capo, mai attraverso di essi

Prendi un documento di tre paragrafi, alcuni spezzati su più righe, con una o tre righe vuote di troppo in mezzo. Applica prima /\s+/ sostituito da un solo spazio, perché «togliere gli spazi in eccesso» era la prima casella, e ottieni una sequenza di 80 caratteri senza alcun confine di paragrafo. Nulla a valle può ripristinarli: gli a capo che portavano la struttura erano spazi, e tu hai chiesto di comprimere gli spazi.

Fai passare lo stesso documento nell'ordine corretto — normalizzare i fine riga, ripulire i bordi di ogni riga, unire le righe spezzate, comprimere solo le sequenze orizzontali con /[^\S\n]+/, poi ridurre tre o più a capo a una riga vuota — ed esce a 58 caratteri su due paragrafi, struttura intatta. La differenza non è che una regex sia migliore dell'altra. È che /\s+/ include \n e \r, e una classe come [^\S\n] deliberatamente no.

C'è un secondo ordinamento, più sottile, dentro questo. Prendi «Paragraph one text.», una riga che contiene un solo spazio, e «Paragraph two text.». Dividi su /\n\n/ e trovi un paragrafo, non due, perché la riga separatrice non è vuota: contiene uno spazio. Ripulisci prima i bordi di ogni riga e la stessa divisione ne trova due. Ecco perché la ripulitura delle righe precede ogni decisione sui paragrafi, mentre la compressione delle sequenze la segue: le due operazioni sugli spazi stanno ai lati opposti del passaggio sugli a capo.

Deduplicare per ultimo, perché l'uguaglianza è una proprietà a valle

La deduplicazione delle righe confronta stringhe intere. Dalle le cinque righe «alpha» con uno spazio finale, «beta», «alpha», «beta» seguita da una tabulazione e «gamma», e non toglie nulla: cinque righe in ingresso, cinque in uscita, perché «alpha» e «alpha» più uno spazio sono semplicemente stringhe diverse, come «beta» e «beta» più una tabulazione. Ripulisci prima gli spazi finali e la stessa deduplicazione porta le cinque a tre. I duplicati c'erano da sempre; erano invisibili esattamente come è invisibile uno spazio finale.

La stessa cosa accade sull'esempio lavorato in fondo a questo articolo. Messa al secondo posto, subito dopo la rimozione dei tag, la deduplicazione toglie zero righe. La stessa deduplicazione messa all'ottavo posto ne toglie una, perché nel frattempo lo spazio a larghezza zero è sparito e il doppio spazio è stato compresso, così le due righe che erano da sempre la stessa frase sono finalmente diventate la stessa stringa. La deduplicazione non trova i duplicati; è la normalizzazione a crearli, e la deduplicazione poi li raccoglie.

Una decisione resta tua: le maiuscole. La deduplicazione confronta in modo esatto, quindi «Total» e «total» sono due righe. Il ripiegamento delle maiuscole è una scelta a parte e con perdita, che merita un passaggio proprio e deve restare visibile nell'interfaccia invece di finire sepolta nel deduplicatore — lo stesso argomento che vale per le convenzioni di denominazione, dove la trasformazione è sicura solo se sai da quale convenzione parti.

I caratteri che sopravvivono a ogni pulizia ingenua

Tre punti di codice producono quasi tutto il residuo. U+00A0, lo spazio unificatore, arriva dai word processor, dalle pagine web e dalla tipografia francese e spagnola, dove sta prima dei due punti o dopo una virgoletta di apertura. U+200B, lo spazio a larghezza zero, arriva dagli editor dei CMS e dal testo formattato incollato, come suggerimento invisibile di andata a capo. U+FEFF, lo spazio a larghezza zero unificatore, è ciò che si ottiene decodificando un marcatore di ordine dei byte UTF-8; compare proprio all'inizio dei file prodotti da esportazioni di fogli di calcolo e da strumenti Windows, e talvolta in mezzo dopo una concatenazione ingenua.

Ciò che conta per una pipeline di pulizia è quali di essi i tuoi strumenti vedono, e la risposta non è intuitiva. Eseguito in Node 22: /\s/ riconosce U+00A0 e U+FEFF ma non U+200B. L'escape di proprietà Unicode /\p{White_Space}/u riconosce U+00A0 ma non U+FEFF. Divergono, in entrambe le direzioni: U+0085, il controllo NEL, è riconosciuto dalla proprietà Unicode e non da /\s/. Il motivo è che la grammatica ECMAScript definisce una propria produzione WhiteSpace, che aggiunge il marcatore di ordine dei byte per ragioni storiche, mentre il database dei caratteri Unicode assegna White_Space con criteri propri e non lo attribuisce a U+FEFF.

La normalizzazione non è un sostituto. Applicare NFKC porta U+00A0 a un normale U+0020, il che è davvero utile, ma lascia U+200B esattamente dov'era: la stringa resta lunga un carattere anche dopo. Perciò la pipeline ha bisogno di un passaggio di cancellazione esplicito per i caratteri di formato — U+200B, U+200C, U+200D, U+FEFF, U+00AD — e di un passaggio di conversione a parte per gli spazi esotici. Nessuno dei due può essere delegato a una regex sugli spazi, perché per una regex sugli spazi metà di essi non sono spazi.

Un esempio disordinato, nove passaggi, prima e dopo

L'esempio è lungo 126 caratteri e contiene, di proposito, tutti i problemi discussi sopra: due spazi iniziali, fine riga CRLF, un h2 e tre elementi p, una e commerciale doppiamente codificata, uno spazio a larghezza zero incollato alla fine di una frase, tre a capo consecutivi, una frase ripetuta alla lettera, uno spazio interno raddoppiato, un paragrafo spezzato su due righe con la continuazione rientrata, e un marcatore di ordine dei byte più due spazi proprio alla fine.

Passato attraverso i nove passaggi in ordine, scende a 58 caratteri: una riga di titolo «Q3 &amp; Q4 report», una riga «Revenue rose 12%.», una riga vuota, poi «Costs fell» e «slightly.» come due righe di uno stesso paragrafo. Le lunghezze intermedie sono 122 dopo la normalizzazione dei fine riga, 92 dopo la rimozione dei tag, 88 dopo l'unica decodifica di entità, 86 una volta cancellati lo spazio a larghezza zero e il marcatore di ordine dei byte, 80 dopo la ripulitura di ogni riga, 77 dopo la compressione orizzontale, 76 dopo la riduzione del triplo a capo, e 58 dopo la deduplicazione.

Due di questi numeri sono l'argomento dell'intero articolo. Il calo da 76 a 58 è la riga duplicata, ed esiste solo perché i passaggi 4 e 8 sono venuti prima; sposta la deduplicazione al secondo posto e quel calo vale zero. Il passaggio da 86 a 80 è la ripulitura riga per riga, ed è ciò che poi permette alla divisione in paragrafi di vedere una riga separatrice davvero vuota. Tutto il resto è contabilità.

Prima di consegnare il testo pulito

Conserva l'originale. Ogni passaggio di questa pipeline è distruttivo per progetto, e nessuno è reversibile: non puoi recuperare quali spazi fossero unificatori, quale a capo fosse una spezzatura e quale un paragrafo, né quale di due righe identiche fosse quella da tenere. Il testo pulito è un artefatto derivato, e un artefatto derivato non deve mai essere l'unica copia.

Poi fai girare la pipeline due volte sul suo stesso output. Una pulizia ordinata correttamente è idempotente: la seconda passata non deve cambiare nulla. Se cambia qualcosa, hai un passaggio che non è stabile sotto ripetizione, e in pratica è quasi sempre la decodifica delle entità: l'unica operazione dell'elenco che può crearsi nuovo lavoro da sola. Un controllo di idempotenza costa una riga di codice e intercetta la classe di bug che compare solo quando un documento ripassa nello strumento sei mesi dopo, perché qualcuno lo ha reimportato.

Infine, conta solo alla fine. Qualsiasi lunghezza, conteggio di parole o indice di leggibilità preso a metà pipeline misura una stringa che non esiste più, e una lunghezza dipende in particolare da che cosa sei disposto a chiamare carattere: una questione da chiarire a parte prima di fidarti di qualunque numero ti dia un contatore.

Sei caratteri invisibili e se gli strumenti per gli spazi di JavaScript li vedono. Eseguito in Node 22: U+FEFF e U+0085 divergono in direzioni opposte, perché la produzione WhiteSpace di ECMAScript e la proprietà Unicode White_Space non sono la stessa lista.
CaratterePunto di codice/\s/ lo riconosce/\p{White_Space}/u lo riconosce.trim() lo rimuove
Spazio unificatoreU+00A0
Spazio unificatore strettoU+202F
Spazio a larghezza zeroU+200BNoNoNo
Spazio a larghezza zero unificatore, il marcatore di ordine dei byteU+FEFFNo
Riga successivaU+0085NoNo
Trattino morbidoU+00ADNoNoNo
Rimuovi spazi in eccessoRiduci gli spazi ripetuti e taglia ogni riga per pulire il testo.Prova lo strumento

Domande frequenti

Se lancio il pulitore due volte, l'ordine conta ancora?
Sì, e lanciarlo due volte può peggiorare le cose. La ripetizione non ricrea l'informazione distrutta da un passaggio precedente: i confini di paragrafo compressi restano compressi, per quante passate tu faccia. Nel frattempo la decodifica delle entità non è idempotente: una seconda passata decodifica &amp;amp; una seconda volta, trasformando una e commerciale letterale voluta nel testo in una strutturale. Il test giusto non è lanciarlo due volte per un risultato migliore, ma lanciarlo due volte per confermare che la seconda passata non cambia nulla.
Perché .trim() rimuove il marcatore di ordine dei byte ma non lo spazio a larghezza zero?
Perché trim è definito rispetto alla produzione WhiteSpace di ECMAScript e non rispetto alla proprietà Unicode White_Space, e quella produzione elenca esplicitamente U+FEFF per ragioni storiche che risalgono a quando il marcatore di ordine dei byte si trovava abitualmente all'inizio di un flusso. U+200B non è mai stato in quell'elenco: Unicode lo classifica come carattere di formato nella categoria generale Cf, perché segnala un'opportunità di andare a capo e non uno spazio fra parole. Quindi trim ne rimuove uno e non l'altro, e né /\s/ né trim ti aiuteranno mai con U+200B. Cancellalo esplicitamente.
C'è mai una buona ragione per usare /\s+/ su un intero documento?
Sì, in esattamente una situazione: quando hai deciso che l'output è una riga sola e la struttura è irrilevante — una chiave di ricerca, un'impronta per il confronto, un valore destinato a una cella CSV di una riga. Lì appiattire tutto in spazi singoli è proprio lo scopo. Ovunque l'output venga letto da una persona, /\s+/ è la classe sbagliata, perché tratta l'a capo che separa due paragrafi e i due spazi dopo un punto come la stessa cosa. Usala di proposito per le chiavi e mai come impostazione predefinita per la prosa.
Come faccio anche solo a vedere che c'è un carattere invisibile?
Confronta la lunghezza che ti aspetti con quella che ottieni, poi stampa i punti di codice. Due stringhe che a schermo appaiono identiche possono avere lunghezze diverse: è tutta lì la spia. Quando la lunghezza ti sorprende, elenca ogni carattere con il suo punto di codice in esadecimale e il colpevole salta all'occhio: un U+00A0 dove supponevi un U+0020, o un U+200B incollato alla fine di una frase. Farlo una volta su un campione di ogni sorgente che importi ti dirà quali produttori della tua catena emettono quali caratteri, e potrai poi cancellare esattamente quelli.
La deduplicazione deve preservare l'ordine originale delle righe?
Quasi sempre sì, e dovrebbe conservare la prima occorrenza anziché l'ultima. Ordinare per individuare i duplicati è un'abitudine ereditata dalle pipeline da riga di comando, e riordina in silenzio un documento il cui ordine portava significato: un elenco di passaggi, un registro delle modifiche, una trascrizione. Conservare la prima occorrenza corrisponde anche al modo in cui si legge: la riga precedente è di solito quella con il contesto intorno. Se uno strumento propone di ordinare mentre deduplica, trattalo come due operazioni distinte e chiedi solo quella che vuoi.

Articoli che potrebbero interessarti

Tutte le guide
GuidaTogliere il Markdown: che cosa perde il testo semplice, e che cosa sbaglia una regexUn link diventa testo con la destinazione cancellata, un elenco annidato perde la gerarchia, una tabella diventa una fila di parole. Poi la metà tecnica: il markdown non ha una sola specifica, e un ripulitore a base di regex rovina un nome di file, un segno di moltiplicazione e l'interno di un blocco di codice — tutto messo a confronto con un vero parser.GuidaTogliere l'HTML come si deve: che cosa può e non può fare un rimuovitore di tagTogliere i tag e sanificare l'HTML sono due lavori diversi. Un frammento reale passato attraverso una regex ingenua e attraverso un rimuovitore consapevole della formattazione, con i contenuti di script e style, le interruzioni di blocco, i commenti, i CDATA e l'ordine delle entità mostrati in output.SpiegazioneMaiuscolo di frase e maiuscolo di titolo: le regole cambiano con la linguaIl maiuscolo di titolo inglese ha tre soglie diverse a seconda del manuale di stile. Francese, spagnolo, portoghese e italiano non ne hanno nessuna. Il tedesco scrive maiuscolo ogni sostantivo. Lo strumento non sa nulla di tutto ciò: ecco esattamente cosa fa.SpiegazioneTrovare i duplicati di un elenco senza foglio di calcoloDue righe che sembrano identiche spesso non lo sono. Le maiuscole, uno spazio finale, uno spazio unificatore e due codifiche diverse della stessa lettera accentata sono stati passati nel rilevatore di duplicati, che in tre casi su quattro non ne ha segnalato nessuno.GuidaPulire un elenco incollato da un foglio di calcolo o da un PDFUn incolla porta con sé caratteri invisibili: spazi unificatori, trattini morbidi, spazi a larghezza zero, tabulazioni e CRLF. Quattro strumenti di pulizia sono stati eseguiti su ciascuno, e usano tre definizioni diverse di spazio.SpiegazioneRilevare la lingua di un testo, e perché i testi brevi fallisconoMisurato, non affermato: 90 frasi brevi reali in sei lingue, nessuna rifiutata e 68 giuste — il 76%, che scende al 64% sotto le sedici lettere. Quattro delle risposte sbagliate sono tornate con il 100% di fiducia.

Strumenti correlati

Fonti

Hai notato un errore in questo articolo?