Kirjautuminen

Haku

Tehtävät

Koodit: Python: Unicode-merkkien tarkastelu

Kirjoittaja: qalle

Kirjoitettu: 28.05.2016 – 17.07.2016

Tagit: ohjelmointitavat, teksti, sovellus, vinkki

Ohjelma lukee tekstitiedoston sekä listaa kunkin siinä esiintyvän merkin, sen Unicode-koodipisteen, tyypin (General Category), nimen, esiintymismäärän ja osuuden kaikista merkeistä. Listaus kirjoitetaan UTF-8-koodattuna toiseen tekstitiedostoon lajiteltuna koodipisteiden, nimien, tyyppien tai esiintymismäärän mukaan.

Ohjelma demonstroi unicodedata-moduulia, lajittelua, komentoriviparametreja (getopt-moduuli) ja tekstipohjaisen taulukon tulostamista siististi (kunkin sarakkeen leveydeksi tulee pisin siinä esiintyvä arvo).

Ohje suoraan ohjelmasta

charfreq.py v. 1.2 - laskee tekstitiedostossa esiintyvien merkkien määrät ja
kirjoittaa ne UTF-8-muotoiseen tekstitiedostoon

Parametrit: ASETUKSET LÄHDE KOHDE
    ASETUKSET (kaikki vapaaehtoisia):
        -eX,
        --encoding=X
            määritä lähdetiedoston merkistökoodaukseksi X; esimerkkejä:
                utf-8        UTF-8 (oletus)
                utf-16-le    UTF-16 little-endian
                utf-16-be    UTF-16 big-endian
                cp1252       Windows-1252
            katso myös:
            http://docs.python.org/3/library/codecs.html#standard-encodings
        -l (pieni L),
        --lowercase
            laske kaikki lähdetiedoston kirjaimet pieniksi kirjaimiksi
        -sX,
        --sort=X
            järjestys, jossa merkit listataan; X on:
                c    koodipiste (oletus)
                n    Unicode-nimi
                g    tyyppi (General Category)
                f    esiintymismäärä
        -7,
        --7bit
            älä näytä kohdetiedostossa muiden kuin 7-bittiseen ASCII:hin
            kuuluvien merkkien ulkoasua
    LÄHDE: luettavan tekstitiedoston nimi
    KOHDE: kirjoitettavan tekstitiedoston nimi

Esimerkkituloste

Lähdetiedosto: "xxx.txt", utf-8, 15 tavu(a), 13 merkki(ä), 10 eri merkki(ä)
Laskettiinko kaikki kirjaimet pieniksi: ei

Sarakkeet:
- koodipiste 10-kantaisena
- koodipiste 16-kantaisena
- tyyppi (General Category)
- merkki (jos tulostettava)
- nimi
- esiintymismäärä
- osuus kaikista merkeistä

 10   A Cc   (tuntematon)                           1  7.69%
 32  20 Zs   SPACE                                  1  7.69%
 46  2E Po . FULL STOP                              1  7.69%
 97  61 Ll a LATIN SMALL LETTER A                   3 23.08%
101  65 Ll e LATIN SMALL LETTER E                   2 15.38%
105  69 Ll i LATIN SMALL LETTER I                   1  7.69%
111  6F Ll o LATIN SMALL LETTER O                   1  7.69%
117  75 Ll u LATIN SMALL LETTER U                   1  7.69%
337 151 Ll ő LATIN SMALL LETTER O WITH DOUBLE ACUTE 1  7.69%
916 394 Lu Δ GREEK CAPITAL LETTER DELTA             1  7.69%

Ohjelmalistaus

"""charfreq.py - laskee tekstitiedostossa esiintyvien merkkien määrät"""

import sys
import os.path
import getopt
import time
import unicodedata

# merkkejä, joiden General Category -ominaisuuden ensimmäinen merkki Unicodessa
# on jokin näistä, ei tulosteta (mm. ohjausmerkkejä, kuten rivinvaihtoja)
NONPRINTABLE_HILEVEL_CATS = ("C", "M", "Z")

# monenko lähdetiedostosta luetun rivin välein tulostetaan näytölle piste (".")
# edistymisen merkiksi
LINES_PER_DOT = 10 ** 6

# sarakkeiden väliin tulostettava merkki/merkit
COLUMN_SEPARATOR = " "

# mitä näytetään niiden merkkien nimenä, joita ei löydy unicodedata:sta
UNKNOWN_CHAR_NAME = "(tuntematon)"

HELP_TEXT = """\
charfreq.py v. 1.2 - laskee tekstitiedostossa esiintyvien merkkien määrät ja
kirjoittaa ne UTF-8-muotoiseen tekstitiedostoon

Parametrit: ASETUKSET LÄHDE KOHDE
    ASETUKSET (kaikki vapaaehtoisia):
        -eX,
        --encoding=X
            määritä lähdetiedoston merkistökoodaukseksi X; esimerkkejä:
                utf-8        UTF-8 (oletus)
                utf-16-le    UTF-16 little-endian
                utf-16-be    UTF-16 big-endian
                cp1252       Windows-1252
            katso myös:
            http://docs.python.org/3/library/codecs.html#standard-encodings
        -l (pieni L),
        --lowercase
            laske kaikki lähdetiedoston kirjaimet pieniksi kirjaimiksi
        -sX,
        --sort=X
            järjestys, jossa merkit listataan; X on:
                c    koodipiste (oletus)
                n    Unicode-nimi
                g    tyyppi (General Category)
                f    esiintymismäärä
        -7,
        --7bit
            älä näytä kohdetiedostossa muiden kuin 7-bittiseen ASCII:hin
            kuuluvien merkkien ulkoasua
    LÄHDE: luettavan tekstitiedoston nimi
    KOHDE: kirjoitettavan tekstitiedoston nimi\
"""

# kohdetiedoston alkuun kirjoitettava teksti
INTRO_TEXT = """\
Lähdetiedosto: "{file:s}", {encoding:s}, {fileSize:d} tavu(a), \
{totalCharCount:d} merkki(ä), {uniqueCharCount:d} eri merkki(ä)
Laskettiinko kaikki kirjaimet pieniksi: {lowerCase:s}

Sarakkeet:
- koodipiste 10-kantaisena
- koodipiste 16-kantaisena
- tyyppi (General Category)
- merkki (jos tulostettava)
- nimi
- esiintymismäärä
- osuus kaikista merkeistä
\
"""

def format_for_stdout(text):
    """Muotoilee tekstin niin, että se voidaan tulostaa stdout:iin. (Korvaa
    ei-tulostettavat merkit kenoviiva-alkuisilla koodeilla.)

    Parametrit:
        text: teksti merkkijonona

    Palautusarvo: teksti merkkijonona
    """

    return (
        text
        .encode(sys.stdout.encoding, errors = "backslashreplace")
        .decode(sys.stdout.encoding)
    )

def count_chars(hnd, lowerCase):
    """Laskee eri koodipisteiden esiintymismäärät tiedostossa.

    Parametrit:
        hnd: luettavan tiedoston kahva
        lowerCase: lasketaanko kaikki kirjaimet pieniksi

    Palautusarvo: dict, jossa koodipisteet avaimina ja esiintymismäärät arvoina

    Tulostaa myös tiedoston lukemisen edistymisen näytölle pisteinä (".").
    """

    hnd.seek(0)
    freqs = {}

    for (lineNum, line) in enumerate(hnd):
        if lowerCase:
            line = line.lower()

        for char in line:
            CP = ord(char)
            freqs[CP] = freqs.get(CP, 0) + 1

        if lineNum % LINES_PER_DOT == 0:
            print(".", end = "", flush = True)

    print()
    return freqs

def digits_required(number, base = 10):
    """Laskee, montako numeroa kokonaisluvun esittämiseen tarvitaan.
    (Logaritmit olisivat epätarkkoja suurten lukujen kanssa.)

    Parametrit:
        number: esitettävä kokonaisluku
        base: esityksen kantaluku (2, 8, 10 tai 16; oletus on 10)

    Palautusarvo: luvun esittämiseen tarvittavien numeroiden määrä
    kokonaislukuna
    """

    # valitse kantalukukoodi format()-funktiota varten
    code = {2: "b", 8: "o", 10: "d", 16: "x"}.get(base)
    if code is None:
        raise ValueError

    return len(format(number, code))

def create_line_format(CPFreqs):
    """Muodostaa .format()-metodille kelpaavan muotoilukoodin ohjelman
    tulosrivien kirjoittamista varten.

    Parametrit:
        CPFreqs: dict, jossa koodipisteet avaimina ja esiintymismäärät arvoina

    Palautusarvo: muotoilukoodi merkkijonona
    """

    # suurin koodipiste
    maxCP = max(CPFreqs)

    # yleisimmän merkin esiintymismäärä
    maxFreq = max(CPFreqs.values())

    # merkkien kokonaismäärä
    totalChars = sum(CPFreqs.values())

    # montako merkkiä tarvitaan merkkien nimien esittämiseen
    maxNameLen = max(len(unicodedata.name(chr(CP), "")) for CP in CPFreqs)
    maxNameLen = max(maxNameLen, len(UNKNOWN_CHAR_NAME))

    # montako merkkiä tarvitaan prosenttiosuuksien esittämiseen
    maxPercentageLen = len(format(maxFreq / totalChars * 100, ".2f"))

    # muodosta muotoilukoodi
    return COLUMN_SEPARATOR.join((
        "{{CP:{0:d}d}}".format(digits_required(maxCP)),
        "{{CP:{0:d}X}}".format(digits_required(maxCP, 16)),
        "{cat:s}",
        "{char:s}",
        "{{name:{0:d}s}}".format(maxNameLen),
        "{{freq:{0:d}d}}".format(digits_required(maxFreq)),
        "{{percentage:{0:d}.2f}}%".format(maxPercentageLen),
    ))

def sort_chars(CPFreqs, sortBy):
    """Lajittelee koodipisteet tulostusta varten.

    Parametrit:
        CPFreqs: dict, jossa koodipisteet avaimina ja esiintymismäärät arvoina
        sortBy: lajitteluperuste:
            c = koodipiste
            n = Unicode-nimi, koodipiste
            g = tyyppi (General Category), koodipiste
            f = esiintymismäärä, koodipiste

    Palautusarvo: lista, jossa lajitellut koodipisteet
    """

    # lajittele nousevasti koodipisteen mukaan; tämä jää toissijaiseksi
    # lajitteluperusteeksi, jos myöhemmin tehdään toinen lajittelu
    sortedCPs = sorted(CPFreqs)

    # mahdollinen toinen lajittelu
    if sortBy == "n":
        # nouseva Unicode-nimijärjestys
        sortedCPs.sort(key = lambda CP: unicodedata.name(chr(CP), ""))
    elif sortBy == "g":
        # nouseva tyyppijärjestys (General Category)
        sortedCPs.sort(key = lambda CP: unicodedata.category(chr(CP)))
    elif sortBy == "f":
        # laskeva yleisyysjärjestys
        sortedCPs.sort(key = lambda CP: CPFreqs[CP], reverse = True)
    elif sortBy != "c":
        raise ValueError

    return sortedCPs

def print_chars(CPFreqs, sortBy, ASCIIOnly, hnd):
    """Kirjoittaa merkkien tiedot kohdetiedostoon.

    Parametrit:
        CPFreqs: dict, jossa koodipisteet avaimina ja esiintymismäärät arvoina
        sortBy: lajitteluperuste, ks. sort_chars()
        ASCIIOnly: näytetäänkö vain 7-bittiseen ASCII:hin kuuluvien merkkien
        ulkoasu (boolean)
        hnd: kirjoitettavan tiedoston kahva

    Palautusarvo: None
    """

    # muodosta tulosrivien muotoilukoodi
    lineFormat = create_line_format(CPFreqs)

    # lajittele koodipisteet tulostusta varten
    sortedCPs = sort_chars(CPFreqs, sortBy)

    # merkkien kokonaismäärä
    totalChars = sum(CPFreqs.values())

    # kirjoita merkkien tiedot kohdetiedostoon
    for CP in sortedCPs:
        char = chr(CP)
        cat = unicodedata.category(char)
        freq = CPFreqs[CP]

        if cat[0] in NONPRINTABLE_HILEVEL_CATS or ASCIIOnly and CP >= 0x80:
            printableChar = " "
        else:
            printableChar = char

        print(lineFormat.format(
            CP = CP,
            cat = cat,
            char = printableChar,
            name = unicodedata.name(char, UNKNOWN_CHAR_NAME),
            freq = freq,
            percentage = freq / totalChars * 100,
        ), file = hnd)

    return None

def main():
    """Pääohjelma."""

    # koko ohjelman suoritusaika näytetään lopuksi
    startTime = time.time()

    # jos komentoriviparametreja ei ole annettu, näytä ohje ja poistu
    if len(sys.argv) == 1:
        exit(HELP_TEXT)

    # lue komentoriviparametrit getopt-moduulilla (opts:iin "-"- ja
    # "--"-alkuiset, args:iin muut)
    try:
        (opts, args) = getopt.getopt(
            sys.argv[1:], "e:ls:7", ["encoding=", "lowercase", "sort=", "7bit"]
        )
    except getopt.GetoptError:
        exit('Virhe "-"- ja "--"-alkuisissa komentoriviparametreissa.')

    if len(args) != 2:
        exit("Virhe: tiedostoparametreja on oltava kaksi.")

    # muunna "-"-alkuiset parametrit dict:iksi, jotta ne on helpompi lukea
    opts = dict(opts)

    # hae asetukset parametreista
    (sourceFile, targetFile) = args
    inputEncoding = opts.get("--encoding", opts.get("-e", "utf-8"))
    lowerCase = "--lowercase" in opts or "-l" in opts
    sortBy = opts.get("--sort", opts.get("-s", "c"))
    printASCIIOnly = "--7bit" in opts or "-7" in opts

    # poistu, jos lajitteluperuste ei kelpaa
    if sortBy not in ("c", "n", "g", "f"):
        exit("Virhe: lajitteluperuste ei kelpaa.")

    # poistu, jos merkistökoodaus on tuntematon
    try:
        temp = "a".encode(inputEncoding)
    except LookupError:
        exit(
            'Virhe: tuntematon merkistökoodaus "{0:s}".'
            .format(format_for_stdout(inputEncoding))
        )

    # poistu, jos lähde- ja kohdetiedosto ovat samat
    try:
        if os.path.samefile(sourceFile, targetFile):
            exit("Virhe: lähde- ja kohdetiedosto eivät saa olla samat.")
    except FileNotFoundError:
        pass
    except OSError:
        # esim. "zzz:" Windowsissa
        pass

    # lue lähdetiedosto
    print(
        'Luetaan lähdetiedosto "{0:s}"...'
        .format(format_for_stdout(os.path.basename(sourceFile)))
    )
    try:
        with open(sourceFile, "rt", encoding = inputEncoding) as inHnd:
            fileSize = inHnd.seek(0, 2)
            if fileSize == 0:
                exit("Virhe: tiedosto on tyhjä.")

            # laske merkkien esiintymismäärät
            try:
                CPFreqs = count_chars(inHnd, lowerCase)
            except UnicodeDecodeError:
                exit(
                    'Virhe: tiedosto ei ole merkistökoodauksen "{0:s}" '
                    'mukainen.'.format(format_for_stdout(inputEncoding))
                )
    except FileNotFoundError:
        exit("Virhe: tiedostoa ei löydy.")
    except PermissionError:
        exit("Virhe: tiedoston lukemiseen ei ole oikeuksia.")
    except Exception as e:
        exit("Virhe luettaessa tiedostoa: " + str(e))

    # kirjoita kohdetiedosto
    print(
        'Kirjoitetaan kohdetiedosto "{0:s}"...'
        .format(format_for_stdout(os.path.basename(targetFile)))
    )
    try:
        with open(targetFile, "wt", encoding = "utf-8") as outHnd:
            # kirjoita alkutekstit
            print(INTRO_TEXT.format(
                file = os.path.basename(sourceFile),
                fileSize = fileSize,
                encoding = inputEncoding,
                lowerCase = "kyllä" if lowerCase else "ei",
                totalCharCount = sum(CPFreqs.values()),
                uniqueCharCount = len(CPFreqs),
            ), file = outHnd)

            # kirjoita merkkien tiedot
            print_chars(CPFreqs, sortBy, printASCIIOnly, outHnd)
    except FileNotFoundError:
        exit("Virhe: tiedoston polkua ei ole olemassa.")
    except PermissionError:
        exit("Virhe: tiedoston kirjoittamiseen ei ole oikeuksia.")
    except Exception as e:
        exit("Virhe kirjoitettaessa tiedostoa: " + str(e))

    print("OK. Aikaa meni {0:.1f} s.".format(time.time() - startTime))

# suorita pääohjelma, jos tätä moduulia ei ajeta toisen moduulin alla
if __name__ == "__main__":
    main()

Versiohistoria

Kommentit

Metabolix [09.06.2016 23:37:15]

#

Hyvä vinkki, julkaistaan saman tien.

Pari korjausta myöhemmäksi:

Hakasulut ovat turhat kohdassa max([generaattori]); tarpeettomasti luodaan väliaikainen lista, vaikka max voisi suoraan käsitellä generaattorin tuottamia arvoja.

Näin pitkässä koodissa herää jo kysymys, voisiko työvaiheita koota funktioiksi.

qalle [14.07.2016 08:08:03]

#

Uusi versio 1.1. Ks. versiohistoria.

Chiman [14.07.2016 18:15:02]

#

Siirtäisin lopunkin koodin funktioihin lukuunottamatta

if __name__ == '__main__':
    main()

-kohtaa. Tällöin kooditiedostosi toimisi sujuvasti importin avulla toisesta sovelluksesta käsin.

Koodin tyyli poikkeaa hieman suositusta PEP 8 -ohjeesta. Se ei ole välttämättä huono asia, mutta moni Python-käyttäjä on tottunut sen mukaiseen koodiin.

Tiedostojen osalta käyttäisin with-rakennetta erillisten open- ja close-kutsujen sijasta. Siinä on omat etunsa.

qalle [17.07.2016 18:15:00]

#

Kiitos. Uusi versio 1.2. Ks. versiohistoria.

Kirjoita kommentti

Muista lukea kirjoitusohjeet.
Tietoa sivustosta