참고소스 수정본

This commit is contained in:
LASTA_DEV01\lasta
2026-05-12 19:40:31 +09:00
parent 0f34a451fc
commit 2e9204243d
8708 changed files with 3259488 additions and 869 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
pub mod model;
pub mod providers;
pub mod renderers;
pub use providers::factory::DocumentType;
use crate::document::model::Document;
use crate::document::providers::factory::ProviderFactory;
use crate::document::renderers::html::HtmlRenderer;
use napi::bindgen_prelude::*;
use napi_derive::napi;
#[napi]
pub struct DocumentConverter {
factory: ProviderFactory,
html_renderer: HtmlRenderer,
}
impl Default for DocumentConverter {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl DocumentConverter {
#[napi(constructor)]
pub fn new() -> Self {
Self {
factory: ProviderFactory::new(),
html_renderer: HtmlRenderer::new(),
}
}
#[napi]
pub fn convert_buffer_to_html(
&self,
data: &[u8],
doc_type: DocumentType,
) -> napi::Result<String> {
let provider = self.factory.get_provider(doc_type);
let document: Document = provider
.parse_buffer(data)
.map_err(|e| Error::new(Status::GenericFailure, format!("Provider error: {e}")))?;
let html = self.html_renderer.render(&document);
Ok(html)
}
}

View File

@@ -0,0 +1,135 @@
use chrono::{DateTime, Utc};
use std::num::NonZeroU32;
#[derive(Debug, Clone)]
pub struct Document {
pub blocks: Vec<Block>,
pub metadata: DocumentMetadata,
pub notes: Vec<Note>,
pub comments: Vec<Comment>,
}
#[derive(Debug, Clone, Default)]
pub struct DocumentMetadata {
pub title: Option<String>,
pub author: Option<String>,
pub created: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct NoteId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CommentId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct BookmarkId(pub String);
#[derive(Debug, Clone)]
pub enum Block {
Paragraph(Paragraph),
Table(Table),
List(List),
Image(Image),
}
#[derive(Debug, Clone)]
pub struct Paragraph {
pub kind: ParagraphKind,
pub inlines: Vec<Inline>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParagraphKind {
Normal,
Heading(u8), // 1..=6 will render as <h1>.. <h6>
Blockquote,
}
#[derive(Debug, Clone)]
pub enum Inline {
Text(String),
LineBreak,
Link { href: String, children: Vec<Inline> },
Strong(Vec<Inline>),
Em(Vec<Inline>),
Del(Vec<Inline>),
Code(String),
Sup(Vec<Inline>),
Sub(Vec<Inline>),
FootnoteRef(NoteId),
EndnoteRef(NoteId),
CommentRef(CommentId),
Bookmark(BookmarkId),
}
#[derive(Debug, Clone)]
pub struct Image {
pub src: String,
pub alt: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Table {
pub rows: Vec<TableRow>,
}
#[derive(Debug, Clone)]
pub struct TableRow {
pub cells: Vec<TableCell>,
pub kind: TableRowKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TableRowKind {
Header,
Body,
Footer,
}
#[derive(Debug, Clone)]
pub struct TableCell {
pub blocks: Vec<Block>,
pub colspan: NonZeroU32,
pub rowspan: NonZeroU32,
}
#[derive(Debug, Clone)]
pub struct List {
pub items: Vec<ListItem>,
pub list_type: ListType,
}
#[derive(Debug, Clone)]
pub struct ListItem {
pub blocks: Vec<Block>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListType {
Ordered,
Unordered,
}
#[derive(Debug, Clone)]
pub struct Note {
pub id: NoteId,
pub kind: NoteKind,
pub blocks: Vec<Block>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoteKind {
Footnote,
Endnote,
}
#[derive(Debug, Clone)]
pub struct Comment {
pub id: CommentId,
pub author_name: Option<String>,
pub author_initials: Option<String>,
pub blocks: Vec<Block>,
}

View File

@@ -0,0 +1,420 @@
use crate::document::model::*;
use crate::document::providers::DocumentProvider;
use cfb::CompoundFile;
use std::error::Error;
use std::io::Cursor;
use std::io::Read;
pub struct DocProvider;
impl DocProvider {
pub fn new() -> Self {
Self
}
}
impl DocumentProvider for DocProvider {
fn parse_buffer(&self, data: &[u8]) -> Result<Document, Box<dyn Error + Send + Sync>> {
let cursor = Cursor::new(data);
let mut cfb = CompoundFile::open(cursor)?;
let mut metadata = DocumentMetadata::default();
// Try to extract metadata from SummaryInformation stream
if let Ok(summary_info) = extract_summary_info(&mut cfb) {
metadata.title = summary_info.title;
metadata.author = summary_info.author;
}
// Extract text content from the document
let text_content = extract_text_content(&mut cfb)?;
// Convert the extracted text to document blocks
let blocks = text_to_blocks(&text_content);
Ok(Document {
blocks,
metadata,
notes: Vec::new(),
comments: Vec::new(),
})
}
fn name(&self) -> &'static str {
"doc"
}
}
#[derive(Default)]
struct SummaryInfo {
title: Option<String>,
author: Option<String>,
}
fn extract_summary_info<R: Read + std::io::Seek>(
cfb: &mut CompoundFile<R>,
) -> Result<SummaryInfo, Box<dyn Error + Send + Sync>> {
let mut info = SummaryInfo::default();
// Try to read the SummaryInformation stream
if let Ok(mut stream) = cfb.open_stream("\x05SummaryInformation") {
let mut buf = Vec::new();
stream.read_to_end(&mut buf)?;
// Parse the OLE property set stream to extract title and author
if let Some((title, author)) = parse_summary_info_stream(&buf) {
info.title = title;
info.author = author;
}
}
Ok(info)
}
fn parse_summary_info_stream(data: &[u8]) -> Option<(Option<String>, Option<String>)> {
// MS-OLEPS: Property Set Stream format
// This is a simplified parser that extracts strings from the property stream
if data.len() < 48 {
return None;
}
// Byte order mark at offset 0 should be 0xFFFE (little-endian)
if data.len() >= 2 && (data[0] != 0xFE || data[1] != 0xFF) {
return None;
}
let mut title: Option<String> = None;
let mut author: Option<String> = None;
// Extract readable strings from the property stream
let strings = extract_ascii_strings(data, 3);
// Filter out common non-title/author strings
let filtered: Vec<&str> = strings
.iter()
.map(|s| s.as_str())
.filter(|s| {
!s.contains("Microsoft")
&& !s.contains("Normal")
&& !s.contains("template")
&& !s.starts_with("http")
&& s.len() >= 2
&& s.len() <= 200
})
.collect();
// Title and author are typically the first meaningful strings
if let Some(t) = filtered.first() {
title = Some(t.to_string());
}
if let Some(a) = filtered.get(1) {
author = Some(a.to_string());
}
Some((title, author))
}
fn extract_text_content<R: Read + std::io::Seek>(
cfb: &mut CompoundFile<R>,
) -> Result<String, Box<dyn Error + Send + Sync>> {
// Try to read the WordDocument stream
if let Ok(mut stream) = cfb.open_stream("WordDocument") {
let mut doc_data = Vec::new();
stream.read_to_end(&mut doc_data)?;
// Extract text from the WordDocument stream
if let Some(text) = extract_text_from_word_document(&doc_data) {
if !text.trim().is_empty() {
return Ok(text);
}
}
}
// Fallback: scan all streams for text
extract_text_fallback(cfb)
}
fn extract_text_from_word_document(doc_data: &[u8]) -> Option<String> {
if doc_data.len() < 32 {
return None;
}
// Check for Word magic number (0xA5EC for Word 97-2003, 0xA5DC for older)
let magic = u16::from_le_bytes([doc_data[0], doc_data[1]]);
if magic != 0xA5EC && magic != 0xA5DC {
return None;
}
// Read the FIB (File Information Block) to get text encoding info
// Bit 9 of flags (offset 0x0A) indicates which table stream to use
// But for text extraction, we'll use a more robust approach
// The FIB contains ccpText at offset 0x4C (character count of main text)
let ccp_text = if doc_data.len() > 0x50 {
u32::from_le_bytes([
doc_data[0x4C],
doc_data[0x4D],
doc_data[0x4E],
doc_data[0x4F],
]) as usize
} else {
0
};
// For complex documents, text may be in pieces. For simple ones, it's contiguous.
// Either way, we'll scan for text runs since the piece table parsing is complex.
// .doc files typically store text as CP1252 (single-byte) or UTF-16LE
// We'll try to detect which one by looking for patterns
// First, try to find substantial ASCII/CP1252 text runs
let ascii_text = extract_document_text_cp1252(doc_data, ccp_text);
if !ascii_text.trim().is_empty() && has_enough_words(&ascii_text, 10) {
return Some(ascii_text);
}
// If ASCII extraction didn't work well, try UTF-16LE
let utf16_text = extract_document_text_utf16(doc_data, ccp_text);
if !utf16_text.trim().is_empty() && has_enough_words(&utf16_text, 10) {
return Some(utf16_text);
}
// Return whichever has more content
if ascii_text.len() > utf16_text.len() {
Some(ascii_text)
} else if !utf16_text.is_empty() {
Some(utf16_text)
} else {
None
}
}
fn extract_document_text_cp1252(data: &[u8], expected_chars: usize) -> String {
// Find long runs of printable ASCII/CP1252 characters
// This works well for most .doc files where text is stored as single-byte
let mut text_runs: Vec<String> = Vec::new();
let mut current_run = String::new();
let mut total_chars = 0;
let max_chars = if expected_chars > 0 && expected_chars < 10_000_000 {
expected_chars * 2 // Allow some extra for headers/footers
} else {
10_000_000
};
for &byte in data.iter() {
if total_chars >= max_chars {
break;
}
let ch = decode_cp1252(byte);
if is_text_char(ch) {
current_run.push(ch);
} else if byte == 0x0D || byte == 0x0A {
// Carriage return or line feed - end of paragraph
if current_run.len() >= 20 && has_word_chars(&current_run) {
text_runs.push(current_run.clone());
total_chars += current_run.len();
}
current_run.clear();
} else if byte == 0x09 {
// Tab
current_run.push('\t');
} else {
// Non-text byte - might be end of a text run
if current_run.len() >= 20 && has_word_chars(&current_run) {
text_runs.push(current_run.clone());
total_chars += current_run.len();
}
current_run.clear();
}
}
// Don't forget the last run
if current_run.len() >= 20 && has_word_chars(&current_run) {
text_runs.push(current_run);
}
// Join text runs with newlines
text_runs.join("\n")
}
fn extract_document_text_utf16(data: &[u8], expected_chars: usize) -> String {
let mut text = String::new();
let max_chars = if expected_chars > 0 && expected_chars < 10_000_000 {
expected_chars * 2
} else {
10_000_000
};
let mut i = 0;
let mut char_count = 0;
while i + 1 < data.len() && char_count < max_chars {
let code = u16::from_le_bytes([data[i], data[i + 1]]);
if let Some(ch) = char::from_u32(code as u32) {
if is_text_char(ch) || ch == '\r' || ch == '\n' || ch == '\t' {
if ch == '\r' {
text.push('\n');
} else {
text.push(ch);
}
char_count += 1;
}
}
i += 2;
}
// Filter to only keep substantial text portions
let lines: Vec<&str> = text
.lines()
.filter(|line| line.len() >= 10 && has_word_chars(line))
.collect();
lines.join("\n")
}
fn has_word_chars(s: &str) -> bool {
// Check if the string contains actual word characters (letters)
let letter_count = s.chars().filter(|c| c.is_alphabetic()).count();
let total_count = s.chars().count();
// At least 30% should be letters
letter_count > 0 && (letter_count * 100 / total_count.max(1)) >= 30
}
fn has_enough_words(s: &str, min_words: usize) -> bool {
s.split_whitespace().count() >= min_words
}
fn is_text_char(ch: char) -> bool {
// Printable character (not control chars, but allow some special ones)
(ch >= ' ' && ch != '\x7F') || ch == '\t'
}
fn extract_ascii_strings(data: &[u8], min_length: usize) -> Vec<String> {
let mut strings = Vec::new();
let mut current = String::new();
for &byte in data {
let ch = decode_cp1252(byte);
if ch.is_ascii_graphic() || ch == ' ' {
current.push(ch);
} else {
if current.len() >= min_length {
strings.push(current.clone());
}
current.clear();
}
}
if current.len() >= min_length {
strings.push(current);
}
strings
}
fn extract_text_fallback<R: Read + std::io::Seek>(
cfb: &mut CompoundFile<R>,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let mut all_text = String::new();
// List all streams and try to extract text from each
let entries: Vec<String> = cfb
.walk()
.filter(|e| e.is_stream())
.map(|e| e.path().to_string_lossy().to_string())
.collect();
for entry in entries {
// Skip known non-text streams
if entry.contains("CompObj")
|| entry.contains("Data")
|| entry.contains("ObjectPool")
|| entry.contains("Pictures")
{
continue;
}
if let Ok(mut stream) = cfb.open_stream(&entry) {
let mut buf = Vec::new();
if stream.read_to_end(&mut buf).is_ok() {
let stream_text = extract_document_text_cp1252(&buf, 0);
if !stream_text.trim().is_empty() && has_enough_words(&stream_text, 5) {
if !all_text.is_empty() {
all_text.push('\n');
}
all_text.push_str(&stream_text);
}
}
}
}
Ok(all_text)
}
fn decode_cp1252(b: u8) -> char {
if b < 0x80 {
return b as char;
}
match b {
0x80 => '\u{20AC}', // Euro sign
0x82 => '\u{201A}', // Single low-9 quotation mark
0x83 => '\u{0192}', // Latin small letter f with hook
0x84 => '\u{201E}', // Double low-9 quotation mark
0x85 => '\u{2026}', // Horizontal ellipsis
0x86 => '\u{2020}', // Dagger
0x87 => '\u{2021}', // Double dagger
0x88 => '\u{02C6}', // Modifier letter circumflex accent
0x89 => '\u{2030}', // Per mille sign
0x8A => '\u{0160}', // Latin capital letter S with caron
0x8B => '\u{2039}', // Single left-pointing angle quotation mark
0x8C => '\u{0152}', // Latin capital ligature OE
0x8E => '\u{017D}', // Latin capital letter Z with caron
0x91 => '\u{2018}', // Left single quotation mark
0x92 => '\u{2019}', // Right single quotation mark
0x93 => '\u{201C}', // Left double quotation mark
0x94 => '\u{201D}', // Right double quotation mark
0x95 => '\u{2022}', // Bullet
0x96 => '\u{2013}', // En dash
0x97 => '\u{2014}', // Em dash
0x98 => '\u{02DC}', // Small tilde
0x99 => '\u{2122}', // Trade mark sign
0x9A => '\u{0161}', // Latin small letter s with caron
0x9B => '\u{203A}', // Single right-pointing angle quotation mark
0x9C => '\u{0153}', // Latin small ligature oe
0x9E => '\u{017E}', // Latin small letter z with caron
0x9F => '\u{0178}', // Latin capital letter Y with diaeresis
_ => char::from_u32(b as u32).unwrap_or('?'),
}
}
fn text_to_blocks(text: &str) -> Vec<Block> {
let mut blocks = Vec::new();
// Split text into paragraphs and create blocks
for paragraph in text.split('\n') {
let trimmed = paragraph.trim();
if trimmed.is_empty() {
continue;
}
// Clean up the text - remove control characters except tabs
let cleaned: String = trimmed
.chars()
.filter(|c| !c.is_control() || *c == '\t')
.collect();
if cleaned.is_empty() {
continue;
}
blocks.push(Block::Paragraph(Paragraph {
kind: ParagraphKind::Normal,
inlines: vec![Inline::Text(cleaned)],
}));
}
blocks
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
use super::doc::DocProvider;
use super::docx::DocxProvider;
use super::odt::OdtProvider;
use super::rtf::RtfProvider;
use super::DocumentProvider;
use super::xlsx::XlsxProvider;
use napi_derive::napi;
#[napi]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DocumentType {
Doc,
Docx,
Rtf,
Odt,
Xlsx,
}
pub struct ProviderFactory {
doc_provider: DocProvider,
docx_provider: DocxProvider,
rtf_provider: RtfProvider,
odt_provider: OdtProvider,
xlsx_provider: XlsxProvider,
}
impl ProviderFactory {
pub fn new() -> Self {
Self {
doc_provider: DocProvider::new(),
docx_provider: DocxProvider::new(),
rtf_provider: RtfProvider::new(),
odt_provider: OdtProvider::new(),
xlsx_provider: XlsxProvider::new(),
}
}
pub fn get_provider(&self, doc_type: DocumentType) -> &dyn DocumentProvider {
match doc_type {
DocumentType::Doc => &self.doc_provider,
DocumentType::Docx => &self.docx_provider,
DocumentType::Rtf => &self.rtf_provider,
DocumentType::Odt => &self.odt_provider,
DocumentType::Xlsx => &self.xlsx_provider,
}
}
}

View File

@@ -0,0 +1,16 @@
use crate::document::model::Document;
use std::error::Error;
pub mod doc;
pub mod docx;
pub mod factory;
pub mod odt;
pub mod rtf;
pub mod xlsx;
pub trait DocumentProvider {
fn parse_buffer(&self, data: &[u8]) -> Result<Document, Box<dyn Error + Send + Sync>>;
#[allow(dead_code)]
fn name(&self) -> &'static str;
}

View File

@@ -0,0 +1,764 @@
use crate::document::model::*;
use crate::document::providers::DocumentProvider;
use chrono::{DateTime, Utc};
use roxmltree::{Document as XmlDoc, Node};
use std::collections::HashMap;
use std::error::Error;
use std::io::{Read, Seek};
use std::num::NonZeroU32;
use zip::read::ZipArchive;
pub struct OdtProvider;
impl OdtProvider {
pub fn new() -> Self {
Self
}
}
impl DocumentProvider for OdtProvider {
fn parse_buffer(&self, data: &[u8]) -> Result<Document, Box<dyn Error + Send + Sync>> {
let cursor = std::io::Cursor::new(data);
let mut zip = ZipArchive::new(cursor)?;
let meta = read_meta(&mut zip).unwrap_or_default();
let styles = read_styles(&mut zip);
let content =
read_zip_text(&mut zip, "content.xml").ok_or("Missing content.xml in document")?;
let xml = XmlDoc::parse(strip_bom(&content))?;
let mut notes: Vec<Note> = Vec::new();
let mut comments: Vec<Comment> = Vec::new();
let mut blocks: Vec<Block> = Vec::new();
let body_text = xml
.descendants()
.find(|n| is_tag(n, "text") && n.ancestors().any(|a| is_tag(&a, "body")));
if let Some(text_node) = body_text {
blocks = parse_block_children_odt(&text_node, &styles, &mut notes, &mut comments, &mut zip);
}
Ok(Document {
blocks,
metadata: meta,
notes,
comments,
})
}
fn name(&self) -> &'static str {
"odt"
}
}
fn read_zip_text<R: Read + Seek>(zip: &mut ZipArchive<R>, path: &str) -> Option<String> {
let mut file = zip.by_name(path).ok()?;
let mut s = String::new();
file.read_to_string(&mut s).ok()?;
Some(s)
}
fn strip_bom(s: &str) -> &str {
const BOM: char = '\u{FEFF}';
s.strip_prefix(BOM).unwrap_or(s)
}
#[derive(Debug, Default, Clone)]
struct OdtStylesInfo {
paragraph_names: HashMap<String, String>,
paragraph_outline_level: HashMap<String, u8>,
paragraph_text_props: HashMap<String, TextStyleProps>,
text_props: HashMap<String, TextStyleProps>,
text_font_name: HashMap<String, String>,
list_is_ordered: HashMap<String, bool>,
}
#[derive(Debug, Default, Clone, Copy)]
struct TextStyleProps {
bold: bool,
italic: bool,
strike: bool,
sup: bool,
sub: bool,
code: bool,
}
fn read_styles<R: Read + Seek>(zip: &mut ZipArchive<R>) -> OdtStylesInfo {
let mut info = OdtStylesInfo::default();
if let Some(t) = read_zip_text(zip, "styles.xml") {
if let Ok(doc) = XmlDoc::parse(strip_bom(&t)) {
harvest_styles_from_doc(&doc, &mut info);
}
}
if let Some(t) = read_zip_text(zip, "content.xml") {
if let Ok(doc) = XmlDoc::parse(strip_bom(&t)) {
harvest_styles_from_doc(&doc, &mut info);
}
}
info
}
fn harvest_styles_from_doc(doc: &XmlDoc, out: &mut OdtStylesInfo) {
for s in doc.descendants().filter(|n| is_tag(n, "style")) {
let Some(family) = get_attr_local(&s, "family") else {
continue;
};
let Some(name) = get_attr_local(&s, "name") else {
continue;
};
let lname = name.to_string();
if family == "paragraph" {
out.paragraph_names.insert(lname.clone(), lname.clone());
if let Some(ppr) = child(&s, "paragraph-properties") {
if let Some(ol) = get_attr_local(&ppr, "outline-level") {
if let Ok(v) = ol.parse::<u8>() {
out.paragraph_outline_level.insert(lname.clone(), v.min(6));
}
}
}
if !out.paragraph_outline_level.contains_key(&lname) {
if let Some(parent) = get_attr_local(&s, "parent-style-name") {
if let Some(lv) = parse_odt_heading_level(parent) {
out.paragraph_outline_level.insert(lname.clone(), lv);
}
}
}
if let Some(tp) = child(&s, "text-properties") {
let mut props = parse_text_properties(&tp);
if let Some(v) = get_attr_local(&tp, "font-name") {
if v.to_ascii_lowercase().contains("courier") || v.to_ascii_lowercase().contains("mono") {
props.code = true;
}
out.text_font_name.insert(lname.clone(), v.to_string());
}
out.paragraph_text_props.insert(lname.clone(), props);
}
} else if family == "text" {
if let Some(tp) = child(&s, "text-properties") {
let mut props = parse_text_properties(&tp);
if let Some(v) = get_attr_local(&tp, "font-name") {
if v.to_ascii_lowercase().contains("courier") || v.to_ascii_lowercase().contains("mono") {
props.code = true;
}
out.text_font_name.insert(lname.clone(), v.to_string());
}
out.text_props.insert(lname.clone(), props);
}
} else if family == "list" {
let is_ordered = s
.children()
.filter(|n| n.is_element())
.any(|child_n| is_tag(&child_n, "list-level-style-number"));
out.list_is_ordered.insert(lname.clone(), is_ordered);
}
}
for ls in doc.descendants().filter(|n| is_tag(n, "list-style")) {
if let Some(name) = get_attr_local(&ls, "name") {
let is_ordered = ls
.children()
.filter(|n| n.is_element())
.any(|c| is_tag(&c, "list-level-style-number"));
out.list_is_ordered.insert(name.to_string(), is_ordered);
}
}
}
fn parse_text_properties(tp: &Node) -> TextStyleProps {
let mut props = TextStyleProps::default();
if let Some(v) = get_attr_local(tp, "font-weight") {
if v.eq_ignore_ascii_case("bold") {
props.bold = true;
}
}
if let Some(v) = get_attr_local(tp, "font-style") {
if v.eq_ignore_ascii_case("italic") {
props.italic = true;
}
}
if let Some(v) = get_attr_local(tp, "text-line-through-type")
.or_else(|| get_attr_local(tp, "text-line-through-style"))
{
if v != "none" {
props.strike = true;
}
}
if let Some(v) = get_attr_local(tp, "text-position") {
let lv = v.to_ascii_lowercase();
if lv.contains("sup") || lv.contains("super") {
props.sup = true;
} else if lv.contains("sub") {
props.sub = true;
}
}
props
}
fn read_meta<R: Read + Seek>(zip: &mut ZipArchive<R>) -> Option<DocumentMetadata> {
let text = read_zip_text(zip, "meta.xml")?;
let xml = XmlDoc::parse(strip_bom(&text)).ok()?;
let mut meta = DocumentMetadata::default();
if let Some(title) = xml
.descendants()
.find(|n| is_tag(n, "title"))
.and_then(|n| n.text())
{
if !title.trim().is_empty() {
meta.title = Some(title.to_string());
}
}
if let Some(author) = xml
.descendants()
.find(|n| is_tag(n, "creator"))
.and_then(|n| n.text())
.or_else(|| {
xml
.descendants()
.find(|n| is_tag(n, "initial-creator"))
.and_then(|n| n.text())
})
{
let trimmed = author.trim();
if !trimmed.is_empty() && !trimmed.eq_ignore_ascii_case("unknown") {
meta.author = Some(trimmed.to_string());
}
}
if let Some(created) = xml
.descendants()
.find(|n| is_tag(n, "creation-date"))
.and_then(|n| n.text())
{
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(created) {
meta.created = Some(DateTime::<Utc>::from(dt));
}
}
Some(meta)
}
fn is_tag(node: &Node, local: &str) -> bool {
node.is_element() && node.tag_name().name() == local
}
fn get_attr_local<'a>(node: &Node<'a, 'a>, local: &str) -> Option<&'a str> {
node
.attributes()
.find(|a| {
let name = a.name();
match name.rsplit_once(':') {
Some((_, l)) => l == local,
None => name == local,
}
})
.map(|a| a.value())
}
fn child<'a>(node: &Node<'a, 'a>, local: &str) -> Option<Node<'a, 'a>> {
node
.children()
.find(|n| n.is_element() && n.tag_name().name() == local)
}
fn children<'a, 'b>(
node: &Node<'a, 'a>,
local: &'b str,
) -> impl Iterator<Item = Node<'a, 'a>> + use<'a, 'b> {
node
.children()
.filter(move |n| n.is_element() && n.tag_name().name() == local)
}
fn parse_block_children_odt<R: Read + Seek>(
node: &Node,
styles: &OdtStylesInfo,
notes: &mut Vec<Note>,
comments: &mut Vec<Comment>,
zip: &mut ZipArchive<R>,
) -> Vec<Block> {
let mut blocks: Vec<Block> = Vec::new();
for child_n in node.children().filter(|n| n.is_element()) {
if is_tag(&child_n, "h") {
if let Some(p) = parse_paragraph(&child_n, styles, notes, comments) {
if paragraph_has_visible_content(&p) {
blocks.push(Block::Paragraph(p));
}
}
} else if is_tag(&child_n, "p") {
if let Some(img) = image_from_paragraph(&child_n, zip) {
blocks.push(Block::Image(img));
} else if let Some(p) = parse_paragraph(&child_n, styles, notes, comments) {
if paragraph_has_visible_content(&p) {
blocks.push(Block::Paragraph(p));
}
}
} else if is_tag(&child_n, "list") {
let mut effective = child_n;
let mut inherited_style_name = get_attr_local(&effective, "style-name");
let mut unwrapped = false;
while let Some(inner) = unwrap_single_nested_list(&effective) {
effective = inner;
unwrapped = true;
if inherited_style_name.is_none() {
inherited_style_name = get_attr_local(&effective, "style-name");
}
}
if is_heading_list(&effective) {
for li in children(&effective, "list-item") {
if let Some(h) = li.descendants().find(|n| is_tag(n, "h")) {
if let Some(p) = parse_paragraph(&h, styles, notes, comments) {
if paragraph_has_visible_content(&p) {
blocks.push(Block::Paragraph(p));
}
}
}
}
} else if let Some(l) = parse_list_with_inherit(
&effective,
styles,
notes,
comments,
zip,
inherited_style_name,
) {
if unwrapped {
if let Some(Block::List(prev)) = blocks.last_mut() {
if let Some(last_item) = prev.items.last_mut() {
last_item.blocks.push(Block::List(l));
continue;
}
}
}
blocks.push(Block::List(l));
}
} else if is_tag(&child_n, "table") {
if let Some(t) = parse_table(&child_n, styles, notes, comments, zip) {
blocks.push(Block::Table(t));
}
} else {
let mut inner = parse_block_children_odt(&child_n, styles, notes, comments, zip);
blocks.append(&mut inner);
}
}
blocks
}
fn unwrap_single_nested_list<'a>(list: &Node<'a, 'a>) -> Option<Node<'a, 'a>> {
let mut li_iter = children(list, "list-item");
let first_li = li_iter.next()?;
if li_iter.next().is_some() {
return None;
}
let mut inner_lists = first_li.children().filter(|n| is_tag(n, "list"));
let inner = inner_lists.next()?;
if inner_lists.next().is_some() {
return None;
}
Some(inner)
}
fn is_heading_list(list: &Node) -> bool {
let mut any = false;
for li in children(list, "list-item") {
any = true;
if !li.descendants().any(|n| is_tag(&n, "h")) {
return false;
}
}
any
}
fn parse_paragraph(
node: &Node,
styles: &OdtStylesInfo,
notes: &mut Vec<Note>,
comments: &mut Vec<Comment>,
) -> Option<Paragraph> {
let kind = paragraph_kind(node, styles);
let base = paragraph_text_props(node, styles);
let inlines = parse_inlines_with_base(node, styles, notes, comments, base);
Some(Paragraph { kind, inlines })
}
fn paragraph_kind(p: &Node, styles: &OdtStylesInfo) -> ParagraphKind {
if p.tag_name().name() == "h" {
if let Some(ol) = get_attr_local(p, "outline-level") {
if let Ok(v) = ol.parse::<u8>() {
return ParagraphKind::Heading(v.min(6));
}
}
return ParagraphKind::Heading(1);
}
if let Some(style_name) = get_attr_local(p, "style-name") {
if let Some(lvl) = styles.paragraph_outline_level.get(style_name) {
return ParagraphKind::Heading((*lvl).min(6));
}
let name = styles
.paragraph_names
.get(style_name)
.map(|s| s.to_ascii_lowercase())
.unwrap_or_default();
if name.contains("quote") {
return ParagraphKind::Blockquote;
}
}
ParagraphKind::Normal
}
fn parse_odt_heading_level(style_name: &str) -> Option<u8> {
let normalized = style_name.replace("_20_", " ").replace('_', " ");
let lower = normalized.to_ascii_lowercase();
if lower.contains("title") {
return Some(1);
}
if let Some(idx) = lower.find("heading") {
let tail = &lower[idx + "heading".len()..];
let num: String = tail.chars().filter(|c| c.is_ascii_digit()).collect();
if let Ok(n) = num.parse::<u8>() {
return Some(n.clamp(1, 6));
}
}
None
}
fn paragraph_text_props(node: &Node, styles: &OdtStylesInfo) -> TextStyleProps {
if let Some(style_name) = get_attr_local(node, "style-name") {
if let Some(p) = styles.paragraph_text_props.get(style_name) {
return *p;
}
}
TextStyleProps::default()
}
fn parse_inlines(
node: &Node,
styles: &OdtStylesInfo,
notes: &mut Vec<Note>,
comments: &mut Vec<Comment>,
) -> Vec<Inline> {
let mut out: Vec<Inline> = Vec::new();
for c in node.children() {
if c.is_text() {
if let Some(t) = c.text() {
if !t.is_empty() {
out.push(Inline::Text(t.to_string()));
}
}
continue;
}
if !c.is_element() {
continue;
}
if is_tag(&c, "span") {
let mut inner = parse_inlines(&c, styles, notes, comments);
let sname = get_attr_local(&c, "style-name").map(|s| s.to_string());
inner = apply_text_style_wrappers(inner, sname.as_deref(), styles, TextStyleProps::default());
out.extend(inner);
} else if is_tag(&c, "a") {
if let Some(href) = get_attr_local(&c, "href") {
let children = parse_inlines(&c, styles, notes, comments);
out.push(Inline::Link {
href: href.to_string(),
children,
});
} else {
out.extend(parse_inlines(&c, styles, notes, comments));
}
} else if is_tag(&c, "line-break") {
out.push(Inline::LineBreak);
} else if is_tag(&c, "s") {
let count = get_attr_local(&c, "c")
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(1);
out.push(Inline::Text(" ".repeat(count)));
} else if is_tag(&c, "tab") {
out.push(Inline::Text("\t".to_string()));
} else if is_tag(&c, "bookmark-start") {
if let Some(name) = get_attr_local(&c, "name") {
out.push(Inline::Bookmark(BookmarkId(name.to_string())));
}
} else if is_tag(&c, "note") {
let kind = match get_attr_local(&c, "note-class") {
Some("endnote") => NoteKind::Endnote,
_ => NoteKind::Footnote,
};
let id = get_attr_local(&c, "id")
.map(|s| s.to_string())
.unwrap_or_else(|| format!("odt-note-{}", notes.len() + 1));
let body = child(&c, "note-body");
let mut blocks: Vec<Block> = Vec::new();
if let Some(b) = body {
blocks = parse_note_body_blocks(&b, styles, notes, comments);
}
notes.push(Note {
id: NoteId(id.clone()),
kind,
blocks,
});
match kind {
NoteKind::Footnote => out.push(Inline::FootnoteRef(NoteId(id))),
NoteKind::Endnote => out.push(Inline::EndnoteRef(NoteId(id))),
}
} else if is_tag(&c, "annotation") {
let cid = format!("odt-comment-{}", comments.len() + 1);
let mut author: Option<String> = None;
let mut initials: Option<String> = None;
if let Some(a) = c
.descendants()
.find(|n| is_tag(n, "creator"))
.and_then(|n| n.text())
{
if !a.trim().is_empty() {
author = Some(a.to_string());
}
}
if let Some(init) = c
.descendants()
.find(|n| is_tag(n, "initials"))
.and_then(|n| n.text())
{
if !init.trim().is_empty() {
initials = Some(init.to_string());
}
}
let mut cblocks: Vec<Block> = Vec::new();
for p in c.children().filter(|n| is_tag(n, "p")) {
let inl = parse_inlines(&p, styles, notes, comments);
if !inl.is_empty() {
cblocks.push(Block::Paragraph(Paragraph {
kind: ParagraphKind::Normal,
inlines: inl,
}));
}
}
comments.push(Comment {
id: CommentId(cid.clone()),
author_name: author,
author_initials: initials,
blocks: cblocks,
});
out.push(Inline::CommentRef(CommentId(cid)));
} else {
out.extend(parse_inlines(&c, styles, notes, comments));
}
}
out
}
fn parse_inlines_with_base(
node: &Node,
styles: &OdtStylesInfo,
notes: &mut Vec<Note>,
comments: &mut Vec<Comment>,
base: TextStyleProps,
) -> Vec<Inline> {
let mut inlines = parse_inlines(node, styles, notes, comments);
inlines = apply_text_style_wrappers(inlines, None, styles, base);
inlines
}
fn apply_text_style_wrappers(
mut inlines: Vec<Inline>,
style_name: Option<&str>,
styles: &OdtStylesInfo,
base: TextStyleProps,
) -> Vec<Inline> {
let mut props = base;
if let Some(name) = style_name {
let lower = name.to_ascii_lowercase();
let mut sprops = styles.text_props.get(name).copied().unwrap_or_default();
if lower.contains("code")
|| styles
.text_font_name
.get(name)
.map(|f| {
f.to_ascii_lowercase().contains("courier") || f.to_ascii_lowercase().contains("mono")
})
.unwrap_or(false)
{
sprops.code = true;
}
props.bold |= sprops.bold;
props.italic |= sprops.italic;
props.strike |= sprops.strike;
props.sup |= sprops.sup;
props.sub |= sprops.sub;
props.code |= sprops.code;
}
if props.code {
let code_text: String = inlines
.iter()
.filter_map(|i| match i {
Inline::Text(s) => Some(s.as_str()),
_ => None,
})
.collect();
if !code_text.is_empty() {
return vec![Inline::Code(code_text)];
}
}
if props.strike {
inlines = vec![Inline::Del(inlines)];
}
if props.italic {
inlines = vec![Inline::Em(inlines)];
}
if props.bold {
inlines = vec![Inline::Strong(inlines)];
}
if props.sup {
inlines = vec![Inline::Sup(inlines)];
}
if props.sub {
inlines = vec![Inline::Sub(inlines)];
}
inlines
}
fn parse_note_body_blocks(
node: &Node,
styles: &OdtStylesInfo,
notes: &mut Vec<Note>,
comments: &mut Vec<Comment>,
) -> Vec<Block> {
let mut blocks = Vec::new();
for p in node.children().filter(|n| is_tag(n, "p") || is_tag(n, "h")) {
let kind = paragraph_kind(&p, styles);
let base = paragraph_text_props(&p, styles);
let inl = parse_inlines_with_base(&p, styles, notes, comments, base);
if inlines_have_visible_content(&inl) {
blocks.push(Block::Paragraph(Paragraph { kind, inlines: inl }));
}
}
blocks
}
fn paragraph_has_visible_content(p: &Paragraph) -> bool {
inlines_have_visible_content(&p.inlines)
}
fn inlines_have_visible_content(inlines: &[Inline]) -> bool {
inlines.iter().any(inline_is_visible)
}
fn inline_is_visible(i: &Inline) -> bool {
match i {
Inline::Text(t) => !t.trim().is_empty(),
Inline::LineBreak => false,
Inline::Link { children, .. } => inlines_have_visible_content(children),
Inline::Strong(c) | Inline::Em(c) | Inline::Del(c) | Inline::Sup(c) | Inline::Sub(c) => {
inlines_have_visible_content(c)
}
Inline::Code(c) => !c.trim().is_empty(),
Inline::FootnoteRef(_) | Inline::EndnoteRef(_) | Inline::CommentRef(_) => true,
Inline::Bookmark(_) => false,
}
}
fn parse_list_with_inherit<R: Read + Seek>(
node: &Node,
styles: &OdtStylesInfo,
notes: &mut Vec<Note>,
comments: &mut Vec<Comment>,
zip: &mut ZipArchive<R>,
inherit_style_name: Option<&str>,
) -> Option<List> {
let style_name = get_attr_local(node, "style-name").or(inherit_style_name);
let list_type = match style_name
.and_then(|n| styles.list_is_ordered.get(n))
.copied()
{
Some(true) => ListType::Ordered,
Some(false) => ListType::Unordered,
None => ListType::Unordered,
};
let mut items: Vec<ListItem> = Vec::new();
for it in children(node, "list-item") {
let mut blocks = Vec::new();
let mut inner = parse_block_children_odt(&it, styles, notes, comments, zip);
blocks.append(&mut inner);
items.push(ListItem { blocks });
}
Some(List { items, list_type })
}
fn parse_table<R: Read + Seek>(
node: &Node,
styles: &OdtStylesInfo,
notes: &mut Vec<Note>,
comments: &mut Vec<Comment>,
zip: &mut ZipArchive<R>,
) -> Option<Table> {
let mut rows: Vec<TableRow> = Vec::new();
for tr in children(node, "table-row") {
let mut cells: Vec<TableCell> = Vec::new();
for tc in children(&tr, "table-cell") {
let mut blocks = parse_block_children_odt(&tc, styles, notes, comments, zip);
let colspan = get_attr_local(&tc, "number-columns-spanned")
.and_then(|v| v.parse::<u32>().ok())
.and_then(NonZeroU32::new)
.unwrap_or_else(|| NonZeroU32::new(1).unwrap());
let rowspan = get_attr_local(&tc, "number-rows-spanned")
.and_then(|v| v.parse::<u32>().ok())
.and_then(NonZeroU32::new)
.unwrap_or_else(|| NonZeroU32::new(1).unwrap());
cells.push(TableCell {
blocks: std::mem::take(&mut blocks),
colspan,
rowspan,
});
}
rows.push(TableRow {
cells,
kind: TableRowKind::Body,
});
}
Some(Table { rows })
}
fn image_from_paragraph<R: Read + Seek>(p: &Node, zip: &mut ZipArchive<R>) -> Option<Image> {
let img = p.descendants().find(|n| is_tag(n, "image"))?;
let href = get_attr_local(&img, "href")?;
image_from_href(href, zip, None)
}
fn image_from_href<R: Read + Seek>(
href: &str,
_zip: &mut ZipArchive<R>,
alt: Option<String>,
) -> Option<Image> {
// only include external images (http/https URLs)
if href.starts_with("http://") || href.starts_with("https://") {
return Some(Image {
src: href.to_string(),
alt,
});
}
None
}

View File

@@ -0,0 +1,764 @@
use crate::document::model::*;
use crate::document::providers::DocumentProvider;
use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc};
use std::error::Error;
use std::num::NonZeroU32;
pub struct RtfProvider;
impl RtfProvider {
pub fn new() -> Self {
Self
}
}
impl DocumentProvider for RtfProvider {
fn parse_buffer(&self, data: &[u8]) -> Result<Document, Box<dyn Error + Send + Sync>> {
let metadata = extract_metadata_from_info(data).unwrap_or_default();
let blocks = parse_rtf_body_to_blocks(data);
Ok(Document {
blocks,
metadata,
notes: Vec::new(),
comments: Vec::new(),
})
}
fn name(&self) -> &'static str {
"rtf"
}
}
fn extract_metadata_from_info(src: &[u8]) -> Option<DocumentMetadata> {
let start = find_group_start(src, b"{\\info")?;
let end = find_matching_brace(src, start)?;
let info = &src[start..end];
let mut meta = DocumentMetadata::default();
if let Some(author) = extract_simple_text_dest(info, br"{\author") {
if !author.eq_ignore_ascii_case("unknown") {
meta.author = Some(author);
}
}
if let Some(title) = extract_simple_text_dest(info, br"{\title") {
if !title.trim().is_empty() {
meta.title = Some(title);
}
}
if let Some(created) = extract_creatim(info) {
meta.created = Some(created);
}
Some(meta)
}
fn find_group_start(buf: &[u8], needle: &[u8]) -> Option<usize> {
buf.windows(needle.len()).position(|w| w == needle)
}
fn find_matching_brace(buf: &[u8], start: usize) -> Option<usize> {
let mut depth = 0usize;
for (i, &b) in buf[start..].iter().enumerate() {
match b {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return Some(start + i + 1);
}
}
_ => {}
}
}
None
}
fn extract_simple_text_dest(buf: &[u8], start_tag: &[u8]) -> Option<String> {
let s = find_group_start(buf, start_tag)?;
let e = find_matching_brace(buf, s)?;
let mut out = String::new();
for &b in &buf[s + start_tag.len()..e - 1] {
push_byte_as_text(b, &mut out);
}
if out.trim().is_empty() {
None
} else {
Some(out.trim().to_string())
}
}
fn extract_creatim(buf: &[u8]) -> Option<DateTime<Utc>> {
let s = find_group_start(buf, br"{\creatim")?;
let e = find_matching_brace(buf, s)?;
let g = &buf[s..e];
let mut yr: Option<i32> = None;
let mut mo: Option<u32> = None;
let mut dy: Option<u32> = None;
let mut hr: Option<u32> = None;
let mut mi: Option<u32> = None;
let mut i = 0usize;
while i < g.len() {
if g[i] == b'\\' {
if let Some((word, val, ni)) = read_control_word(g, i + 1) {
match word.as_str() {
"yr" => yr = val,
"mo" => mo = val.map(|v| v as u32),
"dy" => dy = val.map(|v| v as u32),
"hr" => hr = val.map(|v| v as u32),
"min" => mi = val.map(|v| v as u32),
_ => {}
}
i = ni;
continue;
}
}
i += 1;
}
let date = NaiveDate::from_ymd_opt(yr?, mo?, dy?)?;
let time = chrono::NaiveTime::from_hms_opt(hr.unwrap_or(0), mi.unwrap_or(0), 0)?;
let dt = NaiveDateTime::new(date, time);
Some(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
}
#[derive(Default)]
struct TableBuilder {
rows: Vec<TableRow>,
current_row: Vec<TableCell>,
current_cell_blocks: Vec<Block>,
}
impl TableBuilder {
fn start_row(&mut self) {
self.current_cell_blocks.clear();
self.current_row.clear();
}
fn push_block(&mut self, block: Block) {
self.current_cell_blocks.push(block);
}
fn finish_cell(&mut self) {
if self.current_cell_blocks.is_empty() {
return;
}
let cell = TableCell {
blocks: std::mem::take(&mut self.current_cell_blocks),
colspan: NonZeroU32::new(1).unwrap(),
rowspan: NonZeroU32::new(1).unwrap(),
};
self.current_row.push(cell);
}
fn finish_row(&mut self) {
self.finish_cell();
if self.current_row.is_empty() {
return;
}
let row = TableRow {
cells: std::mem::take(&mut self.current_row),
kind: TableRowKind::Body,
};
self.rows.push(row);
}
fn finalize(mut self) -> Option<Block> {
self.finish_row();
if self.rows.is_empty() {
None
} else {
Some(Block::Table(Table { rows: self.rows }))
}
}
}
fn push_block_target(
block: Block,
blocks: &mut Vec<Block>,
table: &mut Option<TableBuilder>,
in_table_cell: bool,
) {
if in_table_cell {
if let Some(builder) = table.as_mut() {
builder.push_block(block);
} else {
blocks.push(block);
}
} else {
if let Some(builder) = table.take() {
if let Some(table_block) = builder.finalize() {
blocks.push(table_block);
}
}
blocks.push(block);
}
}
fn flush_table(blocks: &mut Vec<Block>, table: &mut Option<TableBuilder>) {
if let Some(builder) = table.take() {
if let Some(block) = builder.finalize() {
blocks.push(block);
}
}
}
fn parse_rtf_body_to_blocks(src: &[u8]) -> Vec<Block> {
let mut p = 0usize;
let n = src.len();
#[derive(Clone, Default, Debug, PartialEq, Eq)]
struct State {
bold: bool,
italic: bool,
strike: bool,
sup: bool,
sub: bool,
}
#[derive(Clone)]
struct Group {
saved: State,
skip: bool,
name_seen: bool,
}
let mut state = State::default();
let mut stack: Vec<Group> = Vec::new();
let mut blocks: Vec<Block> = Vec::new();
let mut cur_inlines: Vec<Inline> = Vec::new();
let mut text_buf = String::new();
let mut table_builder: Option<TableBuilder> = None;
let mut in_table_cell = false;
let mut uc_skip: usize = 1;
let mut pending_uc_skip: usize = 0;
const SKIP_DESTS: &[&str] = &[
"fonttbl",
"colortbl",
"stylesheet",
"listtable",
"listoverridetable",
"themedata",
"latentstyles",
"rsidtbl",
"xmlnstbl",
"mmathPr",
"wgrffmtfilter",
"datastore",
"filetbl",
"colorschememapping",
"pnseclvl1",
"pnseclvl2",
"pnseclvl3",
"pnseclvl4",
"pnseclvl5",
"pnseclvl6",
"pnseclvl7",
"pnseclvl8",
"pnseclvl9",
"pict",
"object",
"info",
];
fn style_wrap(mut node: Inline, st: &State) -> Inline {
if st.strike {
node = Inline::Del(vec![node]);
}
if st.italic {
node = Inline::Em(vec![node]);
}
if st.bold {
node = Inline::Strong(vec![node]);
}
if st.sup {
node = Inline::Sup(vec![node]);
} else if st.sub {
node = Inline::Sub(vec![node]);
}
node
}
fn push_text_buf(text_buf: &mut String, cur: &mut Vec<Inline>, st: &State) {
if !text_buf.is_empty() {
let node = style_wrap(Inline::Text(text_buf.clone()), st);
cur.push(node);
text_buf.clear();
}
}
fn has_visible_content(inlines: &[Inline]) -> bool {
inlines.iter().any(|i| match i {
Inline::Text(t) => !t.trim().is_empty(),
Inline::LineBreak => false,
Inline::Link { children, .. } => has_visible_content(children),
Inline::Strong(c) | Inline::Em(c) | Inline::Del(c) | Inline::Sup(c) | Inline::Sub(c) => {
has_visible_content(c)
}
Inline::Code(t) => !t.trim().is_empty(),
Inline::FootnoteRef(_) | Inline::EndnoteRef(_) | Inline::CommentRef(_) => true,
Inline::Bookmark(_) => false,
})
}
fn flush_paragraph(
cur: &mut Vec<Inline>,
text_buf: &mut String,
blocks: &mut Vec<Block>,
table: &mut Option<TableBuilder>,
st: &State,
in_table_cell: bool,
) {
push_text_buf(text_buf, cur, st);
if has_visible_content(cur) {
let block = Block::Paragraph(Paragraph {
kind: ParagraphKind::Normal,
inlines: std::mem::take(cur),
});
push_block_target(block, blocks, table, in_table_cell);
} else {
cur.clear();
if !in_table_cell {
flush_table(blocks, table);
}
}
}
let flush_before_change = |text_buf: &mut String, cur: &mut Vec<Inline>, st: &State| {
push_text_buf(text_buf, cur, st);
};
while p < n {
match src[p] {
b'{' => {
let inherited_skip = stack.last().map(|g| g.skip).unwrap_or(false);
stack.push(Group {
saved: state.clone(),
skip: inherited_skip,
name_seen: false,
});
p += 1;
}
b'}' => {
if let Some(g) = stack.last() {
if !g.skip {
flush_before_change(&mut text_buf, &mut cur_inlines, &state);
}
}
if let Some(g) = stack.pop() {
state = g.saved;
}
p += 1;
}
b'\\' => {
if p + 1 >= n {
break;
}
let next = src[p + 1];
if next == b'\\' || next == b'{' || next == b'}' {
if !stack.last().map(|g| g.skip).unwrap_or(false) {
text_buf.push(next as char);
}
p += 2;
continue;
}
if next == b'\'' {
if p + 3 < n {
let h1 = src[p + 2];
let h2 = src[p + 3];
if !stack.last().map(|g| g.skip).unwrap_or(false) {
if let (Some(a), Some(b)) = (hex_val(h1), hex_val(h2)) {
let byte = (a << 4) | b;
push_byte_as_text(byte, &mut text_buf);
}
}
p += 4;
continue;
} else {
break;
}
}
let skip = stack.last().map(|g| g.skip).unwrap_or(false);
match next {
b'~' => {
if !skip {
text_buf.push('\u{00A0}');
} // non-breaking space
p += 2;
continue;
}
b'-' => {
if !skip {
text_buf.push('\u{00AD}');
} // soft hyphen
p += 2;
continue;
}
_ => {}
}
if starts_with_word(src, p + 1, b"rquote") {
if !skip {
text_buf.push('\u{2019}');
} // '
p = skip_word_and_space(src, p + 1);
continue;
}
if starts_with_word(src, p + 1, b"lquote") {
if !skip {
text_buf.push('\u{2018}');
} // '
p = skip_word_and_space(src, p + 1);
continue;
}
if starts_with_word(src, p + 1, b"rdblquote") {
if !skip {
text_buf.push('\u{201D}');
} // "
p = skip_word_and_space(src, p + 1);
continue;
}
if starts_with_word(src, p + 1, b"ldblquote") {
if !skip {
text_buf.push('\u{201C}');
} // "
p = skip_word_and_space(src, p + 1);
continue;
}
if starts_with_word(src, p + 1, b"emdash") {
if !skip {
text_buf.push('\u{2014}');
} // —
p = skip_word_and_space(src, p + 1);
continue;
}
if starts_with_word(src, p + 1, b"endash") {
if !skip {
text_buf.push('\u{2013}');
} //
p = skip_word_and_space(src, p + 1);
continue;
}
if starts_with_word(src, p + 1, b"bullet") {
if !skip {
text_buf.push('\u{2022}');
} // •
p = skip_word_and_space(src, p + 1);
continue;
}
if starts_with_word(src, p + 1, b"line") {
if !skip {
push_text_buf(&mut text_buf, &mut cur_inlines, &state);
cur_inlines.push(Inline::LineBreak);
}
p = skip_word_and_space(src, p + 1);
continue;
}
if starts_with_word(src, p + 1, b"tab") {
if !skip {
text_buf.push('\t');
}
p = skip_word_and_space(src, p + 1);
continue;
}
if let Some((word, val, new_p)) = read_control_word(src, p + 1) {
if let Some(g) = stack.last_mut() {
if !g.name_seen {
g.name_seen = true;
if word == "*" || SKIP_DESTS.contains(&word.as_str()) {
g.skip = true;
}
}
}
let skipping = stack.last().map(|g| g.skip).unwrap_or(false);
if !skipping {
match word.as_str() {
"trowd" => {
let builder = table_builder.get_or_insert_with(TableBuilder::default);
builder.start_row();
in_table_cell = false;
}
"intbl" => {
in_table_cell = true;
}
"cell" => {
flush_paragraph(
&mut cur_inlines,
&mut text_buf,
&mut blocks,
&mut table_builder,
&state,
true,
);
if let Some(builder) = table_builder.as_mut() {
builder.finish_cell();
}
in_table_cell = false;
}
"row" => {
if let Some(builder) = table_builder.as_mut() {
builder.finish_row();
}
in_table_cell = false;
}
"cellx" | "clvertalb" | "clvertalc" | "clvertalt" => {}
"b" => {
flush_before_change(&mut text_buf, &mut cur_inlines, &state);
state.bold = val.map(|v| v != 0).unwrap_or(true);
}
"i" => {
flush_before_change(&mut text_buf, &mut cur_inlines, &state);
state.italic = val.map(|v| v != 0).unwrap_or(true);
}
"strike" | "striked" | "striked1" => {
flush_before_change(&mut text_buf, &mut cur_inlines, &state);
state.strike = val.map(|v| v != 0).unwrap_or(true);
}
"super" => {
flush_before_change(&mut text_buf, &mut cur_inlines, &state);
state.sup = val.map(|v| v != 0).unwrap_or(true);
if state.sup {
state.sub = false;
}
}
"sub" => {
flush_before_change(&mut text_buf, &mut cur_inlines, &state);
state.sub = val.map(|v| v != 0).unwrap_or(true);
if state.sub {
state.sup = false;
}
}
"nosupersub" => {
flush_before_change(&mut text_buf, &mut cur_inlines, &state);
state.sup = false;
state.sub = false;
}
"plain" => {
flush_before_change(&mut text_buf, &mut cur_inlines, &state);
state = State::default();
}
"par" => {
flush_paragraph(
&mut cur_inlines,
&mut text_buf,
&mut blocks,
&mut table_builder,
&state,
in_table_cell,
);
}
"uc" => {
uc_skip = val.unwrap_or(1).max(0) as usize;
}
"u" => {
if let Some(mut num) = val {
if num < 0 {
num += 65536;
}
if let Some(ch) = std::char::from_u32(num as u32) {
text_buf.push(ch);
}
pending_uc_skip = uc_skip;
}
}
_ => {}
}
} else if word == "par" {
flush_paragraph(
&mut cur_inlines,
&mut text_buf,
&mut blocks,
&mut table_builder,
&state,
in_table_cell,
);
}
let mut final_p = new_p;
if pending_uc_skip > 0 {
let mut k = 0usize;
while k < pending_uc_skip && final_p < n {
if matches!(src[final_p], b'\\' | b'{' | b'}') {
break;
}
final_p += 1;
k += 1;
}
pending_uc_skip = 0;
}
p = final_p;
continue;
}
p += 1;
}
b'\r' | b'\n' => {
p += 1;
}
byte => {
if !stack.last().map(|g| g.skip).unwrap_or(false) {
if pending_uc_skip > 0 {
pending_uc_skip -= 1;
} else {
push_byte_as_text(byte, &mut text_buf);
}
}
p += 1;
}
}
}
if !text_buf.is_empty() || !cur_inlines.is_empty() {
flush_paragraph(
&mut cur_inlines,
&mut text_buf,
&mut blocks,
&mut table_builder,
&state,
in_table_cell,
);
}
flush_table(&mut blocks, &mut table_builder);
blocks
}
fn read_control_word(src: &[u8], mut i: usize) -> Option<(String, Option<i32>, usize)> {
if i >= src.len() {
return None;
}
if src[i] == b'*' {
i += 1;
if i < src.len() && src[i] == b' ' {
i += 1;
}
return Some(("*".to_string(), None, i));
}
let start = i;
while i < src.len() && is_alpha(src[i]) {
i += 1;
}
if i == start {
return None;
}
let word = String::from_utf8_lossy(&src[start..i]).to_string();
let mut sign = 1i32;
let mut val: Option<i32> = None;
if i < src.len() && (src[i] == b'-' || is_digit(src[i])) {
if src[i] == b'-' {
sign = -1;
i += 1;
}
let num_start = i;
while i < src.len() && is_digit(src[i]) {
i += 1;
}
if i > num_start {
let n = std::str::from_utf8(&src[num_start..i])
.ok()?
.parse::<i32>()
.ok()?;
val = Some(sign * n);
}
}
if i < src.len() && src[i] == b' ' {
i += 1;
}
Some((word, val, i))
}
#[inline]
fn is_alpha(b: u8) -> bool {
b.is_ascii_uppercase() || b.is_ascii_lowercase()
}
#[inline]
fn is_digit(b: u8) -> bool {
b.is_ascii_digit()
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(10 + (b - b'a')),
b'A'..=b'F' => Some(10 + (b - b'A')),
_ => None,
}
}
fn push_byte_as_text(byte: u8, text_buf: &mut String) {
let ch = decode_cp1252(byte);
let cp = ch as u32;
if ch == '\t' || ch == '\u{00A0}' || cp >= 0x20 {
text_buf.push(ch);
}
}
fn decode_cp1252(b: u8) -> char {
if b < 0x80 {
return b as char;
}
match b {
0x80 => '\u{20AC}', // €
0x82 => '\u{201A}', //
0x83 => '\u{0192}', // ƒ
0x84 => '\u{201E}', // „
0x85 => '\u{2026}', // …
0x86 => '\u{2020}', // †
0x87 => '\u{2021}', // ‡
0x88 => '\u{02C6}', // ˆ
0x89 => '\u{2030}', // ‰
0x8A => '\u{0160}', // Š
0x8B => '\u{2039}', //
0x8C => '\u{0152}', // Œ
0x8E => '\u{017D}', // Ž
0x91 => '\u{2018}', // '
0x92 => '\u{2019}', // '
0x93 => '\u{201C}', // "
0x94 => '\u{201D}', // "
0x95 => '\u{2022}', // •
0x96 => '\u{2013}', //
0x97 => '\u{2014}', // —
0x98 => '\u{02DC}', // ˜
0x99 => '\u{2122}', // ™
0x9A => '\u{0161}', // š
0x9B => '\u{203A}', //
0x9C => '\u{0153}', // œ
0x9E => '\u{017E}', // ž
0x9F => '\u{0178}', // Ÿ
_ => b as char,
}
}
fn starts_with_word(src: &[u8], i: usize, word: &[u8]) -> bool {
let end = i + word.len();
end <= src.len() && &src[i..end] == word
}
fn skip_word_and_space(src: &[u8], mut i: usize) -> usize {
while i < src.len() && is_alpha(src[i]) {
i += 1;
}
if i < src.len() && src[i] == b' ' {
i += 1;
}
i
}

View File

@@ -0,0 +1,87 @@
use crate::document::model::*;
use crate::document::providers::DocumentProvider;
use calamine::{open_workbook_auto_from_rs, Data, Reader};
use std::error::Error;
use std::io::Cursor;
use std::num::NonZeroU32;
const ONE: NonZeroU32 = unsafe { NonZeroU32::new_unchecked(1) };
pub struct XlsxProvider;
impl XlsxProvider {
pub fn new() -> Self {
Self
}
}
impl DocumentProvider for XlsxProvider {
fn parse_buffer(&self, data: &[u8]) -> Result<Document, Box<dyn Error + Send + Sync>> {
let cursor = Cursor::new(data);
let mut workbook = open_workbook_auto_from_rs(cursor)?;
let mut blocks: Vec<Block> = Vec::new();
for sheet_name in workbook.sheet_names() {
// Add sheet heading
blocks.push(Block::Paragraph(Paragraph {
kind: ParagraphKind::Heading(2),
inlines: vec![Inline::Text(sheet_name.clone())],
}));
if let Ok(range) = workbook.worksheet_range(&sheet_name) {
let mut rows: Vec<TableRow> = Vec::new();
for r in range.rows() {
let mut cells: Vec<TableCell> = Vec::new();
for cell in r {
let text = data_type_to_string(cell);
let blocks_in_cell = if text.trim().is_empty() {
Vec::new()
} else {
vec![Block::Paragraph(Paragraph {
kind: ParagraphKind::Normal,
inlines: vec![Inline::Text(text)],
})]
};
cells.push(TableCell {
blocks: blocks_in_cell,
colspan: ONE,
rowspan: ONE,
});
}
rows.push(TableRow {
cells,
kind: TableRowKind::Body,
});
}
blocks.push(Block::Table(Table { rows }));
}
}
Ok(Document {
blocks,
metadata: DocumentMetadata::default(),
notes: Vec::new(),
comments: Vec::new(),
})
}
fn name(&self) -> &'static str {
"xlsx"
}
}
fn data_type_to_string(cell: &Data) -> String {
match cell {
Data::Empty => String::new(),
Data::String(s) => s.clone(),
Data::Float(f) => f.to_string(),
Data::Int(i) => i.to_string(),
Data::Bool(b) => b.to_string(),
Data::DateTime(v) => v.to_string(),
Data::DateTimeIso(v) => v.to_string(),
Data::DurationIso(v) => v.to_string(),
Data::Error(e) => format!("#ERROR({e:?})"),
}
}

View File

@@ -0,0 +1,237 @@
use crate::document::model::*;
use maud::{html, Markup, DOCTYPE};
pub struct HtmlRenderer;
impl HtmlRenderer {
pub fn new() -> Self {
Self
}
pub fn render(&self, document: &Document) -> String {
let title = document.metadata.title.as_deref().unwrap_or("Document");
let footnotes: Vec<&Note> = document
.notes
.iter()
.filter(|n| matches!(n.kind, NoteKind::Footnote))
.collect();
let endnotes: Vec<&Note> = document
.notes
.iter()
.filter(|n| matches!(n.kind, NoteKind::Endnote))
.collect();
let author = document.metadata.author.as_deref();
let page: Markup = html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="UTF-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
title { (title) }
@if let Some(author) = author {
meta name="author" content=(author);
}
}
body {
main { (self.render_blocks(&document.blocks)) }
@if !footnotes.is_empty() {
section id="footnotes" {
h2 { "Footnotes" }
@for footnote in &footnotes {
div id={ "footnote-" (&footnote.id.0) } {
(self.render_blocks(&footnote.blocks))
}
}
}
}
@if !endnotes.is_empty() {
section id="endnotes" {
h2 { "Endnotes" }
@for endnote in &endnotes {
div id={ "endnote-" (&endnote.id.0) } {
(self.render_blocks(&endnote.blocks))
}
}
}
}
@if !document.comments.is_empty() {
section id="comments" {
h2 { "Comments" }
@for comment in &document.comments {
article id={ "comment-" (&comment.id.0) } {
@if let Some(author) = &comment.author_name {
header {
(author)
@if let Some(initials) = &comment.author_initials {
" (" (initials) ")"
}
}
}
(self.render_blocks(&comment.blocks))
}
}
}
}
}
}
};
page.into_string()
}
fn render_blocks(&self, blocks: &[Block]) -> Markup {
html! {
@for b in blocks {
@match b {
Block::Paragraph(p) => { (self.render_paragraph(p)) }
Block::Table(t) => { (self.render_table(t)) }
Block::List(l) => { (self.render_list(l)) }
Block::Image(i) => { (self.render_image(i)) }
}
}
}
}
fn render_blocks_inline(&self, blocks: &[Block]) -> Markup {
if blocks.len() == 1 {
if let Block::Paragraph(p) = &blocks[0] {
if matches!(p.kind, ParagraphKind::Normal) {
return self.render_inlines(&p.inlines);
}
}
}
self.render_blocks(blocks)
}
fn render_paragraph(&self, p: &Paragraph) -> Markup {
match p.kind {
ParagraphKind::Normal => html! { p { (self.render_inlines(&p.inlines)) } },
ParagraphKind::Blockquote => html! {
blockquote { p { (self.render_inlines(&p.inlines)) } }
},
ParagraphKind::Heading(level) => match level {
1 => html! { h1 { (self.render_inlines(&p.inlines)) } },
2 => html! { h2 { (self.render_inlines(&p.inlines)) } },
3 => html! { h3 { (self.render_inlines(&p.inlines)) } },
4 => html! { h4 { (self.render_inlines(&p.inlines)) } },
5 => html! { h5 { (self.render_inlines(&p.inlines)) } },
_ => html! { h6 { (self.render_inlines(&p.inlines)) } },
},
}
}
fn render_table(&self, t: &Table) -> Markup {
let mut head_rows = Vec::new();
let mut body_rows = Vec::new();
let mut foot_rows = Vec::new();
for row in &t.rows {
match row.kind {
TableRowKind::Header => head_rows.push(row),
TableRowKind::Body => body_rows.push(row),
TableRowKind::Footer => foot_rows.push(row),
}
}
html! {
table {
@if !head_rows.is_empty() {
thead { @for row in head_rows { (self.render_table_row(row, true)) } }
}
tbody { @for row in body_rows { (self.render_table_row(row, false)) } }
@if !foot_rows.is_empty() {
tfoot { @for row in foot_rows { (self.render_table_row(row, false)) } }
}
}
}
}
fn render_table_row(&self, row: &TableRow, header: bool) -> Markup {
html! {
tr {
@for cell in &row.cells {
@let cs = cell.colspan.get();
@let rs = cell.rowspan.get();
@let cs_attr = if cs > 1 { Some(cs) } else { None };
@let rs_attr = if rs > 1 { Some(rs) } else { None };
@if header {
@if let (Some(cs), Some(rs)) = (cs_attr, rs_attr) {
th colspan=(cs) rowspan=(rs) { (self.render_blocks_inline(&cell.blocks)) }
} @else if let Some(cs) = cs_attr {
th colspan=(cs) { (self.render_blocks_inline(&cell.blocks)) }
} @else if let Some(rs) = rs_attr {
th rowspan=(rs) { (self.render_blocks_inline(&cell.blocks)) }
} @else {
th { (self.render_blocks_inline(&cell.blocks)) }
}
} @else {
@if let (Some(cs), Some(rs)) = (cs_attr, rs_attr) {
td colspan=(cs) rowspan=(rs) { (self.render_blocks_inline(&cell.blocks)) }
} @else if let Some(cs) = cs_attr {
td colspan=(cs) { (self.render_blocks_inline(&cell.blocks)) }
} @else if let Some(rs) = rs_attr {
td rowspan=(rs) { (self.render_blocks_inline(&cell.blocks)) }
} @else {
td { (self.render_blocks_inline(&cell.blocks)) }
}
}
}
}
}
}
fn render_list(&self, l: &List) -> Markup {
match l.list_type {
ListType::Ordered => html! {
ol { @for item in &l.items { li { (self.render_blocks_inline(&item.blocks)) } } }
},
ListType::Unordered => html! {
ul { @for item in &l.items { li { (self.render_blocks_inline(&item.blocks)) } } }
},
}
}
fn render_image(&self, i: &Image) -> Markup {
match &i.alt {
Some(alt) => html! { img src=(i.src) alt=(alt); },
None => html! { img src=(i.src); },
}
}
fn render_inlines(&self, inlines: &[Inline]) -> Markup {
html! { @for inline in inlines { (self.render_inline(inline)) } }
}
fn render_inline(&self, inline: &Inline) -> Markup {
match inline {
Inline::Text(t) => html! { (t) },
Inline::LineBreak => html! { br; },
Inline::Link { href, children } => {
html! { a href=(href) { (self.render_inlines(children)) } }
}
Inline::Strong(children) => html! { strong { (self.render_inlines(children)) } },
Inline::Em(children) => html! { em { (self.render_inlines(children)) } },
Inline::Del(children) => html! { del { (self.render_inlines(children)) } },
Inline::Code(code) => html! { code { (code) } },
Inline::Sup(children) => html! { sup { (self.render_inlines(children)) } },
Inline::Sub(children) => html! { sub { (self.render_inlines(children)) } },
Inline::FootnoteRef(id) => {
html! { sup { a href={ "#footnote-" (&id.0) } { (&id.0) } } }
}
Inline::EndnoteRef(id) => html! { sup { a href={ "#endnote-" (&id.0) } { (&id.0) } } },
Inline::CommentRef(id) => html! { a href={ "#comment-" (&id.0) } { "💬" } },
Inline::Bookmark(id) => html! { a id=(&id.0) {} },
}
}
}

View File

@@ -0,0 +1 @@
pub mod html;

View File

@@ -0,0 +1,206 @@
use napi_derive::napi;
use serde::{Deserialize, Serialize};
use strsim::levenshtein;
use tokio::task;
/// Result of evaluating a single URL across different engines
#[derive(Deserialize, Serialize)]
#[napi(object)]
pub struct EngpickerUrlResult {
pub url: String,
pub cdp_basic_markdown: Option<String>,
pub cdp_basic_success: bool,
pub cdp_stealth_markdown: Option<String>,
pub cdp_stealth_success: bool,
pub tls_basic_markdown: Option<String>,
pub tls_basic_success: bool,
pub tls_stealth_markdown: Option<String>,
pub tls_stealth_success: bool,
}
/// Verdict for a single URL
#[derive(Serialize)]
#[napi(object)]
pub struct EngpickerUrlVerdict {
pub url: String,
pub tls_client_sufficient: bool,
pub cdp_failed: bool,
pub similarity: Option<f64>,
pub reason: String,
}
/// Final verdict enum
#[derive(Serialize)]
#[napi(string_enum)]
pub enum EngpickerFinalVerdict {
/// tlsclient is sufficient for this site
TlsClientOk,
/// Chrome CDP is required for proper rendering
ChromeCdpRequired,
/// Too many CDP failures to determine verdict
Uncertain,
}
/// Final verdict result
#[derive(Serialize)]
#[napi(object)]
pub struct EngpickerVerdict {
pub url_verdicts: Vec<EngpickerUrlVerdict>,
pub tls_client_ok_count: u32,
pub chrome_cdp_required_count: u32,
pub cdp_failed_count: u32,
pub total_urls: u32,
pub verdict: EngpickerFinalVerdict,
}
/// Compute engpicker verdict using Levenshtein distance to compare tlsclient vs chrome-cdp results.
///
/// Chrome-CDP is the gold standard. We compare tlsclient markdown against it to determine
/// if tlsclient is sufficient for scraping this site (i.e., JS rendering not required).
///
/// Arguments:
/// - results: scrape results for each URL
/// - similarity_threshold: minimum similarity (0.0-1.0) for tlsclient to be considered sufficient
/// - success_rate_threshold: minimum ratio of successful comparisons for a definitive verdict
/// - cdp_failure_threshold: maximum ratio of CDP failures before verdict becomes uncertain
#[napi]
pub async fn compute_engpicker_verdict(
results: Vec<EngpickerUrlResult>,
similarity_threshold: f64,
success_rate_threshold: f64,
cdp_failure_threshold: f64,
) -> napi::Result<EngpickerVerdict> {
task::spawn_blocking(move || {
_compute_engpicker_verdict(results, similarity_threshold, success_rate_threshold, cdp_failure_threshold)
})
.await
.map_err(|e| {
napi::Error::new(
napi::Status::GenericFailure,
format!("compute_engpicker_verdict join error: {e}"),
)
})?
}
fn _compute_engpicker_verdict(
results: Vec<EngpickerUrlResult>,
similarity_threshold: f64,
success_rate_threshold: f64,
cdp_failure_threshold: f64,
) -> napi::Result<EngpickerVerdict> {
let url_verdicts: Vec<EngpickerUrlVerdict> = results
.iter()
.map(|result| {
// Get the best chrome-cdp result as gold standard (prefer stealth if both succeeded)
let gold_standard = if result.cdp_stealth_success && result.cdp_stealth_markdown.is_some() {
result.cdp_stealth_markdown.as_ref()
} else if result.cdp_basic_success && result.cdp_basic_markdown.is_some() {
result.cdp_basic_markdown.as_ref()
} else {
None
};
// Get the best tlsclient result (prefer stealth if both succeeded)
let tls_result = if result.tls_stealth_success && result.tls_stealth_markdown.is_some() {
result.tls_stealth_markdown.as_ref()
} else if result.tls_basic_success && result.tls_basic_markdown.is_some() {
result.tls_basic_markdown.as_ref()
} else {
None
};
// If chrome-cdp failed, we can't evaluate this URL
let gold_standard = match gold_standard {
Some(gs) if !gs.is_empty() => gs,
_ => {
return EngpickerUrlVerdict {
url: result.url.clone(),
tls_client_sufficient: false,
cdp_failed: true,
similarity: None,
reason: "chrome-cdp failed".to_string(),
};
}
};
// If tlsclient failed entirely, it's definitely not enough
let tls_result = match tls_result {
Some(tls) if !tls.is_empty() => tls,
_ => {
return EngpickerUrlVerdict {
url: result.url.clone(),
tls_client_sufficient: false,
cdp_failed: false,
similarity: None,
reason: "tlsclient failed".to_string(),
};
}
};
// Calculate Levenshtein distance and normalize to similarity score
let distance = levenshtein(gold_standard, tls_result);
let max_length = gold_standard.len().max(tls_result.len());
let similarity = if max_length > 0 {
1.0 - (distance as f64 / max_length as f64)
} else {
1.0
};
let tls_client_sufficient = similarity >= similarity_threshold;
let reason = if tls_client_sufficient {
format!("{:.1}% similar - tlsclient captures full content", similarity * 100.0)
} else {
format!("{:.1}% similar - JS rendering likely required", similarity * 100.0)
};
EngpickerUrlVerdict {
url: result.url.clone(),
tls_client_sufficient,
cdp_failed: false,
similarity: Some(similarity),
reason,
}
})
.collect();
let total_urls = url_verdicts.len() as u32;
let cdp_failed_count = url_verdicts.iter().filter(|v| v.cdp_failed).count() as u32;
let tls_client_ok_count = url_verdicts.iter().filter(|v| v.tls_client_sufficient).count() as u32;
let chrome_cdp_required_count = url_verdicts.iter().filter(|v| !v.tls_client_sufficient && !v.cdp_failed).count() as u32;
// Determine final verdict
let verdict = if total_urls == 0 {
EngpickerFinalVerdict::Uncertain
} else {
let cdp_failure_rate = cdp_failed_count as f64 / total_urls as f64;
// If too many CDP failures, we can't make a confident verdict
if cdp_failure_rate > cdp_failure_threshold {
EngpickerFinalVerdict::Uncertain
} else {
// Calculate success rate among URLs where we could actually compare
let comparable_urls = total_urls - cdp_failed_count;
if comparable_urls == 0 {
EngpickerFinalVerdict::Uncertain
} else {
let tls_ok_rate = tls_client_ok_count as f64 / comparable_urls as f64;
if tls_ok_rate >= success_rate_threshold {
EngpickerFinalVerdict::TlsClientOk
} else {
EngpickerFinalVerdict::ChromeCdpRequired
}
}
}
};
Ok(EngpickerVerdict {
url_verdicts,
tls_client_ok_count,
chrome_cdp_required_count,
cdp_failed_count,
total_urls,
verdict,
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
#![deny(clippy::all)]
pub use crate::crawler::*;
pub use crate::engpicker::*;
pub use crate::html::*;
pub use crate::logging::*;
pub use crate::pdf::*;
pub use crate::utils::*;
pub use crate::document::{DocumentConverter, DocumentType};
mod crawler;
mod document;
mod engpicker;
mod html;
mod logging;
mod pdf;
mod utils;
pub use napi::bindgen_prelude::*;
pub use serde::{Deserialize, Serialize};

View File

@@ -0,0 +1,269 @@
use napi_derive::napi;
use serde::Serialize;
use serde_json::Value;
use std::sync::{Arc, Mutex};
use tracing::field::{Field, Visit};
use tracing::Level;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::Layer;
/// Context passed from TypeScript to continue the trace.
#[derive(Clone)]
#[napi(object)]
pub struct NativeContext {
pub scrape_id: String,
pub url: String,
}
/// A single log entry captured during Rust execution.
#[derive(Clone, Debug, Serialize)]
#[napi(object)]
pub struct NativeLogEntry {
pub level: String,
pub target: String,
pub message: String,
pub fields: Value,
pub timestamp_ms: f64,
}
struct LogCollector {
logs: Arc<Mutex<Vec<NativeLogEntry>>>,
}
struct FieldVisitor {
fields: serde_json::Map<String, Value>,
message: Option<String>,
}
impl Visit for FieldVisitor {
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = Some(format!("{:?}", value));
} else {
self
.fields
.insert(field.name().to_string(), Value::String(format!("{:?}", value)));
}
}
fn record_str(&mut self, field: &Field, value: &str) {
if field.name() == "message" {
self.message = Some(value.to_string());
} else {
self
.fields
.insert(field.name().to_string(), Value::String(value.to_string()));
}
}
fn record_i64(&mut self, field: &Field, value: i64) {
self
.fields
.insert(field.name().to_string(), Value::Number(value.into()));
}
fn record_u64(&mut self, field: &Field, value: u64) {
self
.fields
.insert(field.name().to_string(), Value::Number(value.into()));
}
fn record_f64(&mut self, field: &Field, value: f64) {
if let Some(n) = serde_json::Number::from_f64(value) {
self
.fields
.insert(field.name().to_string(), Value::Number(n));
}
}
fn record_bool(&mut self, field: &Field, value: bool) {
self
.fields
.insert(field.name().to_string(), Value::Bool(value));
}
}
impl<S: tracing::Subscriber> Layer<S> for LogCollector {
fn on_event(&self, event: &tracing::Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
let mut visitor = FieldVisitor {
fields: serde_json::Map::new(),
message: None,
};
event.record(&mut visitor);
let level = match *event.metadata().level() {
Level::ERROR => "error",
Level::WARN => "warn",
Level::INFO => "info",
Level::DEBUG => "debug",
Level::TRACE => "trace",
};
let entry = NativeLogEntry {
level: level.to_string(),
target: event.metadata().target().to_string(),
message: visitor.message.unwrap_or_default(),
fields: Value::Object(visitor.fields),
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64() * 1000.0)
.unwrap_or(0.0),
};
if let Ok(mut logs) = self.logs.lock() {
logs.push(entry);
}
}
}
#[derive(Debug)]
pub struct TracingResult<T> {
pub value: T,
pub logs: Vec<NativeLogEntry>,
}
/// Run a closure with tracing enabled, capturing all log events.
/// Wraps the closure in `catch_unwind` for panic safety.
///
/// Returns `TracingResult<napi::Result<T>>` so that logs are **always**
/// available — even when the closure returns `Err` or panics.
pub fn with_native_tracing<T, F>(
ctx: Option<&NativeContext>,
module: &str,
f: F,
) -> TracingResult<napi::Result<T>>
where
F: FnOnce() -> napi::Result<T>,
{
let logs = Arc::new(Mutex::new(Vec::new()));
let collector = LogCollector { logs: logs.clone() };
let subscriber = tracing_subscriber::Registry::default().with(collector);
let result = tracing::subscriber::with_default(subscriber, || {
let _span = match ctx {
Some(c) => tracing::info_span!(
"native",
scrape_id = %c.scrape_id,
url = %c.url,
module = %module,
)
.entered(),
None => tracing::info_span!("native", module = %module).entered(),
};
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(result) => result,
Err(panic_info) => {
let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic_info.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
};
let backtrace = std::backtrace::Backtrace::force_capture();
tracing::error!(
panic = true,
backtrace = %backtrace,
"native panic in {}: {}", module, msg,
);
Err(napi::Error::new(
napi::Status::GenericFailure,
format!("Rust panic in {module}: {msg}\nBacktrace:\n{backtrace}"),
))
}
}
});
let collected = logs.lock().map(|l| l.clone()).unwrap_or_default();
TracingResult {
value: result,
logs: collected,
}
}
/// Append serialized logs to a NAPI error so they survive the FFI boundary.
/// The TS side can extract them from `error.message` via `extractNativeLogs`.
pub fn embed_logs_in_error(err: napi::Error, logs: &[NativeLogEntry]) -> napi::Error {
if logs.is_empty() {
return err;
}
if let Ok(logs_json) = serde_json::to_string(logs) {
napi::Error::new(
err.status,
format!("{}\n__native_logs__:{logs_json}", err.reason),
)
} else {
err
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_collects_logs() {
let traced = with_native_tracing(None, "test", || {
tracing::info!("hello from rust");
Ok(42)
});
let value = traced.value.unwrap();
assert_eq!(value, 42);
assert_eq!(traced.logs.len(), 1);
assert_eq!(traced.logs[0].level, "info");
assert!(traced.logs[0].message.contains("hello from rust"));
}
#[test]
fn test_with_context() {
let ctx = NativeContext {
scrape_id: "test-123".to_string(),
url: "https://example.com".to_string(),
};
let traced = with_native_tracing(Some(&ctx), "pdf", || {
tracing::warn!("something odd");
Ok("ok")
});
assert_eq!(traced.value.unwrap(), "ok");
assert_eq!(traced.logs.len(), 1);
assert_eq!(traced.logs[0].level, "warn");
}
#[test]
fn test_captures_panic_with_logs() {
let traced: TracingResult<napi::Result<()>> = with_native_tracing(None, "test", || {
panic!("test panic");
});
assert!(traced.value.is_err());
let err = traced.value.unwrap_err();
assert!(err.reason.contains("test panic"));
assert!(err.reason.contains("Backtrace"));
// Panic log is preserved even though the closure failed
assert!(!traced.logs.is_empty());
assert_eq!(traced.logs[0].level, "error");
assert!(traced.logs[0].message.contains("test panic"));
}
#[test]
fn test_error_preserves_logs() {
let traced: TracingResult<napi::Result<()>> = with_native_tracing(None, "test", || {
tracing::info!("before error");
Err(napi::Error::new(
napi::Status::GenericFailure,
"test error",
))
});
assert!(traced.value.is_err());
// Logs are preserved even on error paths
assert_eq!(traced.logs.len(), 1);
assert_eq!(traced.logs[0].level, "info");
assert!(traced.logs[0].message.contains("before error"));
}
}

View File

@@ -0,0 +1,118 @@
use napi::bindgen_prelude::*;
use napi_derive::napi;
use pdf_inspector::{PdfOptions, PdfType, process_pdf_with_options as rust_process_pdf};
use crate::logging::{embed_logs_in_error, with_native_tracing, NativeContext, NativeLogEntry};
#[napi(object)]
pub struct PdfProcessResult {
pub pdf_type: String,
pub markdown: Option<String>,
pub page_count: i32,
pub processing_time_ms: f64,
pub pages_needing_ocr: Vec<i32>,
pub title: Option<String>,
pub confidence: f64,
pub is_complex: bool,
pub logs: Vec<NativeLogEntry>,
}
fn pdf_type_str(t: PdfType) -> &'static str {
match t {
PdfType::TextBased => "TextBased",
PdfType::Scanned => "Scanned",
PdfType::ImageBased => "ImageBased",
PdfType::Mixed => "Mixed",
}
}
fn to_napi_result(result: pdf_inspector::PdfProcessResult) -> PdfProcessResult {
PdfProcessResult {
pdf_type: pdf_type_str(result.pdf_type).to_string(),
markdown: result.markdown,
page_count: result.page_count as i32,
processing_time_ms: result.processing_time_ms as f64,
pages_needing_ocr: result.pages_needing_ocr.iter().map(|&p| p as i32).collect(),
title: result.title,
confidence: result.confidence as f64,
is_complex: result.layout.is_complex,
logs: Vec::new(),
}
}
/// Process a PDF file: detect type, extract text + markdown if text-based.
/// When `max_pages` is provided, only the first N pages are extracted.
/// Pass `ctx` (NativeContext) for structured tracing with scrape_id/url.
#[napi]
pub fn process_pdf(
path: String,
max_pages: Option<u32>,
ctx: Option<NativeContext>,
) -> Result<PdfProcessResult> {
let traced = with_native_tracing(ctx.as_ref(), "pdf", || {
tracing::info!(max_pages = ?max_pages, "starting PDF processing");
let opts = match max_pages {
Some(n) if n > 0 => PdfOptions::new().pages(1..=n),
_ => PdfOptions::new(),
};
let result = rust_process_pdf(&path, opts).map_err(|e| {
tracing::error!(error = %e, "PDF processing failed");
Error::new(Status::GenericFailure, format!("Failed to process PDF: {e}"))
})?;
tracing::info!(
pdf_type = pdf_type_str(result.pdf_type),
page_count = result.page_count,
confidence = %result.confidence,
is_complex = result.layout.is_complex,
"PDF processing complete"
);
Ok(to_napi_result(result))
});
match traced.value {
Ok(mut result) => {
result.logs = traced.logs;
Ok(result)
}
Err(err) => Err(embed_logs_in_error(err, &traced.logs)),
}
}
/// Fast metadata-only detection: page count, title, type, confidence.
/// Skips text extraction, markdown generation, and layout analysis.
/// Pass `ctx` (NativeContext) for structured tracing with scrape_id/url.
#[napi]
pub fn detect_pdf(
path: String,
ctx: Option<NativeContext>,
) -> Result<PdfProcessResult> {
let traced = with_native_tracing(ctx.as_ref(), "pdf", || {
tracing::info!("starting PDF detection");
let result = rust_process_pdf(&path, PdfOptions::detect_only()).map_err(|e| {
tracing::error!(error = %e, "PDF detection failed");
Error::new(Status::GenericFailure, format!("Failed to detect PDF: {e}"))
})?;
tracing::info!(
pdf_type = pdf_type_str(result.pdf_type),
page_count = result.page_count,
confidence = %result.confidence,
"PDF detection complete"
);
Ok(to_napi_result(result))
});
match traced.value {
Ok(mut result) => {
result.logs = traced.logs;
Ok(result)
}
Err(err) => Err(embed_logs_in_error(err, &traced.logs)),
}
}

View File

@@ -0,0 +1,5 @@
use napi::bindgen_prelude::*;
pub fn to_napi_err<E: std::fmt::Display>(error: E) -> Error {
Error::new(Status::GenericFailure, error.to_string())
}