Привет, Хабр!
Недавно мне для личных целей потребовалось написать читалку FB2. И сразу я столкнулась с тем, что информации по теме минимум. Палочка-выручалочка под названием ChatGPT выдал что-то невразумительное в ответ на довольно подробный запрос. К тому же, никаких готовых библиотек, чтобы по-быстренькому наваять ридер, я также не смогла обнаружить. Хотя искала долго и упорно, как Чубакка расческу.
Все это привело меня к закономерному выводу, что сначала нужно изучить формат FB2. А потом подумать, как прочитать его стандартными способами и вывести на экран. После того, как я немного разобралась со структурой FB2, начала догадываться, почему нет готовых библиотек. Дело в том, что этот формат довольно простой, и нет особой необходимости писать для него отдельную библиотеку. Можно довольно быстро наваять свой код, который будет читать практически все файлы FB2. И вы сможете убедиться в этом, если дочитаете статью до конца.
А раз все так просто, зачем я пишу эту статью? Для этого у меня есть две причины. Во-первых, это моя первая проба пера на Хабре. А во-вторых, возможно, это сэкономит кучу времени другому такому же новичку, как я. Ну, или пригодится какому-нибудь студенту, который пишет реферат.
Что из себя представляют файлы FB2
Итак, начнем с краткого описания формата. Откроем первый попавший под руку файл FB2 в блокноте, и мы увидим, что это обычный XML.
Стандартная структура файла включает три крупных блока:
<FictionBook>
<description>
Куча элементов с разной информацией о книге, такой как имя,
фамилия автора, название, краткое описание, обложка и т.д.
</description>
<body>
Собственно текст книги
</body>
<binary>
Base64 код картинки
</binary>
<binary>
...
</binary>
... еще много binary элементов
</FictionBook>
Наша задача заключается в том, чтобы вытащить содержимое этих блоков. Контент из description и body можно, немного подредактировав, отобразить как html-код с помощью такого инструмента JavaFX, как WebView. А над binary-элементами придется сначала чуточку пошаманить. Дело в том, что они представляют собой бинарный код в формате Base64, что выглядит примерно так:
Иными словами, это огромный блок сплошных символов. И все это безобразие находится в конце файла FB2. Нам нужно будет не только извлечь весь этот код из FB2, но и отделить картинки друг от друга. А потом сохранить их как нормальные изображения в формате JPG, PNG или GIF.
Подготовка к работе над читалкой FB2
Итак, для работы нам понадобится среда разработки Eclipse IDE. У меня на момент написания статьи установлена версия 4.25.0. Наводим мышку на главное меню, нажимаем File, выбираем пункт New, а затем Project...
Далее в открывшемся окошке в разделе Maven выбираем Maven Project и жмем Next.
В следующем окне ставим галочку напротив пункта Create a simple project и снова жмем Next.
В окне под названием New Maven Project заполняем поля Group Id и Artifact Id примерно как на следующей картинке. После этого можно смело жать Finish.
Eclipse создаст новый проект, который появится в левой колонке под названием Package Explorer.
Потом щелкаем правой кнопкой мыши по строчке src/main/java, выбираем в выпавшем списке пункт New, а затем Package.
.Заполняем поле Name в открывшемся окне в точности как на картинке.
В левой колонке (Package Explorer) появится новая строка com.example. Клацаем по ней правой кнопкой мыши, выбираем пункт New, а затем Class. В появившемся окне заполняем поле Name как на картинке ниже. Также нужно поставить галочку в поле public static void main.
В центральной колонке откроется файл FbReader.java, в котором будем писать весь основной код. Также нам понадобится файл pom.xml. В него мы будем вставлять зависимости Maven. Они нужны для того, чтобы подключались все необходимые для работы программы плагины. Файл pom.xml также нужно открыть для редактирования. Для этого отыщите его в левой колонке (Package Explorer) и дважды щелкните по нему мышкой. Когда будете производить какие-либо изменения в этих файлах, не забывайте их сохранять (пункт верхнего меню File, затем Save All).
Чтобы программа работала корректно, нам нужно сделать еще пару настроек. В Package Explorer выберите пункт JRE System Library, а затем Properties.
Далее потребуется сменить версию JRE на JavaSE-1.8 (jdk-19.0.2). Если в списке у вас нет такого пункта, погуглите, чтобы узнать, как установить нужную версию. Мы на этом останавливаться сейчас не будем.
И последняя настройка. В Package Explorer (левая колонка) выбираем наш проект, жмем на него правой кнопкой мыши, открываем Properties, находим пункт Run/Debug Setting, затем жмем New..., выбираем Java Application и подтверждаем выбор, нажав на ОК.
В новом окне выбираем Main class (как на картинке).
Затем переходим на вкладку Arguments и заполняем поле VM Arguments так, как показано на картинке. Жмем ОК, а потом Apply and Close.
На этом предварительные настройки завершены. Можно переходить к написанию кода.
Пошаговое написание читалки FB2 на Java
I этап. Создание временной папки и файла для тестов
Первым делом создадим временную папку, в которую будем складывать ресурсы (картинки, временный файл с текстом и др.). Одновременно напишем метод для удаления временной папки после завершения работы программы.
Но сначала нужно найти какой-нибудь файл в формате FB2 для тестов и сохранить его в папку проекта. Сразу запишем путь к этому файлу в строковую переменную EPUB_FILE. Создадим метод для копирования файлов и воспользуемся им для переноса тестового FB2 во временную папку. Изменим расширение скопированного файла на txt.
Добавим следующий код в файл FbReader.java:
import java.awt.image.BufferedImage;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Base64.Decoder;
import javax.imageio.ImageIO;
import org.mozilla.universalchardet.UniversalDetector;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.Alert.AlertType;
import javafx.scene.layout.BorderPane;
import javafx.scene.web.WebEngine;
import javafx.scene.web.WebView;
import javafx.stage.Stage;
import javafx.stage.WindowEvent;
public class FbReader extends Application {
// Путь к тестовому файлу FB2
private static final String EPUB_FILE = "test.fb2";
public static void main(String[] args) {
launch(args);
}
public void start(Stage stage) throws Exception {
stage.setTitle("FBReader: программа для чтения книг FB2");
// Создаем временную папку в папке проекта
String filePath = new File("").getAbsolutePath();
Path tempDir = Files.createTempDirectory(Paths.get(filePath), "temp");
// Каталог, в который нужно сохранить все ресурсы
String outputDir = String.valueOf(tempDir) + "\\";
// Создаем копию файла и переименовываем расширение в xml
try {
copyFile(new File(EPUB_FILE), new File(outputDir + "temp.txt"));
} catch (java.nio.file.NoSuchFileException e) {
Alert alert = new Alert(AlertType.WARNING);
alert.setTitle("Файл не найден");
alert.setHeaderText("Ошибка при открытии файла");
alert.setContentText("Такого файла не существует.");
alert.showAndWait();
File tmpFls = new File(String.valueOf(tempDir));
deleteDir(tmpFls);
}
}
// Метод для удаления временной папки
private static void deleteDir(File tmpFls) {
File[] contents = tmpFls.listFiles();
if (contents != null) {
for (File f : contents) {
if (! Files.isSymbolicLink(f.toPath())) {
deleteDir(f);
}
}
}
tmpFls.delete();
}
// Метод для копирования файла
public static void copyFile(File src, File dest) throws IOException {
Files.copy(src.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
Если программа не обнаружит наш тестовый FB2, она выкинет окошко (Alert) с сообщением о ненайденном файле и удалит временную папку.
На данном этапе потребуется добавить следующие зависимости в файл pom.xml:
<dependencies>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>16</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-graphics</artifactId>
<version>16</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-base</artifactId>
<version>16</version>
</dependency>
</dependencies>
II этап. Сохранение в переменную всего содержимого FB2
После того, как мы создали временную папку для хранения ресурсов и переместили туда тестовый файл, нам нужно будет вытащить из него абсолютно все, что в нем есть. И на этом моменте у меня возникла первая сложность. Дело в том, что по умолчанию Java работает с текстами в кодировке UTF-8. А мой тестовый файлик был совсем в другой кодировке (Windows-1251). Подозреваю, что у других книг FB2 могут встречаться всякие разные варианты кодировок. Но это не точно.
Поэтому нам нужно программно определить кодировку файла, а затем перекодировать его содержимое в UTF-8. Добавим код в наш класс FbReader.java:
// Получение всего текста из файла temp.txt
String tempFilePath = outputDir + "temp.txt";
String charset = detectCharset(tempFilePath);
String content = readText(tempFilePath, charset);
А еще напишем два метода, первый из которых служит для определения кодировки текстового файла. А второй — для чтения содержимого этого файла, извлечения данных, кодировки в UTF-8 и записи в переменную String.
// Метод для определения кодировки текстового файла
private static String detectCharset(String filePath) {
try (InputStream inputStream = new FileInputStream(filePath)) {
byte[] bytes = new byte[4096];
UniversalDetector detector = new UniversalDetector(null);
int nread;
while ((nread = inputStream.read(bytes)) > 0 && !detector.isDone()) {
detector.handleData(bytes, 0, nread);
}
detector.dataEnd();
return detector.getDetectedCharset();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
// Метод для чтения содержимого текстового файла, извлечения данных,
// кодировки в UTF-8 и записи в переменную String
private static String readText(String filePath, String charset) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
new FileInputStream(filePath), charset))) {
StringBuilder builder = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
builder.append(line).append("\n");
}
return builder.toString();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
Чтобы все заработало как надо, в pom.xml добавим зависимость:
<dependency>
<groupId>com.googlecode.juniversalchardet</groupId>
<artifactId>juniversalchardet</artifactId>
<version>1.0.3</version>
</dependency>
III этап. Извлечение текстового контента
Теперь, когда мы вытащили все содержимое FB2 файла, перекодировали его в UTF-8 и записали в строковую переменную, можно приступать к самому интересному. А именно, к извлечению частей текста и картинок. Для получения содержимого элементов description, body и binary мы будем использовать стандартные операции Java со строковыми переменными. На этом шаге займемся исключительно description и body.
Добавим следующий код в класс FbReader.java:
// Получение текста элемента description
int start = content.indexOf("<description>");
int end = content.lastIndexOf("</description>");
end = end + 14;
char[] dest = new char[end - start];
content.getChars(start, end, dest, 0);
String description = new String(dest);
description = description.replace("image l:href=\"#", "img src=\"file://"
+ tempDir.toString().replace("\\","/") + "/");
// Получение текста между тегами body
int startBody = content.indexOf("<body>");
int endBody = content.lastIndexOf("</body>");
endBody = endBody + 7;
char[] dst = new char[endBody - startBody];
content.getChars(startBody, endBody, dst, 0);
String body = new String(dst);
body = body.replace("image l:href=\"#", "img src=\"file://"
+ tempDir.toString().replace("\\", "/") + "/");
Таким образом, мы получили содержимое элементов description и body из файла FB2. Одновременно с этим мы немножко подредактировали теги картинок, чтобы они корректно отображались в дальнейшем. Для этого нам понадобилось заменить невалидный для отображения в формате HTML код элементов image на соответствующие теги. Также мы прописали путь к картинкам, указав временную директорию. Именно в эту папку мы на следующем этапе и будем сохранять картинки.
IV этап. Извлечение картинок и сохранение во временную папку
На данном шаге нам придется хорошо постараться. Но все усилия будут не напрасны. Сначала мы вытащим из нашего многострадального текстового файла вообще все binary элементы и сохраним их в строковую переменную. Затем создадим цикл и два массива. Мы последовательно будем проходиться по всем элементам binary. Код Base64 изображений для каждого отдельного элемента будем сохранять в один массив, а имя файла картинки — во второй.
В конце мы сохраним картинки в нашу временную директорию. Но сначала перекодируем их из формата Base64 и получим байтовый массив. Это позволит нам получить отдельные файлы картинок в привычных форматах (JPG, PNG, GIF) и сохранить их во временную папку.
Вносим следующие изменения в класс FbReader.java:
// Получение текста со всеми картинками (binary)
int startBin = content.indexOf("<binary");
int endBin = content.lastIndexOf("</binary>");
if(startBin != -1 || endBin != -1) {
endBin = endBin + 9;
char[] dstb = new char[endBin - startBin];
content.getChars(startBin, endBin, dstb, 0);
String binContent = new String(dstb);
//System.out.println(binContent);
// Объявляем массивы для данных о картинках
ArrayList<String> binCode = new ArrayList<>();
ArrayList<String> binImg = new ArrayList<>();
// Получение отдельных binary
do {
int nextBin = binContent.indexOf("<binary");
int lastBin = binContent.indexOf("</binary>");
if(nextBin != -1) {
lastBin = lastBin + 9;
// Получение первой из оставшихся binary
char[] dstBin = new char[lastBin - nextBin];
binContent.getChars(nextBin, lastBin, dstBin, 0);
String binaryEl = new String(dstBin);
// Удаление текущей картинки из текста со всеми картинками
binContent = binContent.replace(binaryEl, "");
// Удаление закрывающего тега binary из текущей картинки
binaryEl = binaryEl.replace("</binary>", "");
// Получение строки с открывающим тегом binary
int findString = binaryEl.indexOf(">");
String tag = binaryEl.substring(0,findString + 1);
// Удаление открываюшего тега из картинки
binaryEl = binaryEl.replace(tag, "");
// Получение названия картинки
int findId = tag.indexOf("id=");
findId = findId + 4;
String imageName = tag.substring(findId);
int newFindId = imageName.indexOf("\"");
String delText = imageName.substring(newFindId);
imageName = imageName.replace(delText, "");
// Кладем данные о картинках в массивы
binCode.add(binaryEl);
binImg.add(imageName);
} else {
break;
}
} while(binContent.indexOf("<binary") != -1);
// Сохраняем картинки во временную директорию temp
for(int i = 0; i < binCode.size(); i++) {
// Декодируем код Base64 картинок и сохраняем как байтовый массив
Decoder decoder = Base64.getMimeDecoder();
byte[] decodedBytes = decoder.decode(binCode.get(i));
try {
BufferedImage img = ImageIO.read(new ByteArrayInputStream(decodedBytes));
// Получаем расширения картинок
String extImage = "";
int extNum = 0;
extNum = binImg.get(i).indexOf(".");
extImage = binImg.get(i).substring(extNum + 1);
// В зависимости от расширения сохраняем в соответствующий формат
if(extImage.equals("jpg")) {
File outputfile = new File(outputDir + binImg.get(i));
ImageIO.write(img, "jpg", outputfile);
} else if(extImage.equals("png")) {
File outputfile = new File(outputDir + binImg.get(i));
ImageIO.write(img, "png", outputfile);
} else if(extImage.equals("gif")) {
File outputfile = new File(outputDir + binImg.get(i));
ImageIO.write(img, "gif", outputfile);
}
} catch (javax.imageio.IIOException e) {
e.printStackTrace();
}
}
}
Можно выдохнуть, самое трудное позади. Остался последний этап. Но расслабляться пока все еще рано. Нам предстоит собрать весь контент книги воедино и отобразить его в окне так называемой сцены программы (Scene).
V этап. Вывод контента в окно программы
В качестве завершающих штрихов мы соединим текстовые части description и body, создадим сцену (Scene), добавим на нее браузерный элемент WebView. А в самом конце добавим код для удаления временной папки при закрытии окна программы. Для этого допишем следующие строчки в класс FbReader.java:
// Помещаем весь текстовый контент в одну переменную
String text = description + "\n" + body;
// Создаем панель WebView для отображения HTML контента
WebView webView = new WebView();
WebEngine webEngine = webView.getEngine();
webEngine.loadContent(text);
// Замена встроенных стилей CSS на собственные
webEngine.setUserStyleSheetLocation("data:, @font-face {font-family: 'Open Sans', "
+ "sans-serif; src: local('Open Sans'), url(fonts/OpenSans-Regular.ttf);} "
+ "body {width: 90% !important; padding-left: 10px; font-size: 14pt; "
+ "font-family: 'Open Sans', sans-serif; line-height: 1.5;} "
+ "img {max-width: 90%; height: auto;}");
BorderPane borderPane = new BorderPane();
borderPane.setCenter(webView);
// Создание сцены и отображение окна
Scene scene = new Scene(borderPane);
stage.setScene(scene);
stage.show();
stage.setHeight(900);
borderPane.setPrefHeight(5000);
// Удаление временной папки при закрытии окна
stage.addEventFilter(WindowEvent.WINDOW_CLOSE_REQUEST, event -> {
File tmpFls = new File(tempDir.toString());
deleteDir(tmpFls);
});
Добавим зависимость в pom.xml для корректной работы WebView:
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-web</artifactId>
<version>17</version>
</dependency>
На данном этапе меня поджидал еще один сюрприз. Когда я собрала весь контент и вывела его в окно программы, то обнаружила, что читать текст довольно сложно. Он выводился слишком мелким шрифтом, с небольшими интервалами между строками, буквально слипшимся. Обложки у некоторых книг вылазили за пределы окна, а полоса вертикального скроллинга скрывала пусть небольшую, но все же часть текста. Для того, чтобы устранить проблему, я прописала собственные стили CSS для окошка WebView.
Еще мне не понравился дефолтный шрифт с засечками. Я решила поменять его на Open Sans без засечек. Текст, написанный таким шрифтом, гораздо легче читается и воспринимается. Шрифт Open Sans или любой другой понравившийся можно скачать на сайте Google Fonts. Затем нужно будет создать папку Fonts в корневой директории проекта и положить туда TTF файл со шрифтом.
Запуск программы
Чтобы проверить, как все работает, нужно запустить программу. Для этого переходим в Package Explorer (левая колонка Eclipse), жмем правой кнопкой мыши на название нашего проекта, выбираем пункт выпавшего меню Run As, далее Java Application.
Откроется окно Select Java Application. В нем выбираем наш проект и жмем ОК.
Если вы сделали все правильно, должно открыться окно программы с текстом и картинками вашей электронной книги FB2.
Над чем еще можно поработать
Если есть желание, можно разобрать элемент description, вытащить из него имя автора, наименование, краткое описание книги, название файла обложки и другие параметры. А затем разложить их по переменным для дальнейшего использования. С этой задачей помогут справиться библиотеки JAXB или Jsoup, которые как раз и предназначены для работы с XML и HTML форматами. Я не стала так заморачиваться и выбрала путь попроще, потому что для моих целей этого было достаточно.
Что хочу сказать напоследок. Я новичок, поэтому прошу проявить милосердие и не кидаться помидорами, если есть чипсы. Буду рада, если в комментариях вы подскажете, как можно улучшить программу.
Если возникнут какие-либо проблемы, например, не сможете разобраться, куда вставлять тот или иной кусок кода, вы всегда можете скачать исходник и посмотреть на весь код целиком.
Исходный код проекта вы найдете тут.