Привет, Хабр!

Недавно мне для личных целей потребовалось написать читалку FB2. И сразу я столкнулась с тем, что информации по теме минимум. Палочка-выручалочка под названием ChatGPT выдал что-то невразумительное в ответ на довольно подробный запрос. К тому же, никаких готовых библиотек, чтобы по-быстренькому наваять ридер, я также не смогла обнаружить. Хотя искала долго и упорно, как Чубакка расческу.

Все это привело меня к закономерному выводу, что сначала нужно изучить формат FB2. А потом подумать, как прочитать его стандартными способами и вывести на экран. После того, как я немного разобралась со структурой FB2, начала догадываться, почему нет готовых библиотек. Дело в том, что этот формат довольно простой, и нет особой необходимости писать для него отдельную библиотеку. Можно довольно быстро наваять свой код, который будет читать практически все файлы FB2. И вы сможете убедиться в этом, если дочитаете статью до конца.

А раз все так просто, зачем я пишу эту статью? Для этого у меня есть две причины. Во-первых, это моя первая проба пера на Хабре. А во-вторых, возможно, это сэкономит кучу времени другому такому же новичку, как я. Ну, или пригодится какому-нибудь студенту, который пишет реферат.

Что из себя представляют файлы FB2

Итак, начнем с краткого описания формата. Откроем первый попавший под руку файл FB2 в блокноте, и мы увидим, что это обычный XML.

Рис. 1. Файл FB2 - это обычный XML документ
Рис. 1. Файл FB2 - это обычный XML документ

Стандартная структура файла включает три крупных блока:

<FictionBook>
   <description>
      Куча элементов с разной информацией о книге, такой как имя, 
	  фамилия автора, название, краткое описание, обложка и т.д.
   </description>
   
   <body>
      Собственно текст книги
   </body>
   
   <binary>
      Base64 код картинки
   </binary> 
   <binary>
      ...
   </binary>   
   ... еще много binary элементов   
</FictionBook>

Наша задача заключается в том, чтобы вытащить содержимое этих блоков. Контент из description и body можно, немного подредактировав, отобразить как html-код с помощью такого инструмента JavaFX, как WebView. А над binary-элементами придется сначала чуточку пошаманить. Дело в том, что они представляют собой бинарный код в формате Base64, что выглядит примерно так:

Рис. 2. Элементы binary в файле FB2 - это код в формате Base64
Рис. 2. Элементы binary в файле FB2 - это код в формате Base64

Иными словами, это огромный блок сплошных символов. И все это безобразие находится в конце файла FB2. Нам нужно будет не только извлечь весь этот код из FB2, но и отделить картинки друг от друга. А потом сохранить их как нормальные изображения в формате JPG, PNG или GIF.

Подготовка к работе над читалкой FB2

Итак, для работы нам понадобится среда разработки Eclipse IDE. У меня на момент написания статьи установлена версия 4.25.0. Наводим мышку на главное меню, нажимаем File, выбираем пункт New, а затем Project...

Рис. 3. Создаем новый проект в Eclipse IDE
Рис. 3. Создаем новый проект в Eclipse IDE

Далее в открывшемся окошке в разделе Maven выбираем Maven Project и жмем Next.

Рис. 4. Выбираем проект Maven
Рис. 4. Выбираем проект Maven

В следующем окне ставим галочку напротив пункта Create a simple project и снова жмем Next.

Рис. 5. Подтверждаем создание простого проекта Maven
Рис. 5. Подтверждаем создание простого проекта Maven

В окне под названием New Maven Project заполняем поля Group Id и Artifact Id примерно как на следующей картинке. После этого можно смело жать Finish.

Рис. 6. Заполняем Group Id и Artifact Id
Рис. 6. Заполняем Group Id и Artifact Id

Eclipse создаст новый проект, который появится в левой колонке под названием Package Explorer.

Рис. 7. Завершаем создание нового проекта в Eclipse
Рис. 7. Завершаем создание нового проекта в Eclipse

Потом щелкаем правой кнопкой мыши по строчке src/main/java, выбираем в выпавшем списке пункт New, а затем Package.

Рис. 8. Создаем новый пакет
Рис. 8. Создаем новый пакет

.Заполняем поле Name в открывшемся окне в точности как на картинке.

Рис. 9. Заполняем поле с именем пакета
Рис. 9. Заполняем поле с именем пакета

В левой колонке (Package Explorer) появится новая строка com.example. Клацаем по ней правой кнопкой мыши, выбираем пункт New, а затем Class. В появившемся окне заполняем поле Name как на картинке ниже. Также нужно поставить галочку в поле public static void main.

Рис. 10. Создаем Main класс проекта
Рис. 10. Создаем Main класс проекта

В центральной колонке откроется файл FbReader.java, в котором будем писать весь основной код. Также нам понадобится файл pom.xml. В него мы будем вставлять зависимости Maven. Они нужны для того, чтобы подключались все необходимые для работы программы плагины. Файл pom.xml также нужно открыть для редактирования. Для этого отыщите его в левой колонке (Package Explorer) и дважды щелкните по нему мышкой. Когда будете производить какие-либо изменения в этих файлах, не забывайте их сохранять (пункт верхнего меню File, затем Save All).

Чтобы программа работала корректно, нам нужно сделать еще пару настроек. В Package Explorer выберите пункт JRE System Library, а затем Properties.

Рис. 11. Выбираем пункт свойств проекта
Рис. 11. Выбираем пункт свойств проекта

Далее потребуется сменить версию JRE на JavaSE-1.8 (jdk-19.0.2). Если в списке у вас нет такого пункта, погуглите, чтобы узнать, как установить нужную версию. Мы на этом останавливаться сейчас не будем.

Рис. 12. Меняем версию JRE
Рис. 12. Меняем версию JRE

И последняя настройка. В Package Explorer (левая колонка) выбираем наш проект, жмем на него правой кнопкой мыши, открываем Properties, находим пункт Run/Debug Setting, затем жмем New..., выбираем Java Application и подтверждаем выбор, нажав на ОК.

Рис. 13. Редактируем настройки запуска проекта
Рис. 13. Редактируем настройки запуска проекта

В новом окне выбираем Main class (как на картинке).

Рис. 14. Выбираем Main class
Рис. 14. Выбираем Main class

Затем переходим на вкладку Arguments и заполняем поле VM Arguments так, как показано на картинке. Жмем ОК, а потом Apply and Close.

Рис. 15. Редактируем вкладку Arguments
Рис. 15. Редактируем вкладку Arguments

На этом предварительные настройки завершены. Можно переходить к написанию кода.

Пошаговое написание читалки 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.

Рис. 16. Запуск программы
Рис. 16. Запуск программы

Откроется окно Select Java Application. В нем выбираем наш проект и жмем ОК.

Рис. 17. Выбор проекта в окне Select Java Application
Рис. 17. Выбор проекта в окне Select Java Application

Если вы сделали все правильно, должно открыться окно программы с текстом и картинками вашей электронной книги FB2.

Над чем еще можно поработать

Если есть желание, можно разобрать элемент description, вытащить из него имя автора, наименование, краткое описание книги, название файла обложки и другие параметры. А затем разложить их по переменным для дальнейшего использования. С этой задачей помогут справиться библиотеки JAXB или Jsoup, которые как раз и предназначены для работы с XML и HTML форматами. Я не стала так заморачиваться и выбрала путь попроще, потому что для моих целей этого было достаточно.

Что хочу сказать напоследок. Я новичок, поэтому прошу проявить милосердие и не кидаться помидорами, если есть чипсы. Буду рада, если в комментариях вы подскажете, как можно улучшить программу.

Если возникнут какие-либо проблемы, например, не сможете разобраться, куда вставлять тот или иной кусок кода, вы всегда можете скачать исходник и посмотреть на весь код целиком.

Исходный код проекта вы найдете тут.

Комментарии (57)