Идея проекта возникла у меня во время работы над проектом поисковика документов. Существует такая библиотека, как Apache Tika, написанная на Java, которая умеет парсить документы различных типов. Чтобы мой поисковик работал, он должен уметь извлекать текст из документов разных типов (PDF, DOC, XLS, HTML, XML, JSON и т. д.). Сам поисковик я писал на Rust. Но, к сожалению, в мире Rust нет библиотеки, которая умела бы парсить документы всех типов.

Shiva Library
Shiva Library

По этой причине пришлось использовать Apache Tika и вызывать её из моего Rust-кода. Какие недостатки такого решения?

1. Необходимо устанавливать Java на каждом компьютере, где будет запускаться мой поисковик.

2. Очень высокие требования к RAM. Apache Tika использует очень много памяти. Из-за того что в Java есть сборщик мусора, который работает не очень эффективно, приходится выделять очень много памяти для JVM.

Я тогда не стал писать библиотеку на Rust, которая умеет парсить документы всех типов, поскольку работодатель не был готов оплатить несколько лет моей разработки. Но идея осталась в голове. И вот, я решился на этот шаг и начал писать свою библиотеку на Rust.

На самом деле, задача по созданию такой библиотеки достаточно интересна. Нужно разработать хорошую архитектуру ядра библиотеки, чтобы впоследствии легко было добавлять новые парсеры и генераторы для различных типов документов. Я выбрал подход, основанный на использовании Common Document Model (CDM). То есть код любого парсера должен преобразовывать документ в CDM, а код любого генератора должен преобразовывать CDM в документ.

В модуле core я заложил следующие структуры:

pub struct Document {
    pub elements: Vec<Box<dyn Element>>,
    pub page_width: f32,
    pub page_height: f32,
    pub left_page_indent: f32,
    pub right_page_indent: f32,
    pub top_page_indent: f32,
    pub bottom_page_indent: f32,
    pub page_header: Vec<Box<dyn Element>>,
    pub page_footer: Vec<Box<dyn Element>>,
}

trait Element  {
   fn as_any(&self) -> &dyn Any; 

   fn as_any_mut(&mut self) -> &mut dyn Any;

   fn element_type(&self) -> ElementType;
}

pub enum ElementType {
   Text,
   Paragraph,
   Image,
   Hyperlink,
   Header,
   Table,
   TableHeader,
   TableRow,
   TableCell,
   List,
   ListItem,
   PageBreak,
   TableOfContents,
}

Поскольку в Rust отсутствует Reflection, я решил использовать Any. Таким образом, я могу хранить в одном векторе различные типы элементов документа. При необходимости я могу приводить их к нужному типу через downcast_ref и downcast_mut. Для этого в трейт Element я добавил методы для всех типов элементов (например, paragraph_as_ref, paragraph_as_mut и т.д.).

    fn paragraph_as_ref(&self) -> anyhow::Result<&ParagraphElement> {
        Ok(self
            .as_any()
            .downcast_ref::<ParagraphElement>()
            .ok_or(CastingError::Common)?)
    }

    fn paragraph_as_mut(&mut self) -> anyhow::Result<&mut ParagraphElement> {
        Ok(self
            .as_any_mut()
            .downcast_mut::<ParagraphElement>()
            .ok_or(CastingError::Common)?)
    }

Для того, чтобы добавить новый тип документа, достаточно реализовать трейт TransformerTrait:

pub trait TransformerTrait {
    fn parse(document: &Bytes, images: &HashMap<String, Bytes>) -> anyhow::Result<Document>;
    fn generate(document: &Document) -> anyhow::Result<(Bytes, HashMap<String, Bytes>)>;
}

На текущей момент реализованы следующие парсеры и генераторы:

  1. Plain text

  2. Markdown

  3. HTML

  4. PDF

Что бы подключить мою библиотеку в свой проект нужно добавить в Cargo.toml следующую строку:

[dependencies]
shiva = "0.1.14"

Пример использования библиотеки:

fn main() {
    let input_vec = std::fs::read("input.html").unwrap();
    let input_bytes = bytes::Bytes::from(input_vec);
    let document = shiva::html::Transformer::parse(&input_bytes, &HashMap::new()).unwrap();
    let output_bytes = shiva::markdown::Transformer::generate(&document, &HashMap::new()).unwrap();
    std::fs::write("out.md", output_bytes).unwrap();
}

Текущий статус проекта — это MVP (Minimum Viable Product). Я планирую добавить поддержку всех типов документов, которые поддерживает Apache Tika, в течение следующих нескольких лет. А в ближайшее время допишу более глубокую поддержку PDF-документов, так как PDF — это самый популярный тип документов. Также добавлю режим работы в виде веб-сервиса, чтобы можно было использовать мою библиотеку через REST API, а не только через CLI.

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

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


  1. rPman
    06.04.2024 16:08
    +2

    Как текст извлекается из pdf?

    Учитывается ли взаимное положение элементов в документе или текст склеивается 'в том порядке что их разместил автор' даже если он редактировал его и верхние части документа добавил последними? Правильно ли распознается многоколоночные документы? Наличие формул и графиков и т.п.

    Ну и в догонку, а как быть с pdf без текстовой информации? как картинка или векторный ps? А поддержка русского и не только языка?


    1. igumnov Автор
      06.04.2024 16:08
      +3

      Признаюсь, пока с этим сложно. Сделал MVP, чтобы обкатать технологию и модель. Но у меня впереди несколько лет, чтобы это всё закодить. Я настроен оптимистично. Прикручу нейросеть, чтобы картинки в векторе или битмапе распознавать как текст. Есть опыт работы с библиотекой Candle на Rust от Hugging Face — в общем, распознавание текста — это банальная задача. Короче, все решаемо. Не вижу здесь особого R&D. Я бы сказал, что все понятно, нужно только сидеть и терпеливо кодить.


    1. igumnov Автор
      06.04.2024 16:08

      И по поводу всего остального, касаемо формул и так далее, — я знаю, думаю и планирую. Просто нужно с чего-то начать. Сделать минимально жизнеспособный продукт (MVP). А потом, рефакторинг никто не отменял. Учитывая, что это молодая и быстро растущая библиотека, у меня нет миллионов пользователей, чтобы поддерживать старые API, так что пока мои руки развязаны. Вороти и твори, что хочешь :) Это потом, когда достигнешь популярности, становится сложнее — нужно поддерживать старые версии. Нельзя же бросать людей, которые доверились тебе и использовали твою библиотеку. Но это пока всё лишь мечты. Впереди несколько лет кодирования. )


      1. dyadyaSerezha
        06.04.2024 16:08
        +1

        Нельзя же бросать людей, которые доверились тебе и использовали твою библиотеку.

        И мона, и нуна (с) из старого детского анекдота

        Люди пусть продолжают использовать старую версию, а новая вполне может быть с несовместимым API. Ну или донаты никто не отменял (в размере полной оплаты поддержки старой версии).


  1. gev
    06.04.2024 16:08
    +5

    Может будет полезно: https://pandoc.org/


    1. igumnov Автор
      06.04.2024 16:08
      +1

      Я конечно знаю про эту либу. Но она не на Rust )


  1. MountainGoat
    06.04.2024 16:08
    +3

    Сразу вижу проблему: зачем в один трейт сложено чтение из документа и генерация? Есть много форматов, например DjVu, которые можно прочитать без зависимостей (вся метадата лежит по известному отступу), но хрен сгенерируешь.

    И да, pandoc.


    1. igumnov Автор
      06.04.2024 16:08
      +1

      Спасибо! Хорошая идея. Когда я дойду до этой проблемы - сделаю рефакторинг.


  1. AlexeyK77
    06.04.2024 16:08

    Дело хорошее, удачи проекту!
    По вашему ощущению, разработка на расте вашей библиотеки дает какое-то преимущество: скорость написания кода, проектирования, и т.п.

    Просто про безопасность пишут везде, но вот в части повешения эффективности разработки, например на расте тоже самое напишу за месяц, что на плюсах - за три, а на питоне за неделю. Интересен исключительно лично ваша субъективная оценка.


    1. gev
      06.04.2024 16:08

      Тут в соседнем топике обсуждают:
      https://habr.com/en/articles/804915/


  1. Kahelman
    06.04.2024 16:08
    +2

    Удачи автору в переписывании pandoc на Rust.

    Вместо своего поисковика посмотрите на SoLR. «Все уже украдено»

    Поддержка разных типов документов это не тот проект который один человек может потянуть, не говоря о том, что вся проблема имеющихся - invented not here синдром.

    Недавно стать читал, про то что «big data” больше не является проблемой.

    Поскольку при распаллеливании задачи ваша производительность растёт линейно, а по закону Мура производительность компьютеров - степенная функция.

    Пока автор будет парсер pdf писать на Rust-e в смартфоне столика памяти будет, что туда все Java библиотеки можно будет засунуть :)


    1. sdramare
      06.04.2024 16:08
      +1

      Закон Мура ограничен сверху законами квантовой физики, а "распаллеливания" законом Амдала.


      1. Kahelman
        06.04.2024 16:08

        Вот только мы в него пока не уперлись.

        Рекомендую к прочтению стать от автора оригинальной идеи: https://motherduck.com/blog/big-data-is-dead/



  1. sdramare
    06.04.2024 16:08
    +2

    fn element_type(&self) -> ElementType;}

    pub enum ElementType { Text, Paragraph,

    fn paragraph_as_ref(&self) -> anyhow::Result<&ParagraphElement>

    В таком подходе тип элементов описывается в трех местах, что во-первых требует больше времени для добавления нового типа, а во-вторых отсуствует статическая проверка типизации - при match element.element_type() нет никакой гарантии компилятора что там будет действительно лежать нужный тип при даункасте. Плюс Vec<Box<dyn Element>> не эффективная по перфомансу, будет постоянный кэш мисс и эвристики подгрузки не работают.

    Может быть лучше попробовать, как первое решение, просто алгебраческий тип:

    pub enum Element<'a> {

    Header { level: u8, /* todo */ },

    Paragraph { child: &'a Element<'a> },

    ...

    }

    и Vec<Element>

    Еще все элементы создаются в куче с динамик трейтами вместо статического полиморфизма, а конструктуры объявлены через Result, хотя ошибку они никогда не кидают. Например

    pub fn new(element: &Box<dyn Element>) -> anyhow::Result<ListItemElement> {
    Ok(ListItemElement {
    element: element.clone(),
    })
    }

    Надеюсь это не будет звучать грубо, но такое решение сильно похоже на попытку использовать ссылочный объектно-ориентированый подход как в java/.net , что смотрится несколько неорганично в расте.


    1. igumnov Автор
      06.04.2024 16:08

      Спасибо. Согласен. Опыт Java давит. Буду делать рефакторинг. А то не комильфо.


      1. DieSlogan
        06.04.2024 16:08

        Весьма радует, что вы решили не ждать пенсии, а изучить что-то новое.


    1. Kahelman
      06.04.2024 16:08
      +1

      Вообще-то у автора уже проблема в реализации типа Document.

      Тип назван документ, но поскольку у него есть pagewidth, … page header и page footer,

      То это скорее всего должен быть Page а не Документ.

      Элементы которые могут быть в Page/Document. Это параграф, … table of context

      Table of Context не имеет смысла в отрыве от документа, который состоит из набора страниц.

      способ объединения Page/Document в «нормальный документ» состоящий из набора страниц не виден.

      Далее, есть table header, но отсутствует table footer, если уж работать по аналогии с html table,

      Зачем-то появился елемент PageBreak, который в рамках поиска, для которого автор делает парсер, вообще не имеет смысла.

      Как уже говорилось в у автора не Document а Page, для которой тип PageBreak тоже не имеет смысла.

      В общем похоже ещё один фанат Rust, готовый переписать весь мир на новом языке.

      Советую обратиться к первоисточникам - перечитать Маркса, т.е. Бруста «Мифический человекомесяц» и его главное утверждение «серебряной пули не существует»

      :)


      1. igumnov Автор
        06.04.2024 16:08

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

        У меня, к сожалению, всего две руки и 24 часа в сутки. Я иду на жертвы с точки зрения универсальности, иду на упрощение. В целом, я с вами не спорю. Надо, возможно, для каждой страницы делать и отступы, и т. д. Надо подумать. Может, пока не поздно, дойти до такой универсальности, как вы сказали.


        1. sfclubh
          06.04.2024 16:08

          Генератор отчетов: json, minijinja, latex, pdf


    1. igumnov Автор
      06.04.2024 16:08

      Вы оказались правы! Спасибо! Поигрался в песочнеце:

      #[derive(Debug)]
      pub enum Element<'a> {
      
          Header { level: u8, text: &'a str },
      
          Paragraph { elements: Vec<Element<'a>> },
      
          List { ordered: bool, text: &'a crate::Element<'a> },
      
          Text { text: &'a str, size: u8 },
      
      }
      
      
      fn main() {
          let header = Element::Header { level: 1, text: "Hello, World!" };
          let paragraph = Element::Paragraph { elements: vec![header] };
          let list = Element::List { ordered: false, text: &paragraph };
          println!("{:?}", list);
      }

      про fn new(element: &Box<dyn Element>) -> anyhow::Result<ListItemElement> тут вообще все очевидно - излишний Rusult конечно

      Буду все рефакторить, пока кода мало!


    1. igumnov Автор
      06.04.2024 16:08

      Вы оказались правы! Спасибо! Поигрался в песочнеце:

      #[derive(Debug)]
      pub enum Element<'a> {
          Header { level: u8, text: &'a str },
          Paragraph { elements: Vec<Element<'a>> },
          List { ordered: bool, text: &'a crate::Element<'a> },
          Text { text: &'a str, size: u8 },
      }
      
      fn main() {
          let header = Element::Header { level: 1, text: "Hello, World!" };
          let paragraph = Element::Paragraph { elements: vec![header] };
          let list = Element::List { ordered: false, text: &paragraph };
          println!("{:?}", list);
      }

      про fn new(element: &Box<dyn Element>) -> anyhow::Result<ListItemElement> тут вообще все очевидно - излишний Rusult конечно

      Буду все рефакторить, пока кода мало!


      1. sdramare
        06.04.2024 16:08

        Может пригодится - синтаксис паттерн матчинга позволяет в таких случаях делать вайлкард

        let x = Element::Header { level: 8, text: "hello" } };

        match x { Element::Header { level, .. } => { println!("{level}") } Element::Paragraph { .. } => { println!("Paragraph") }

        };


    1. domix32
      06.04.2024 16:08

      Плюс Vec<Box<dyn Element>> не эффективная по перфомансу

      Где-то у matklad в блоге было про что-то похожее. Запихивание всего в Box<dyn Element> позволяет этому лежать в памяти выровнено по размеру Box, а не Element, что наоборот ускоряет обработку отдельных токенов и уменьшает количество дыр из-за выравнивания, экономя память. Даже не смотря на пару уровней индирекции это всё равно может быть быстрее голых Element в векторе. Так что спешу поставить под сомнение это утверждение и посоветую автору прогонять подобные штуки через профайлер (flamegraph, hyperfine) до и после рефакторинга.

      Похожим образом Zig оптимизирует свои Енамы, но уже на уровне компилятора.


      1. sdramare
        06.04.2024 16:08

        Даже не смотря на пару уровней индирекции это всё равно может быть быстрее голых Element в векторе. 

        Индирекции в общем случае будут медленее, так как соседние элементы банально могут не обладать пространственной когерентностью и даже могут быть на разных страницах. А вы приводите зачем-то узкий случай оптимизации под конкретный алгоритм, "If your access pattern does not require blind iteration (which can be the case for flattened, index-based tree structures)", при том что в исходном коде буквально делается итерация по всему вектору: document.elements.iter()Так что спешу поставить под сомнение(с) ваши советы запихивать все в Вox<dyn Element> , хотя использовать профайл дело вполне полезное.


        1. domix32
          06.04.2024 16:08

          Так что спешу поставить под сомнение(с) ваши советы запихивать все в Вox<dyn Element>

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


  1. Aleus1249355
    06.04.2024 16:08
    +1

    Большое спасибо за Вашу мотивацию. Мне определённо нравится Ваш проект. У меня и самого вертелась схожая идея в голове.

    Совершенно соглашусь. Задача по созданию такой библиотеки вполне интересна


    1. igumnov Автор
      06.04.2024 16:08

      Хотите присоединиться к кодированию проекта?


  1. KonstantinSmith
    06.04.2024 16:08
    +1

    Я такую задачу решил на С# в проекте Pullenti Unitext с конвертацией в Java, Javascript и Python. Пытался конвертировать на Rust, но это не получилось. Понимаю всю сложность этой задачи и желаю автору удачи!