Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,9 @@ Only documents that need OCR leave the machine, and the whole document goes, sin

| Format | Extensions |
| ---------------- | ---------------------------------------------------------- |
| Word | `.doc`, `.docx`, `.docm` |
| Word | `.doc`, `.docx`, `.docm`, `.wps` |
| PowerPoint | `.ppt`, `.pps`, `.pot`, `.pptx`, `.pptm`, `.ppsx`, `.ppsm` |
| Excel | `.xls`, `.xlsx`, `.xlsm`, `.xlsb` |
| Excel | `.xls`, `.xlsx`, `.xlsm`, `.xlsb`, `.et` |
| OpenDocument | `.odt`, `.ods`, `.odp` |
| Rich Text Format | `.rtf` |
| EPUB | `.epub` |
Expand Down
19 changes: 13 additions & 6 deletions python/anydoc/__init__.py → python/anydoc_sznb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from pathlib import Path
from typing import Literal, get_args

from anydoc._anydoc import (
from anydoc_sznb._anydoc import (
Asset,
Block,
Cell,
Expand All @@ -35,8 +35,9 @@
format_from_path,
to_document,
)
from anydoc._anydoc import to_markdown as _to_markdown
from anydoc._anydoc import to_markdown_bytes as _to_markdown_bytes
from anydoc_sznb._anydoc import to_markdown as _to_markdown
from anydoc_sznb._anydoc import to_markdown_bytes as _to_markdown_bytes
from anydoc_sznb._anydoc import to_text_bytes

Format = Literal[
"doc", "docx", "odt", "pdf", "ppt", "pptx", "rtf", "epub", "xlsx", "ods", "odp", "csv"
Expand Down Expand Up @@ -91,18 +92,23 @@ def to_markdown_bytes(
data: "bytes | bytearray",
format: "Format | None" = None,
*,
html_tables: bool = True,
ocr: Ocr = "reject",
parse_mode: ParseMode = "auto",
api_key: "str | None" = None,
api_url: "str | None" = None,
) -> str:
"""Convert an in-memory document to Markdown. Without a format, it is
detected from the content, which signature-less formats (CSV) have to
name explicitly. `ocr`, `parse_mode`, `api_key` and `api_url` are as
for `to_markdown`."""
name explicitly.

Fork (sznb): `html_tables` selects the table form — `True` (default)
renders HTML `<table>` carrying merged-cell colspan/rowspan; `False`
renders GFM pipe tables (no span syntax). `ocr`, `parse_mode`,
`api_key` and `api_url` are as for `to_markdown`."""
_check_parse_mode(parse_mode)
try:
return _to_markdown_bytes(data, format)
return _to_markdown_bytes(data, format, html_tables=html_tables)
except NeedsOcrError:
if ocr != "hosted":
raise
Expand Down Expand Up @@ -229,4 +235,5 @@ def _version() -> str:
"to_document",
"to_markdown",
"to_markdown_bytes",
"to_text_bytes",
]
19 changes: 16 additions & 3 deletions python/anydoc/_anydoc.pyi → python/anydoc_sznb/_anydoc.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,23 @@ def to_markdown(path: str | os.PathLike[str]) -> str:
file content; the extension is the fallback for signature-less formats
(CSV) and unrecognizable containers."""

def to_markdown_bytes(data: bytes | bytearray, format: Format | None = None) -> str:
def to_markdown_bytes(
data: bytes | bytearray, format: Format | None = None, *, html_tables: bool = True
) -> str:
"""Convert an in-memory document to Markdown. Without a format, it is
detected from the content, which signature-less formats (CSV) have to
name explicitly."""
name explicitly.

Fork (sznb): `html_tables` selects the table form — HTML `<table>` with
merged-cell colspan/rowspan (default) or GFM pipe tables."""

def to_text_bytes(data: bytes | bytearray, format: Format | None = None) -> str:
"""Convert an in-memory document to plain text (no Markdown syntax;
tables render as tab-joined rows carrying merged content once). Format
handling as for `to_markdown_bytes`.

Unsupported for `pdf`: PDF conversion produces Markdown directly and has
no document-model form."""

def to_document(data: bytes | bytearray, format: Format | None = None) -> Document:
"""Parse an in-memory document into the document model, which also
Expand Down Expand Up @@ -159,7 +172,7 @@ class ImageSource:

@final
class List:
marker: Literal["bullet", "decimal", "lower_alpha", "upper_alpha", "lower_roman", "upper_roman"]
marker: Literal["bullet", "decimal", "lower_alpha", "upper_alpha", "lower_roman", "upper_roman", "chinese_counting", "chinese_legal"]
"""The marker family the list uses in the source document."""
start: int
"""Ordinal the first item counts from."""
Expand Down
File renamed without changes.
4 changes: 2 additions & 2 deletions python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "maturin"
# The distribution installs as `firecrawl-anydoc` (the bare `anydoc` name on
# PyPI is held by an unrelated package) and imports as `anydoc`.
[project]
name = "firecrawl-anydoc"
name = "firecrawl-anydoc-sznb"
description = "Convert documents (doc, docx, odt, rtf, epub, pdf, presentations, spreadsheets, csv) to GitHub-Flavored Markdown"
readme = "README.md"
license = "MIT"
Expand All @@ -27,4 +27,4 @@ Repository = "https://github.com/firecrawl/anydoc"
Issues = "https://github.com/firecrawl/anydoc/issues"

[tool.maturin]
module-name = "anydoc._anydoc"
module-name = "anydoc_sznb._anydoc"
28 changes: 15 additions & 13 deletions python/src/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use pyo3::types::{PyBytes, PyList};

use anydoc::model;

#[pyclass(frozen, get_all, module = "anydoc")]
#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct Document {
/// list[Block]
blocks: Py<PyList>,
Expand All @@ -21,7 +21,7 @@ pub struct Document {
assets: Py<PyList>,
}

#[pyclass(frozen, get_all, module = "anydoc")]
#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct Block {
/// heading, paragraph, list, table, block_quote, code_block, rule, or
/// math.
Expand Down Expand Up @@ -86,7 +86,7 @@ fn block(py: Python<'_>, block: model::Block) -> PyResult<Block> {
})
}

#[pyclass(frozen, get_all, module = "anydoc")]
#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct Inline {
/// text, link, image, anchor (a zero-width marker for an internal link
/// target at this position), note_ref, line_break, or math.
Expand Down Expand Up @@ -156,7 +156,7 @@ fn inline(py: Python<'_>, inline: model::Inline) -> PyResult<Inline> {
}

/// Fully resolved character style.
#[pyclass(frozen, get_all, module = "anydoc")]
#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct Style {
bold: bool,
italic: bool,
Expand All @@ -170,7 +170,7 @@ impl From<model::Style> for Style {
}
}

#[pyclass(frozen, get_all, module = "anydoc")]
#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct LinkTarget {
/// external (absolute URL with a scheme), relative (scheme-less relative
/// reference, preserved as written), or anchor (internal target: a
Expand All @@ -191,7 +191,7 @@ impl From<model::LinkTarget> for LinkTarget {
}
}

#[pyclass(frozen, get_all, module = "anydoc")]
#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct ImageSource {
/// external (absolute URL with a scheme), asset (embedded image, carried
/// in `Document.assets`), or unavailable (no usable source: the image's
Expand Down Expand Up @@ -220,7 +220,7 @@ impl From<model::ImageSource> for ImageSource {
}
}

#[pyclass(frozen, get_all, module = "anydoc")]
#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct List {
/// The marker family the list uses in the source document: bullet,
/// decimal, lower_alpha, upper_alpha, lower_roman, or upper_roman.
Expand All @@ -239,12 +239,14 @@ fn list(py: Python<'_>, list: model::List) -> PyResult<List> {
model::MarkerKind::UpperAlpha => "upper_alpha",
model::MarkerKind::LowerRoman => "lower_roman",
model::MarkerKind::UpperRoman => "upper_roman",
model::MarkerKind::ChineseCounting => "chinese_counting",
model::MarkerKind::ChineseLegal => "chinese_legal",
};
let items = list.items.into_iter().map(|item| list_item(py, item));
Ok(List { marker, start: list.start, items: pylist(py, items)? })
}

#[pyclass(frozen, get_all, module = "anydoc")]
#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct ListItem {
/// list[Block]
blocks: Py<PyList>,
Expand All @@ -261,7 +263,7 @@ fn list_item(py: Python<'_>, item: model::ListItem) -> PyResult<ListItem> {
/// Canonical table grid: every logical grid position appears exactly once.
/// Content and spans live on the origin slot, and each position a span covers
/// holds a `covered` slot pointing back at that origin.
#[pyclass(frozen, get_all, module = "anydoc")]
#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct Table {
/// list[list[CellSlot]]
grid: Py<PyList>,
Expand All @@ -287,7 +289,7 @@ fn table(py: Python<'_>, table: model::Table) -> PyResult<Table> {
})
}

#[pyclass(frozen, get_all, module = "anydoc")]
#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct CellSlot {
/// origin or covered.
kind: &'static str,
Expand Down Expand Up @@ -316,7 +318,7 @@ fn cell_slot(py: Python<'_>, slot: model::CellSlot) -> PyResult<CellSlot> {
})
}

#[pyclass(frozen, get_all, module = "anydoc")]
#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct Cell {
/// list[Block]
blocks: Py<PyList>,
Expand All @@ -328,7 +330,7 @@ fn cell(py: Python<'_>, cell: model::Cell) -> PyResult<Cell> {
Ok(Cell { blocks: blocks(py, cell.blocks)?, col_span: cell.col_span, row_span: cell.row_span })
}

#[pyclass(frozen, get_all, module = "anydoc")]
#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct Note {
id: String,
/// footnote or endnote.
Expand All @@ -350,7 +352,7 @@ fn note(py: Python<'_>, note: model::Note) -> PyResult<Note> {

/// An embedded binary asset (image, object payload). Bytes are always
/// retained, so a document stays self-contained.
#[pyclass(frozen, get_all, module = "anydoc")]
#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct Asset {
/// Index into `Document.assets`, as referenced by an image source.
id: usize,
Expand Down
35 changes: 26 additions & 9 deletions python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use pyo3::prelude::*;
mod document;

create_exception!(
anydoc,
anydoc_sznb,
ConvertError,
PyException,
"A complete conversion was impossible. Catch this to handle every kind of \
Expand All @@ -18,14 +18,14 @@ create_exception!(
);

create_exception!(
anydoc,
anydoc_sznb,
UnsupportedError,
ConvertError,
"The format is unknown, or cannot be converted at all."
);

create_exception!(
anydoc,
anydoc_sznb,
NeedsOcrError,
ConvertError,
"Pages of a PDF are scanned or image-only and need OCR, which anydoc does \
Expand All @@ -34,7 +34,7 @@ create_exception!(
);

create_exception!(
anydoc,
anydoc_sznb,
MalformedError,
ConvertError,
"The document is structurally unusable: no meaningful content could be \
Expand All @@ -43,22 +43,22 @@ create_exception!(
);

create_exception!(
anydoc,
anydoc_sznb,
EncryptedError,
ConvertError,
"The document is encrypted or password-protected."
);

create_exception!(
anydoc,
anydoc_sznb,
ResourceLimitError,
ConvertError,
"A fixed safety limit was crossed: decompression, nesting depth, node \
count, repeat expansion, or retained asset bytes. `limit` names it."
);

create_exception!(
anydoc,
anydoc_sznb,
MissingPartError,
ConvertError,
"A part required for any meaningful output is absent. `part` names it."
Expand Down Expand Up @@ -171,10 +171,26 @@ fn to_markdown(py: Python<'_>, path: PathBuf) -> PyResult<String> {
/// detected from the content, which signature-less formats (CSV) have to name
/// explicitly.
#[pyfunction]
#[pyo3(signature = (data, format=None, *, html_tables=true))]
fn to_markdown_bytes(
py: Python<'_>,
data: Vec<u8>,
format: Option<&str>,
html_tables: bool,
) -> PyResult<String> {
let format = format.map(parse_format).transpose()?;
py.detach(|| anydoc::to_markdown_bytes_opts(&data, format, html_tables))
.map_err(|e| convert_error(py, e))
}

/// Fork (sznb): convert an in-memory document to plain text (no Markdown
/// syntax; tables render as tab-joined rows). Format handling as for
/// `to_markdown_bytes`; PDF has no document-model form and is unsupported.
#[pyfunction]
#[pyo3(signature = (data, format=None))]
fn to_markdown_bytes(py: Python<'_>, data: Vec<u8>, format: Option<&str>) -> PyResult<String> {
fn to_text_bytes(py: Python<'_>, data: Vec<u8>, format: Option<&str>) -> PyResult<String> {
let format = format.map(parse_format).transpose()?;
py.detach(|| anydoc::to_markdown_bytes(&data, format)).map_err(|e| convert_error(py, e))
py.detach(|| anydoc::to_text_bytes(&data, format)).map_err(|e| convert_error(py, e))
}

/// Parse an in-memory document into the document model, which also carries
Expand Down Expand Up @@ -203,6 +219,7 @@ fn _anydoc(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(format_from_path, m)?)?;
m.add_function(wrap_pyfunction!(to_markdown, m)?)?;
m.add_function(wrap_pyfunction!(to_markdown_bytes, m)?)?;
m.add_function(wrap_pyfunction!(to_text_bytes, m)?)?;
m.add_function(wrap_pyfunction!(to_document, m)?)?;
m.add_class::<document::Asset>()?;
m.add_class::<document::Block>()?;
Expand Down
8 changes: 8 additions & 0 deletions src/formats/doc/lists.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,14 @@ fn marker_for_nfc(nfc: u8) -> Option<MarkerKind> {
3 => Some(MarkerKind::UpperAlpha),
4 => Some(MarkerKind::LowerAlpha),
23 => Some(MarkerKind::Bullet),
// Fork (sznb): CJK counting codes ([MS-OSHARED] MSONFC) render
// 一、二、三 / 壹、贰、叁 instead of degrading to 1、2、3 — WPS-era
// government documents use them heavily (0x27 chineseCountingThousand
// verified in the wild on real .wps files).
0x0A | 0x0B | 0x11 | 0x21 | 0x23 | 0x24 | 0x25 | 0x27 => {
Some(MarkerKind::ChineseCounting)
}
0x10 | 0x22 | 0x26 => Some(MarkerKind::ChineseLegal),
0xFF => None,
_ => Some(MarkerKind::Decimal),
}
Expand Down
26 changes: 25 additions & 1 deletion src/formats/docx/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,16 @@ fn emit_paragraph(kind: ParaKind, pieces: Vec<Piece>, blocks: &mut Vec<Block>, r
}
ParaKind::Plain => {
runs.flush(blocks);
blocks.extend(pieces_into_blocks(pieces));
let mut out = pieces_into_blocks(pieces);
// Fork (sznb): a visually empty paragraph still emits an empty
// block — the source uses blank paragraphs as vertical spacing
// and the original pipeline renders each as a blank line;
// dropping them here lost those line breaks (the renderer folds
// consecutive empties and trims trailing ones).
if out.is_empty() {
out.push(Block::Paragraph(Vec::new()));
}
blocks.extend(out);
}
}
}
Expand Down Expand Up @@ -585,6 +594,21 @@ impl<'a, 'b, 'e> InlineWalker<'a, 'b, 'e> {
// object's identity and payload must win over its preview.
if let Some(ole) = elem.first_descendant(ns::O_VML, "OLEObject") {
let prog_id = ole.attr(ns::O_VML, "ProgID").unwrap_or("object").to_string();
// Fork (sznb): an embedded excel.sheet.8 workbook carries its
// chart's data as a BIFF8 stream — extract the worksheets as
// tables instead of an "Embedded object" marker line (the data
// is the whole point of the chart in these documents).
if prog_id.to_ascii_lowercase().starts_with("excel.")
&& let Some(rel_id) = ole.attr_qualified(ns::R, "id")
&& let Some((_, bytes)) = self.ctx.rel_part(rel_id)?
&& let Some(tables) = super::ole::workbook_tables(&bytes)
{
let blocks: Vec<Block> = tables.into_iter().map(Block::Table).collect();
if !blocks.is_empty() {
self.push_blocks(blocks);
return Ok(());
}
}
let alt = if descr.trim().is_empty() {
format!("Embedded object: {prog_id}")
} else {
Expand Down
Loading