Идея проекта возникла у меня во время работы над проектом поисковика документов. Существует такая библиотека, как Apache Tika, написанная на Java, которая умеет парсить документы различных типов. Чтобы мой поисковик работал, он должен уметь извлекать текст из документов разных типов (PDF, DOC, XLS, HTML, XML, JSON и т. д.). Сам поисковик я писал на Rust. Но, к сожалению, в мире Rust нет библиотеки, которая умела бы парсить документы всех типов.
По этой причине пришлось использовать 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>)>;
}
На текущей момент реализованы следующие парсеры и генераторы:
Plain text
Markdown
HTML
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)
MountainGoat
06.04.2024 16:08+3Сразу вижу проблему: зачем в один трейт сложено чтение из документа и генерация? Есть много форматов, например DjVu, которые можно прочитать без зависимостей (вся метадата лежит по известному отступу), но хрен сгенерируешь.
И да, pandoc.
igumnov Автор
06.04.2024 16:08+1Спасибо! Хорошая идея. Когда я дойду до этой проблемы - сделаю рефакторинг.
AlexeyK77
06.04.2024 16:08Дело хорошее, удачи проекту!
По вашему ощущению, разработка на расте вашей библиотеки дает какое-то преимущество: скорость написания кода, проектирования, и т.п.Просто про безопасность пишут везде, но вот в части повешения эффективности разработки, например на расте тоже самое напишу за месяц, что на плюсах - за три, а на питоне за неделю. Интересен исключительно лично ваша субъективная оценка.
Kahelman
06.04.2024 16:08+2Удачи автору в переписывании pandoc на Rust.
Вместо своего поисковика посмотрите на SoLR. «Все уже украдено»
Поддержка разных типов документов это не тот проект который один человек может потянуть, не говоря о том, что вся проблема имеющихся - invented not here синдром.
Недавно стать читал, про то что «big data” больше не является проблемой.
Поскольку при распаллеливании задачи ваша производительность растёт линейно, а по закону Мура производительность компьютеров - степенная функция.
Пока автор будет парсер pdf писать на Rust-e в смартфоне столика памяти будет, что туда все Java библиотеки можно будет засунуть :)
sdramare
06.04.2024 16:08+1Закон Мура ограничен сверху законами квантовой физики, а "распаллеливания" законом Амдала.
Kahelman
06.04.2024 16:08Вот только мы в него пока не уперлись.
Рекомендую к прочтению стать от автора оригинальной идеи: https://motherduck.com/blog/big-data-is-dead/
sdramare
06.04.2024 16:08+2fn 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 , что смотрится несколько неорганично в расте.
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, готовый переписать весь мир на новом языке.
Советую обратиться к первоисточникам - перечитать Маркса, т.е. Бруста «Мифический человекомесяц» и его главное утверждение «серебряной пули не существует»
:)
igumnov Автор
06.04.2024 16:08Спасибо за замечание. Долго думал над архитектурой. Дело в том, что у меня много идей, куда и как применить мою библиотеку. В частности, одна из многих идей — это создание генератора отчетов, на входе которого данные и шаблон отчета. Я всегда иду по пути наименьшего сопротивления и решил принять за аксиому, что документ имеет все страницы одинакового формата. Просто я не видел отчетов, у которых каждая страница имеет разные отступы, ориентацию и т.д.
У меня, к сожалению, всего две руки и 24 часа в сутки. Я иду на жертвы с точки зрения универсальности, иду на упрощение. В целом, я с вами не спорю. Надо, возможно, для каждой страницы делать и отступы, и т. д. Надо подумать. Может, пока не поздно, дойти до такой универсальности, как вы сказали.
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: ¶graph }; println!("{:?}", list); }
про fn new(element: &Box<dyn Element>) -> anyhow::Result<ListItemElement> тут вообще все очевидно - излишний Rusult конечно
Буду все рефакторить, пока кода мало!
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: ¶graph }; println!("{:?}", list); }
про fn new(element: &Box<dyn Element>) -> anyhow::Result<ListItemElement> тут вообще все очевидно - излишний Rusult конечно
Буду все рефакторить, пока кода мало!
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") }
};
domix32
06.04.2024 16:08Плюс
Vec<Box<dyn Element>>
не эффективная по перфомансуГде-то у matklad в блоге было про что-то похожее. Запихивание всего в
Box<dyn Element>
позволяет этому лежать в памяти выровнено по размеруBox
, а неElement
, что наоборот ускоряет обработку отдельных токенов и уменьшает количество дыр из-за выравнивания, экономя память. Даже не смотря на пару уровней индирекции это всё равно может быть быстрее голыхElement
в векторе. Так что спешу поставить под сомнение это утверждение и посоветую автору прогонять подобные штуки через профайлер (flamegraph, hyperfine) до и после рефакторинга.Похожим образом Zig оптимизирует свои Енамы, но уже на уровне компилятора.
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> , хотя использовать профайл дело вполне полезное.domix32
06.04.2024 16:08Так что спешу поставить под сомнение(с) ваши советы запихивать все в Вox<dyn Element>
именно поэтому и сказал, что нужно использовать профайлер. Ну и в случае с matklad - он писал парсер для языка программирования, так что количество и разнообразие токенов там заметно обширнее в сравнениии с документами и манипуляции над токенами несколько иные.
Aleus1249355
06.04.2024 16:08+1Большое спасибо за Вашу мотивацию. Мне определённо нравится Ваш проект. У меня и самого вертелась схожая идея в голове.
Совершенно соглашусь. Задача по созданию такой библиотеки вполне интересна
KonstantinSmith
06.04.2024 16:08+1Я такую задачу решил на С# в проекте Pullenti Unitext с конвертацией в Java, Javascript и Python. Пытался конвертировать на Rust, но это не получилось. Понимаю всю сложность этой задачи и желаю автору удачи!
rPman
Как текст извлекается из pdf?
Учитывается ли взаимное положение элементов в документе или текст склеивается 'в том порядке что их разместил автор' даже если он редактировал его и верхние части документа добавил последними? Правильно ли распознается многоколоночные документы? Наличие формул и графиков и т.п.
Ну и в догонку, а как быть с pdf без текстовой информации? как картинка или векторный ps? А поддержка русского и не только языка?
igumnov Автор
Признаюсь, пока с этим сложно. Сделал MVP, чтобы обкатать технологию и модель. Но у меня впереди несколько лет, чтобы это всё закодить. Я настроен оптимистично. Прикручу нейросеть, чтобы картинки в векторе или битмапе распознавать как текст. Есть опыт работы с библиотекой Candle на Rust от Hugging Face — в общем, распознавание текста — это банальная задача. Короче, все решаемо. Не вижу здесь особого R&D. Я бы сказал, что все понятно, нужно только сидеть и терпеливо кодить.
igumnov Автор
И по поводу всего остального, касаемо формул и так далее, — я знаю, думаю и планирую. Просто нужно с чего-то начать. Сделать минимально жизнеспособный продукт (MVP). А потом, рефакторинг никто не отменял. Учитывая, что это молодая и быстро растущая библиотека, у меня нет миллионов пользователей, чтобы поддерживать старые API, так что пока мои руки развязаны. Вороти и твори, что хочешь :) Это потом, когда достигнешь популярности, становится сложнее — нужно поддерживать старые версии. Нельзя же бросать людей, которые доверились тебе и использовали твою библиотеку. Но это пока всё лишь мечты. Впереди несколько лет кодирования. )
dyadyaSerezha
И мона, и нуна (с) из старого детского анекдота
Люди пусть продолжают использовать старую версию, а новая вполне может быть с несовместимым API. Ну или донаты никто не отменял (в размере полной оплаты поддержки старой версии).