Przejdź do treści

Eksport i import CSV ze średnikiem

  • Pliki i katalogi
  • poziom: zaawansowany
  • aktualizacja: 16 sierpnia 2026

CSV nie da się poprawnie obsłużyć przez zwykłe split(";"). Średnik potrafi siedzieć wewnątrz pola ujętego w cudzysłowy, sam cudzysłów zapisuje się podwójnie, a jedno pole potrafi rozciągnąć się na kilka linii. Poniższy parser przechodzi po tekście znak po znaku i rozpoznaje wszystkie te przypadki. Eksporter cytuje tylko te pola, które naprawdę tego wymagają, a plik zapisuje w UTF-8 z BOM-em i końcami linii CRLF — bez BOM-u polski Excel otworzy go jako ANSI i zamiast „Łódź" zobaczysz krzaczki. Na koniec program robi pełny test eksport–import i sprawdza, czy dane wróciły bez zmian.

JAVA CsvZeSrednikiem.java 139 linii Pobierz .java
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.StringJoiner;

/**
 * Eksport i import CSV ze średnikiem — skryptyJAVA.pl
 * Zapisz jako: CsvZeSrednikiem.java
 */
public class CsvZeSrednikiem {
    public enum TrybEksportu { WIERNY, BEZPIECZNY_DLA_ARKUSZA }

    /** Cytuje pole tylko wtedy, gdy zawiera znak specjalny CSV albo zaczyna się od BOM-u. */
    public static String kodujPole(String pole, TrybEksportu tryb) {
        Objects.requireNonNull(pole, "pole");
        Objects.requireNonNull(tryb, "tryb");
        int i = 0;
        while (i < pole.length() && (Character.isWhitespace(pole.charAt(i))
                || Character.isSpaceChar(pole.charAt(i)) || pole.charAt(i) == '\uFEFF')) { i++; }
        if (tryb == TrybEksportu.BEZPIECZNY_DLA_ARKUSZA && i < pole.length()
                && "=+-@".indexOf(pole.charAt(i)) >= 0) {
            pole = "'" + pole;
        }
        boolean cytuj = pole.isEmpty() || pole.indexOf(';') >= 0 || pole.indexOf('"') >= 0
                || pole.indexOf('\n') >= 0 || pole.indexOf('\r') >= 0 || pole.startsWith("\uFEFF");
        return cytuj ? '"' + pole.replace("\"", "\"\"") + '"' : pole;
    }

    /** Zapisuje w UTF-8 z BOM-em i końcami linii CRLF, czyli tak, jak tego chce Excel. */
    public static void zapisz(Path plik, List<? extends List<String>> wiersze,
                              TrybEksportu tryb) throws IOException {
        StringBuilder csv = new StringBuilder("\uFEFF");
        for (List<String> wiersz : wiersze) {
            if (Objects.requireNonNull(wiersz, "wiersz").isEmpty()) {
                throw new IllegalArgumentException("Wiersz nie ma pól.");
            }
            StringJoiner linia = new StringJoiner(";");
            wiersz.forEach(pole -> linia.add(kodujPole(pole, tryb)));
            csv.append(linia).append("\r\n");
        }
        Files.createDirectories(plik.toAbsolutePath().getParent());
        Files.writeString(plik, csv, StandardCharsets.UTF_8);
    }

    public static List<List<String>> wczytaj(Path plik) throws IOException {
        return parsuj(Files.readString(plik, StandardCharsets.UTF_8));
    }

    /** Parsuje średnikowy CSV, także z separatorami i końcami linii wewnątrz pól. */
    public static List<List<String>> parsuj(String tekst) {
        Objects.requireNonNull(tekst, "tekst");
        // BOM obcinamy tylko z fizycznego początku pliku, nigdy ze środka pola
        tekst = tekst.startsWith("\uFEFF") ? tekst.substring(1) : tekst;
        List<List<String>> wynik = new ArrayList<>();
        List<String> wiersz = new ArrayList<>();
        StringBuilder pole = new StringBuilder();
        boolean wCudzyslowie = false, poCudzyslowie = false, rekordRozpoczety = false;
        for (int i = 0; i < tekst.length(); i++) {
            char znak = tekst.charAt(i);
            if (wCudzyslowie) {
                if (znak != '"') {
                    pole.append(znak);
                } else if (i + 1 < tekst.length() && tekst.charAt(i + 1) == '"') {
                    pole.append('"'); // podwojony cudzysłów to jeden znak danych
                    i++;
                } else {
                    wCudzyslowie = false;
                    poCudzyslowie = true;
                }
                continue;
            }
            if (poCudzyslowie && znak != ';' && znak != '\r' && znak != '\n') {
                throw new IllegalArgumentException("Znak po zamknięciu pola na pozycji " + i);
            }
            if (znak == ';') {
                wiersz.add(pole.toString());
                pole.setLength(0);
                poCudzyslowie = false;
                rekordRozpoczety = true;
            } else if (znak == '\r' || znak == '\n') {
                if (rekordRozpoczety || poCudzyslowie) { // pusta linia nie jest rekordem
                    wiersz.add(pole.toString());
                    wynik.add(List.copyOf(wiersz));
                    wiersz.clear();
                    pole.setLength(0);
                    poCudzyslowie = false;
                    rekordRozpoczety = false;
                }
                if (znak == '\r' && i + 1 < tekst.length() && tekst.charAt(i + 1) == '\n') {
                    i++;
                }
            } else if (znak == '"') {
                if (pole.length() != 0) {
                    throw new IllegalArgumentException("Cudzysłów w niecytowanym polu: " + i);
                }
                wCudzyslowie = true;
                rekordRozpoczety = true;
            } else {
                pole.append(znak);
                rekordRozpoczety = true;
            }
        }
        if (wCudzyslowie) {
            throw new IllegalArgumentException("Niezamknięte pole w cudzysłowie.");
        }
        if (rekordRozpoczety || poCudzyslowie) {
            wiersz.add(pole.toString());
            wynik.add(List.copyOf(wiersz));
        }
        return List.copyOf(wynik);
    }

    // ── PRZYKŁADY ───────────────────────────────────────────
    public static void main(String[] args) throws IOException {
        List<List<String>> dane = List.of(
                List.of("imię", "uwagi", "adres"),
                List.of("Anna", "Lubi \"Javę\"", "Warszawa; Mokotów"),
                List.of("Piotr", "Pierwsza linia\nDruga linia", "Łódź"),
                List.of(""),
                List.of("Ola", "", "Gdańsk"));
        Path plik = Files.createTempFile("osoby-", ".csv");
        try {
            zapisz(plik, dane, TrybEksportu.BEZPIECZNY_DLA_ARKUSZA);
            System.out.print("Zapisany CSV:\n" + Files.readString(plik, StandardCharsets.UTF_8));
            List<List<String>> odczytane = wczytaj(plik);
            if (!dane.equals(odczytane)) {
                throw new IllegalStateException("Dane po imporcie różnią się od oryginału.");
            }
            System.out.println("\nImport poprawny, rekordów z nagłówkiem: " + odczytane.size());
            System.out.println("Puste linie pomijane: " + parsuj("a;b\n\n\nc;d"));
        } finally {
            Files.deleteIfExists(plik);
        }
    }
}

Jak użyć tego skryptu

  1. Zapisz kod w pliku CsvZeSrednikiem.java — plik .java nazywa się tak samo jak klasa publiczna w środku — i uruchom poleceniem java CsvZeSrednikiem.java. Program zapisze dane z cudzysłowem, średnikiem i wielowierszowym polem, wczyta je ponownie i porówna z oryginałem.
  2. Przygotuj dane jako List<List<String>>, gdzie zewnętrzna lista zawiera rekordy, a każda niepusta lista wewnętrzna — pola jednego rekordu. Pierwszy wiersz może zawierać nazwy kolumn.
  3. Wywołaj zapisz(Path.of("raport.csv"), dane, TrybEksportu.BEZPIECZNY_DLA_ARKUSZA) dla danych niezaufanych albo wybierz WIERNY, gdy wymagany jest dokładny eksport–import. Tryb arkusza poprzedza apostrofem pola, których pierwszy niebiały znak to =, +, - lub @; jest to celowa zmiana danych, widoczna po ponownym imporcie.
  4. Do importu użyj wczytaj(sciezka). Jeśli dane masz już w pamięci, wywołaj bezpośrednio parsuj(tekst); wynik jest listą niemodyfikowalnych wierszy.

Warto wiedzieć

  • Nie parsuj CSV przez split(";") ani pojedyncze wyrażenie regularne. Separator i koniec linii mogą należeć do cytowanego pola, a podwójny cudzysłów wewnątrz pola oznacza jeden rzeczywisty cudzysłów. Samo cytowanie nie zatrzymuje formuły w Excelu — do wartości pochodzących od użytkownika wybierz jawnie tryb BEZPIECZNY_DLA_ARKUSZA.
  • Puste pola są zachowywane, ale puste linie parser pomija — nadmiarowy enter na końcu pliku nie zamieni się w fantomowy rekord z jednym pustym polem, na którym późniejsze wiersz.get(1) wyłożyłoby się bez ostrzeżenia. Rekord będący jednym pustym polem zapisuj jako "", tak jak robi to eksporter. Po zamykającym cudzysłowie dopuszczalny jest wyłącznie separator, koniec rekordu albo koniec pliku.
  • Wszystkie pola muszą być napisami innymi niż null. Ustal jawne zasady reprezentacji braku danych, na przykład pusty napis albo osobną wartość tekstową, bo format CSV sam nie rozróżnia pustej wartości od null.
  • Cały plik ląduje w pamięci — i to po obu stronach: wczytaj czyta go w całości, a zapisz skleja go w jednym StringBuilderze. Przy eksporcie liczonym w gigabajtach pierwsze poleci OutOfMemoryError właśnie przy zapisie, nie przy odczycie. Do dużych zbiorów pisz przyrostowo przez Files.newBufferedWriter, a czytaj parserem strumieniowym na Reader; stan cytowanego pola musi wtedy przetrwać między kolejnymi porcjami danych.
#pliki #nio #utf-8 #polskie znaki #parsowanie #walidacja

Podobne skrypty