Привет, Хабр!
Недавно мне для личных целей потребовалось написать читалку 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 форматами. Я не стала так заморачиваться и выбрала путь попроще, потому что для моих целей этого было достаточно.
Что хочу сказать напоследок. Я новичок, поэтому прошу проявить милосердие и не кидаться помидорами, если есть чипсы. Буду рада, если в комментариях вы подскажете, как можно улучшить программу.
Если возникнут какие-либо проблемы, например, не сможете разобраться, куда вставлять тот или иной кусок кода, вы всегда можете скачать исходник и посмотреть на весь код целиком.
Исходный код проекта вы найдете тут.