diff --git a/.gitignore b/.gitignore
index c3cb32df..2cdf865c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,3 +16,6 @@ __pycache__/
/fuzz/corpus/
/fuzz/artifacts/
/fuzz/coverage/
+
+# sznb: maturin 本地构建产物
+dist/
diff --git a/Cargo.toml b/Cargo.toml
index 2a334b72..56bec806 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,7 +19,6 @@ include = ["/src", "/examples/convert.rs", "/README.md", "/LICENSE"]
[dev-dependencies]
insta = "1"
-sha2 = "0.11"
[dependencies]
cfb = "0.14.0"
@@ -29,6 +28,8 @@ encoding_rs = "0.8.35"
log = "0.4"
pdf-inspector = "1.14.2"
quick-xml = "0.41.0"
+# Fork (sznb): content-addressed names for extracted images.
+sha2 = "0.11"
zip = { version = "8.6.0", default-features = false, features = ["deflate"] }
[profile.release]
diff --git a/README.md b/README.md
index 2a920ec8..c37372f4 100644
--- a/README.md
+++ b/README.md
@@ -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` |
diff --git a/python/anydoc/__init__.py b/python/anydoc_sznb/__init__.py
similarity index 86%
rename from python/anydoc/__init__.py
rename to python/anydoc_sznb/__init__.py
index 1cd64e73..d861f631 100644
--- a/python/anydoc/__init__.py
+++ b/python/anydoc_sznb/__init__.py
@@ -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,
@@ -17,7 +17,9 @@
ConvertError,
Document,
EncryptedError,
+ ExtractedImage,
ImageSource,
+ ImagesResult,
Inline,
LinkTarget,
List,
@@ -34,9 +36,12 @@
format_from_extension,
format_from_path,
to_document,
+ to_markdown_with_images,
+ to_text_with_images,
)
-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"
@@ -91,6 +96,9 @@ def to_markdown_bytes(
data: "bytes | bytearray",
format: "Format | None" = None,
*,
+ html_tables: bool = True,
+ dedup_cells: bool = True,
+ include_toc: bool = True,
ocr: Ocr = "reject",
parse_mode: ParseMode = "auto",
api_key: "str | None" = None,
@@ -98,11 +106,19 @@ def to_markdown_bytes(
) -> 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 `
` carrying merged-cell colspan/rowspan; `False`
+ renders GFM pipe tables (no span syntax). `dedup_cells` shows merged
+ content once on the origin cell (default) or repeats it in every covered
+ position; `include_toc=False` drops the Word table of contents. `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, dedup_cells=dedup_cells, include_toc=include_toc
+ )
except NeedsOcrError:
if ocr != "hosted":
raise
@@ -206,9 +222,11 @@ def _version() -> str:
"ConvertError",
"Document",
"EncryptedError",
+ "ExtractedImage",
"Format",
"HostedError",
"ImageSource",
+ "ImagesResult",
"Inline",
"LinkTarget",
"List",
@@ -229,4 +247,7 @@ def _version() -> str:
"to_document",
"to_markdown",
"to_markdown_bytes",
+ "to_markdown_with_images",
+ "to_text_bytes",
+ "to_text_with_images",
]
diff --git a/python/anydoc/_anydoc.pyi b/python/anydoc_sznb/_anydoc.pyi
similarity index 68%
rename from python/anydoc/_anydoc.pyi
rename to python/anydoc_sznb/_anydoc.pyi
index ca45a408..81c3e692 100644
--- a/python/anydoc/_anydoc.pyi
+++ b/python/anydoc_sznb/_anydoc.pyi
@@ -66,10 +66,93 @@ 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,
+ dedup_cells: bool = True,
+ include_toc: 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 `
` with
+ merged-cell colspan/rowspan (default) or GFM pipe tables. `dedup_cells`
+ shows merged content once on the origin cell (default) or repeats it in
+ every covered position (spans collapse). `include_toc=False` drops the
+ Word table of contents (`Table of Contents` SDTs and bare `TOC` field
+ results, with the 目录 title before them; docx / doc / wps only)."""
+
+def to_text_bytes(
+ data: bytes | bytearray,
+ format: Format | None = None,
+ *,
+ dedup_cells: bool = True,
+ include_toc: bool = True,
+) -> str:
+ """Convert an in-memory document to plain text (no Markdown syntax;
+ tables render as tab-joined rows carrying merged content once, or in
+ every covered cell with `dedup_cells=False`). `include_toc` as for
+ `to_markdown_bytes`. Format handling as for `to_markdown_bytes`.
+
+ Unsupported for `pdf`: PDF conversion produces Markdown directly and has
+ no document-model form."""
+
+def to_markdown_with_images(
+ data: bytes | bytearray,
+ format: Format | None = None,
+ *,
+ html_tables: bool = True,
+ dedup_cells: bool = True,
+ include_toc: bool = True,
+ min_width: int = 50,
+ min_height: int = 50,
+ placeholder: str = "[IMG::{name}]",
+ name_prefix: str = "img_",
+) -> ImagesResult:
+ """Fork (sznb): `to_markdown_bytes` plus image extraction. Embedded
+ images become placeholder tokens in place (`{name}` = the file name);
+ images under `min_width` x `min_height` pixels are dropped (unknown size
+ is kept); names are `{name_prefix}{sha256[:16]}{ext}`, content-addressed
+ so duplicates collapse. PDF yields its Markdown and no images."""
+
+def to_text_with_images(
+ data: bytes | bytearray,
+ format: Format | None = None,
+ *,
+ dedup_cells: bool = True,
+ include_toc: bool = True,
+ min_width: int = 50,
+ min_height: int = 50,
+ placeholder: str = "[IMG::{name}]",
+ name_prefix: str = "img_",
+) -> ImagesResult:
+ """Fork (sznb): `to_text_bytes` plus image extraction (as for
+ `to_markdown_with_images`). Unsupported for `pdf`."""
+
+@final
+class ExtractedImage:
+ name: str
+ """Content-addressed file name, e.g. `img_3f2a9c0d1b2e4f56.png`."""
+ placeholder: str
+ """The token standing in for the image in the rendered text."""
+ media_type: str
+ width: int | None
+ height: int | None
+ data: bytes
+ occurrences: int
+ """How many places in the document reference it."""
+ origin_part: str
+
+@final
+class ImagesResult:
+ text: str
+ images: list[ExtractedImage]
+ """In order of first appearance."""
+ skipped: int
+ """Image references dropped for being under the size threshold."""
def to_document(data: bytes | bytearray, format: Format | None = None) -> Document:
"""Parse an in-memory document into the document model, which also
@@ -148,18 +231,18 @@ class LinkTarget:
@final
class ImageSource:
- kind: Literal["external", "asset", "unavailable"]
+ kind: Literal["external", "asset", "unavailable", "placeholder"]
"""external: absolute URL with a scheme. asset: embedded image, carried
in `Document.assets`. unavailable: no usable source, only the alt text
- remains."""
+ remains. placeholder (fork): extracted, `url` holds the token."""
url: str | None
- """external."""
+ """external; placeholder: the token."""
asset_id: int | None
"""asset: index into `Document.assets`."""
@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."""
diff --git a/python/anydoc/py.typed b/python/anydoc_sznb/py.typed
similarity index 100%
rename from python/anydoc/py.typed
rename to python/anydoc_sznb/py.typed
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 81885146..c36cc73f 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -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"
@@ -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"
diff --git a/python/src/document.rs b/python/src/document.rs
index f0e2e844..1cc1809e 100644
--- a/python/src/document.rs
+++ b/python/src/document.rs
@@ -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,
@@ -21,7 +21,7 @@ pub struct Document {
assets: Py,
}
-#[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.
@@ -86,7 +86,7 @@ fn block(py: Python<'_>, block: model::Block) -> PyResult {
})
}
-#[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.
@@ -156,7 +156,7 @@ fn inline(py: Python<'_>, inline: model::Inline) -> PyResult {
}
/// Fully resolved character style.
-#[pyclass(frozen, get_all, module = "anydoc")]
+#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct Style {
bold: bool,
italic: bool,
@@ -170,7 +170,7 @@ impl From 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
@@ -191,14 +191,15 @@ impl From 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
/// part is missing or unreadable and it has no URL; only the alt text
- /// remains).
+ /// remains), or placeholder (fork: extracted by `to_*_with_images`,
+ /// `url` holds the literal token).
kind: &'static str,
- /// external.
+ /// external; placeholder: the token.
url: Option,
/// asset: index into `Document.assets`.
asset_id: Option,
@@ -216,11 +217,15 @@ impl From for ImageSource {
model::ImageSource::Unavailable => {
ImageSource { kind: "unavailable", url: None, asset_id: None }
}
+ // Fork (sznb): the placeholder token travels in `url`.
+ model::ImageSource::Placeholder(token) => {
+ ImageSource { kind: "placeholder", url: Some(token), asset_id: None }
+ }
}
}
}
-#[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.
@@ -239,12 +244,14 @@ fn list(py: Python<'_>, list: model::List) -> PyResult {
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,
@@ -261,7 +268,7 @@ fn list_item(py: Python<'_>, item: model::ListItem) -> PyResult {
/// 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,
@@ -287,7 +294,7 @@ fn table(py: Python<'_>, table: model::Table) -> PyResult
{
})
}
-#[pyclass(frozen, get_all, module = "anydoc")]
+#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct CellSlot {
/// origin or covered.
kind: &'static str,
@@ -316,7 +323,7 @@ fn cell_slot(py: Python<'_>, slot: model::CellSlot) -> PyResult {
})
}
-#[pyclass(frozen, get_all, module = "anydoc")]
+#[pyclass(frozen, get_all, module = "anydoc_sznb")]
pub struct Cell {
/// list[Block]
blocks: Py,
@@ -328,7 +335,7 @@ fn cell(py: Python<'_>, cell: model::Cell) -> PyResult {
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.
@@ -350,7 +357,7 @@ fn note(py: Python<'_>, note: model::Note) -> PyResult {
/// 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,
diff --git a/python/src/lib.rs b/python/src/lib.rs
index a38a09de..ff7f333c 100644
--- a/python/src/lib.rs
+++ b/python/src/lib.rs
@@ -5,11 +5,12 @@ use std::path::PathBuf;
use pyo3::create_exception;
use pyo3::exceptions::{PyException, PyValueError};
use pyo3::prelude::*;
+use pyo3::types::{PyBytes, PyList};
mod document;
create_exception!(
- anydoc,
+ anydoc_sznb,
ConvertError,
PyException,
"A complete conversion was impossible. Catch this to handle every kind of \
@@ -18,14 +19,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 \
@@ -34,7 +35,7 @@ create_exception!(
);
create_exception!(
- anydoc,
+ anydoc_sznb,
MalformedError,
ConvertError,
"The document is structurally unusable: no meaningful content could be \
@@ -43,14 +44,14 @@ 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 \
@@ -58,7 +59,7 @@ create_exception!(
);
create_exception!(
- anydoc,
+ anydoc_sznb,
MissingPartError,
ConvertError,
"A part required for any meaningful output is absent. `part` names it."
@@ -170,11 +171,161 @@ fn to_markdown(py: Python<'_>, path: PathBuf) -> PyResult {
/// 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.
+///
+/// Fork (sznb) keywords: `html_tables` (HTML `
` with spans vs GFM),
+/// `dedup_cells` (merged content once vs repeated in every covered cell),
+/// `include_toc` (keep vs drop the Word table of contents).
#[pyfunction]
-#[pyo3(signature = (data, format=None))]
-fn to_markdown_bytes(py: Python<'_>, data: Vec, format: Option<&str>) -> PyResult {
+#[pyo3(signature = (data, format=None, *, html_tables=true, dedup_cells=true, include_toc=true))]
+fn to_markdown_bytes(
+ py: Python<'_>,
+ data: Vec,
+ format: Option<&str>,
+ html_tables: bool,
+ dedup_cells: bool,
+ include_toc: bool,
+) -> PyResult {
+ let format = format.map(parse_format).transpose()?;
+ let opts = anydoc::ConvertOptions { html_tables, dedup_cells, include_toc };
+ py.detach(|| anydoc::to_markdown_bytes_with(&data, format, opts))
+ .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.
+/// Fork (sznb) keywords: `dedup_cells`, `include_toc` (as for
+/// `to_markdown_bytes`).
+#[pyfunction]
+#[pyo3(signature = (data, format=None, *, dedup_cells=true, include_toc=true))]
+fn to_text_bytes(
+ py: Python<'_>,
+ data: Vec,
+ format: Option<&str>,
+ dedup_cells: bool,
+ include_toc: bool,
+) -> PyResult {
+ let format = format.map(parse_format).transpose()?;
+ let opts = anydoc::ConvertOptions { dedup_cells, include_toc, ..Default::default() };
+ py.detach(|| anydoc::to_text_bytes_with(&data, format, opts)).map_err(|e| convert_error(py, e))
+}
+
+/// Fork (sznb): one image extracted by `to_markdown_with_images` /
+/// `to_text_with_images`.
+#[pyclass(frozen, get_all, module = "anydoc_sznb")]
+pub struct ExtractedImage {
+ /// Content-addressed file name, e.g. `img_3f2a9c0d1b2e4f56.png`.
+ name: String,
+ /// The token standing in for the image in the rendered text.
+ placeholder: String,
+ /// MIME type (sniffed from the bytes when recognizable).
+ media_type: String,
+ /// Pixel width, `None` when the header gives none.
+ width: Option,
+ /// Pixel height, `None` when the header gives none.
+ height: Option,
+ /// The payload, exactly as embedded.
+ data: Py,
+ /// How many places in the document reference it.
+ occurrences: usize,
+ /// Package part or stream the first copy came from.
+ origin_part: String,
+}
+
+/// Fork (sznb): rendered text plus the images its placeholders name.
+#[pyclass(frozen, get_all, module = "anydoc_sznb")]
+pub struct ImagesResult {
+ /// The Markdown or plain text, placeholders in place.
+ text: String,
+ /// Extracted images, in order of first appearance.
+ images: Py,
+ /// Image references dropped for being under the size threshold.
+ skipped: usize,
+}
+
+fn images_result(
+ py: Python<'_>,
+ (text, report): (String, anydoc::ImageReport),
+) -> PyResult {
+ let images = report
+ .images
+ .into_iter()
+ .map(|i| {
+ Py::new(
+ py,
+ ExtractedImage {
+ data: PyBytes::new(py, &i.bytes).unbind(),
+ name: i.name,
+ placeholder: i.placeholder,
+ media_type: i.media_type,
+ width: i.width,
+ height: i.height,
+ occurrences: i.occurrences,
+ origin_part: i.origin_part,
+ },
+ )
+ })
+ .collect::>>()?;
+ let images = PyList::new(py, images)?.unbind();
+ Ok(ImagesResult { text, images, skipped: report.skipped })
+}
+
+/// Fork (sznb): `to_markdown_bytes` plus image extraction. Embedded images
+/// become placeholder tokens (`placeholder`, `{name}` = file name) in place;
+/// images under `min_width` x `min_height` pixels are dropped (size unknown
+/// = kept); names are `{name_prefix}{sha256[:16]}{ext}` (content-addressed,
+/// so duplicates collapse). PDF yields its Markdown and no images.
+#[pyfunction]
+#[pyo3(signature = (data, format=None, *, html_tables=true, dedup_cells=true, include_toc=true,
+ min_width=50, min_height=50, placeholder="[IMG::{name}]".to_string(),
+ name_prefix="img_".to_string()))]
+#[allow(clippy::too_many_arguments)]
+fn to_markdown_with_images(
+ py: Python<'_>,
+ data: Vec,
+ format: Option<&str>,
+ html_tables: bool,
+ dedup_cells: bool,
+ include_toc: bool,
+ min_width: u32,
+ min_height: u32,
+ placeholder: String,
+ name_prefix: String,
+) -> PyResult {
+ let format = format.map(parse_format).transpose()?;
+ let opts = anydoc::ConvertOptions { html_tables, dedup_cells, include_toc };
+ let images = anydoc::ImageOptions { min_width, min_height, placeholder, name_prefix };
+ let out = py
+ .detach(|| anydoc::to_markdown_with_images(&data, format, opts, &images))
+ .map_err(|e| convert_error(py, e))?;
+ images_result(py, out)
+}
+
+/// Fork (sznb): `to_text_bytes` plus image extraction (as for
+/// `to_markdown_with_images`). Unsupported for `pdf`.
+#[pyfunction]
+#[pyo3(signature = (data, format=None, *, dedup_cells=true, include_toc=true,
+ min_width=50, min_height=50, placeholder="[IMG::{name}]".to_string(),
+ name_prefix="img_".to_string()))]
+#[allow(clippy::too_many_arguments)]
+fn to_text_with_images(
+ py: Python<'_>,
+ data: Vec,
+ format: Option<&str>,
+ dedup_cells: bool,
+ include_toc: bool,
+ min_width: u32,
+ min_height: u32,
+ placeholder: String,
+ name_prefix: String,
+) -> PyResult {
let format = format.map(parse_format).transpose()?;
- py.detach(|| anydoc::to_markdown_bytes(&data, format)).map_err(|e| convert_error(py, e))
+ let opts = anydoc::ConvertOptions { dedup_cells, include_toc, ..Default::default() };
+ let images = anydoc::ImageOptions { min_width, min_height, placeholder, name_prefix };
+ let out = py
+ .detach(|| anydoc::to_text_with_images(&data, format, opts, &images))
+ .map_err(|e| convert_error(py, e))?;
+ images_result(py, out)
}
/// Parse an in-memory document into the document model, which also carries
@@ -203,7 +354,12 @@ 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_function(wrap_pyfunction!(to_markdown_with_images, m)?)?;
+ m.add_function(wrap_pyfunction!(to_text_with_images, m)?)?;
+ m.add_class::()?;
+ m.add_class::()?;
m.add_class::()?;
m.add_class::()?;
m.add_class::()?;
diff --git a/src/formats/doc/lists.rs b/src/formats/doc/lists.rs
index d0648216..1495d9cb 100644
--- a/src/formats/doc/lists.rs
+++ b/src/formats/doc/lists.rs
@@ -265,6 +265,14 @@ fn marker_for_nfc(nfc: u8) -> Option {
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),
}
diff --git a/src/formats/doc/mod.rs b/src/formats/doc/mod.rs
index 085a2c5f..d600aab6 100644
--- a/src/formats/doc/mod.rs
+++ b/src/formats/doc/mod.rs
@@ -18,7 +18,7 @@ use crate::shared::assets::AssetSink;
use crate::shared::binary::{get_u16, get_u32, read_ole_stream, utf16le_units};
use crate::shared::blockstyle::StyledRun;
use crate::shared::delta::rebase_emphasis;
-use crate::shared::fields::{FieldFrame, field_result};
+use crate::shared::fields::{FieldFrame, TocTracker, drop_toc_title, field_result};
use crate::shared::grid::{CellProp, GridRow, build_edge_table};
use crate::shared::list::MarkerKind;
use crate::shared::list::{ListEntry, ListKey, flush_list};
@@ -28,7 +28,14 @@ use std::collections::HashMap;
use std::io::Cursor;
use stsh::Stylesheet;
+#[cfg(test)]
pub fn parse(bytes: &[u8]) -> Result {
+ parse_with(bytes, true)
+}
+
+/// Fork (sznb): `include_toc = false` drops the result paragraphs of a `TOC`
+/// field (and the 目录 / Contents title right before it).
+pub fn parse_with(bytes: &[u8], include_toc: bool) -> Result {
let cursor = Cursor::new(bytes);
let mut ole = cfb::CompoundFile::open(cursor)
.map_err(|e| ConvertError::malformed(format!("not an OLE2 compound file: {e}")))?;
@@ -113,6 +120,7 @@ pub fn parse(bytes: &[u8]) -> Result {
counters: std::cell::RefCell::new(Counters::default()),
data,
assets: std::cell::RefCell::new(AssetSink::new()),
+ skip_toc: !include_toc,
};
let blocks = assembler.build_blocks(0, main_end)?;
let mut notes = Vec::new();
@@ -599,6 +607,8 @@ struct Assembler {
/// The Data stream, for `sprmCPicLocation` picture payloads.
data: Vec,
assets: std::cell::RefCell,
+ /// Fork (sznb): drop the table of contents (the service's `content=false`).
+ skip_toc: bool,
}
/// A paragraph's resolved properties: the style chain's contribution merged
@@ -700,6 +710,7 @@ impl Assembler {
let mut row: Vec> = Vec::new();
let mut table_rows: Vec = Vec::new();
let mut para = ParaBuilder::new();
+ let mut toc = TocTracker::default();
let mut i = lo;
while i < hi.min(self.text.chars.len()) {
@@ -715,6 +726,26 @@ impl Assembler {
let pap = self.effective_pap(fc, i);
let inlines = std::mem::replace(&mut para, ParaBuilder::new()).finish();
let is_cell_mark = c == '\u{7}';
+ // Fork (sznb): content=false — a TOC field's result
+ // paragraphs (top level) go, with the title right before.
+ if self.skip_toc {
+ let (in_toc, started) = toc.finish_paragraph();
+ if in_toc && !(pap.effective.in_table.unwrap_or(false) || is_cell_mark) {
+ Self::flush_table(
+ &mut blocks,
+ &mut table_rows,
+ &mut row,
+ &mut cell_blocks,
+ )?;
+ if started {
+ styled.flush(&mut blocks);
+ flush_list(&mut blocks, &mut list_run);
+ drop_toc_title(&mut blocks);
+ }
+ i += 1;
+ continue;
+ }
+ }
if pap.effective.in_table.unwrap_or(false) || is_cell_mark {
// A table is a hard boundary for top-level list and
// styled runs, even while its rows are accumulated.
@@ -771,9 +802,18 @@ impl Assembler {
}
}
'\u{b}' => para.push_inline(Inline::LineBreak),
- '\u{13}' => para.field_begin(),
- '\u{14}' => para.field_separate(),
- '\u{15}' => para.field_end(),
+ '\u{13}' => {
+ toc.begin();
+ para.field_begin();
+ }
+ '\u{14}' => {
+ toc.separate();
+ para.field_separate();
+ }
+ '\u{15}' => {
+ toc.end();
+ para.field_end();
+ }
'\t' => {
let style = self.char_style(fc, i);
para.push_char(' ', style);
@@ -792,6 +832,10 @@ impl Assembler {
'\u{2}' | '\u{5}' | '\u{8}' | '\u{1f}' => {}
c if c.is_control() => {}
c => {
+ if toc.reading_instr() {
+ let mut buf = [0u8; 4];
+ toc.instr_text(c.encode_utf8(&mut buf));
+ }
let style = self.char_style(fc, i);
para.push_char(c, style);
}
@@ -1007,7 +1051,7 @@ impl Assembler {
return Ok(());
}
let rows = std::mem::take(table_rows).into_iter().map(DocRow::into_grid_row).collect();
- if let Some(block) = build_edge_table(rows)? {
+ if let Some(block) = build_edge_table(rows, true)? {
blocks.push(block);
}
Ok(())
@@ -1025,6 +1069,10 @@ impl DocRow {
fn into_grid_row(self) -> GridRow {
let header = self.tap.as_ref().is_some_and(|t| t.header);
let tap = self.tap.unwrap_or_default();
+ // Fork (sznb): rgdxaCenter[0] is the row's left edge - indented rows
+ // keep their column offset instead of collapsing to column 0.
+ // Rows without a TAP have no boundaries -> None (column 0).
+ let left = tap.boundaries.first().map(|&b| b as i64);
let cells = self
.cells
.into_iter()
@@ -1045,7 +1093,7 @@ impl DocRow {
(blocks, prop)
})
.collect();
- GridRow { cells, header }
+ GridRow { cells, header, left }
}
}
diff --git a/src/formats/docx/content.rs b/src/formats/docx/content.rs
index c5a7c18b..fcd764ed 100644
--- a/src/formats/docx/content.rs
+++ b/src/formats/docx/content.rs
@@ -11,7 +11,7 @@ use crate::package::relationships::{RelTarget, Relationships, TargetMode, rel_ta
use crate::package::xml::{Element, ns};
use crate::shared::blockstyle::{BlockStyle, StyledRun};
use crate::shared::delta::rebase_emphasis;
-use crate::shared::fields::{FieldFrame, field_result};
+use crate::shared::fields::{FieldFrame, TocTracker, drop_toc_title, field_result};
use crate::shared::header::resolve_header_rows;
use crate::shared::list::{ListEntry, ListKey, flush_list};
use crate::shared::math::{omath_para_to_tex, omath_to_tex};
@@ -45,6 +45,8 @@ pub(super) struct Ctx<'a, 'b> {
pub numbering: &'b Numbering,
pub counters: &'b RefCell,
pub assets: &'b RefCell,
+ /// Fork (sznb): drop the table of contents (the service's `content=false`).
+ pub skip_toc: bool,
}
impl<'a, 'b> Ctx<'a, 'b> {
@@ -59,6 +61,7 @@ impl<'a, 'b> Ctx<'a, 'b> {
numbering: self.numbering,
counters: self.counters,
assets: self.assets,
+ skip_toc: self.skip_toc,
}
}
@@ -112,8 +115,57 @@ pub(super) enum ParaKind {
pub(super) struct Runs {
list: Vec,
styled: StyledRun,
+ /// Fork (sznb): complex-field state across sibling paragraphs, tracked
+ /// only when the TOC is skipped (a `TOC` field spans many paragraphs).
+ toc: TocTracker,
}
+/// Fork (sznb): feed one paragraph's field characters (document order) to
+/// the tracker; returns `(in_toc, toc_started_here)`.
+fn paragraph_in_toc(toc: &mut TocTracker, p: &Element) -> (bool, bool) {
+ let mut stack: Vec<&Element> = vec![p];
+ while let Some(e) = stack.pop() {
+ if e.ns.as_deref() == Some(ns::W) {
+ match e.local.as_str() {
+ "fldChar" => {
+ match e.attr(ns::W, "fldCharType") {
+ Some("begin") => toc.begin(),
+ Some("separate") => toc.separate(),
+ Some("end") => toc.end(),
+ _ => {}
+ }
+ continue;
+ }
+ "instrText" => {
+ toc.instr_text(&e.text());
+ continue;
+ }
+ "fldSimple" => {
+ if let Some(instr) = e.attr(ns::W, "instr") {
+ toc.simple(instr);
+ }
+ }
+ // Text boxes carry their own paragraphs and field chains.
+ "txbxContent" => continue,
+ _ => {}
+ }
+ }
+ let kids: Vec<&Element> = e.child_elems().collect();
+ stack.extend(kids.into_iter().rev());
+ }
+ toc.finish_paragraph()
+}
+
+/// A `Table of Contents` building-block SDT (`w:docPartGallery`).
+fn is_toc_sdt(sdt: &Element) -> bool {
+ sdt.find(ns::W, "sdtPr")
+ .and_then(|pr| pr.find(ns::W, "docPartObj"))
+ .and_then(|o| o.find(ns::W, "docPartGallery"))
+ .and_then(|g| g.attr(ns::W, "val"))
+ .is_some_and(|v| v.trim().to_ascii_lowercase().starts_with("table of contents"))
+}
+
+
impl Runs {
fn flush(&mut self, blocks: &mut Vec) {
self.styled.flush(blocks);
@@ -159,6 +211,18 @@ fn collect_blocks(
}
match child.local.as_str() {
"p" => {
+ if ctx.skip_toc {
+ let (in_toc, started) = paragraph_in_toc(&mut runs.toc, child);
+ if in_toc {
+ // Fork (sznb): content=false — the TOC's result
+ // paragraphs go, and so does the title right before.
+ if started {
+ runs.flush(blocks);
+ drop_toc_title(blocks);
+ }
+ continue;
+ }
+ }
let (kind, pieces) = parse_paragraph(child, ctx)?;
emit_paragraph(kind, pieces, blocks, runs);
}
@@ -167,6 +231,13 @@ fn collect_blocks(
blocks.extend(parse_table(child, ctx)?);
}
"sdt" => {
+ // Fork (sznb): content=false drops a TOC building block whole,
+ // with the title right before it (outside the SDT).
+ if ctx.skip_toc && is_toc_sdt(child) {
+ runs.flush(blocks);
+ drop_toc_title(blocks);
+ continue;
+ }
if let Some(content) = child.find(ns::W, "sdtContent") {
collect_blocks(content, ctx, blocks, runs)?;
}
@@ -225,7 +296,16 @@ fn emit_paragraph(kind: ParaKind, pieces: Vec, blocks: &mut Vec, 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);
}
}
}
@@ -253,7 +333,15 @@ fn parse_paragraph(p: &Element, ctx: &Ctx) -> Result<(ParaKind, Vec), Con
// Numbering resolves independently of heading semantics: a numbered
// heading advances its sequence and keeps its number visible.
- let numbering = resolve_numbering(ppr, pstyle_id, ctx)?;
+ // Fork (sznb): an empty paragraph that only carries a section break
+ // (`w:pPr/w:sectPr`) takes no number. Word and LibreOffice show no
+ // label on it and do not advance the list, so a numbered style on that
+ // carrier (typically Heading 1 right before the first chapter) must not
+ // consume `第1章` and shift every chapter by one.
+ let section_carrier =
+ ppr.is_some_and(|pr| pr.find(ns::W, "sectPr").is_some()) && !has_visible_content(p);
+ let numbering =
+ if section_carrier { None } else { resolve_numbering(ppr, pstyle_id, ctx)? };
// Toggle properties: the paragraph style chain's true-parity flips the
// docDefaults base. Headings use the same resolution as body text.
@@ -287,6 +375,23 @@ fn parse_paragraph(p: &Element, ctx: &Ctx) -> Result<(ParaKind, Vec), Con
Ok((kind, walker.finish()))
}
+/// Fork (sznb): whether a paragraph renders anything besides its mark —
+/// non-empty text, a tab, a symbol, or a drawing/picture/OLE object. The
+/// `pPr` subtree is skipped (its `w:tabs/w:tab` are tab stops, not content).
+fn has_visible_content(p: &Element) -> bool {
+ let visible = |e: &Element| {
+ (e.is(ns::W, "t") && !e.text().trim().is_empty())
+ || e.is(ns::W, "tab")
+ || e.is(ns::W, "sym")
+ || e.is(ns::W, "drawing")
+ || e.is(ns::W, "pict")
+ || e.is(ns::W, "object")
+ };
+ p.child_elems()
+ .filter(|c| !c.is(ns::W, "pPr"))
+ .any(|c| visible(c) || c.descendant_elems().any(visible))
+}
+
/// Resolve a paragraph's effective numbering per ECMA-376: the direct
/// `numPr` children are tri-state and merge property-by-property with the
/// style-inherited `numPr` (a missing `numId`/`ilvl` inherits; an explicit
@@ -585,6 +690,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 = 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 {
@@ -625,20 +745,19 @@ impl<'a, 'b, 'e> InlineWalker<'a, 'b, 'e> {
self.ctx.assets,
rel_id,
)?;
- match source {
- Some(source) => self.push(Inline::Image { alt: descr, source }),
- None => {
- if !descr.trim().is_empty() {
- self.push(Inline::Image { alt: descr, source: ImageSource::Unavailable });
- }
- }
+ // Fork (sznb): a bitmap's `descr` is not body text. Word/WPS fill
+ // it with the inserted file's name (「图片1」「捕获」 from
+ // 捕获.PNG), and rendering it as the image's alt text spliced
+ // those words into paragraphs. The original pipeline emits
+ // nothing for images; keep the asset, drop the alt text.
+ if let Some(source) = source {
+ self.push(Inline::Image { alt: String::new(), source });
}
return Ok(());
}
- if !descr.trim().is_empty() {
- self.push(Inline::Image { alt: descr, source: ImageSource::Unavailable });
- }
+ // Fork (sznb): a drawing with neither text, chart, OLE nor bitmap —
+ // likewise no `descr` text (see above).
Ok(())
}
@@ -807,6 +926,7 @@ pub(super) fn parse_table(tbl: &Element, ctx: &Ctx) -> Result, Conver
.count();
let mut builder = GridBuilder::new();
+ builder.keep_empty_rows();
for row in &matrix {
builder.next_row();
for tc in row {
diff --git a/src/formats/docx/mod.rs b/src/formats/docx/mod.rs
index eee5317e..549e70bd 100644
--- a/src/formats/docx/mod.rs
+++ b/src/formats/docx/mod.rs
@@ -5,6 +5,7 @@
mod content;
mod numbering;
+mod ole;
mod styles;
use crate::error::ConvertError;
@@ -17,7 +18,15 @@ use content::Ctx;
use numbering::Counters;
use std::cell::RefCell;
+#[cfg(test)]
pub fn parse(bytes: &[u8]) -> Result {
+ parse_with(bytes, true)
+}
+
+/// Fork (sznb): `include_toc = false` drops the table of contents — a
+/// `Table of Contents` SDT and the result paragraphs of a bare `TOC` field
+/// (plus the 目录 / Contents title right before it).
+pub fn parse_with(bytes: &[u8], include_toc: bool) -> Result {
let pkg = match Package::open(bytes) {
Ok(p) => p,
Err(e) => return Err(crate::package::archive::probe_ole(bytes).unwrap_or(e)),
@@ -70,6 +79,7 @@ pub fn parse(bytes: &[u8]) -> Result {
numbering: &numbering,
counters: &counters,
assets: &assets,
+ skip_toc: !include_toc,
};
let blocks = content::parse_blocks(body, &ctx)?;
@@ -330,4 +340,224 @@ mod tests {
.collect();
assert_eq!(headings, vec!["1. Intro", "2. Details"]);
}
+
+ /// Fork (sznb): `(numId, text)` paragraphs over a single-level abstract
+ /// with label `第%1项`; `nums` is the `` list. Returns the text
+ /// output lines.
+ fn shared_abstract_lines(paras: &[(u32, &str)], nums: &str) -> Vec {
+ let body: String = paras
+ .iter()
+ .map(|(num_id, text)| {
+ format!(
+ r#"{text}"#
+ )
+ })
+ .collect();
+ let document = format!(r#"{body}"#);
+ let numbering = format!(
+ r#"
+
+
+
+ {nums}
+ "#
+ );
+ let bytes = docx_parts(&[("word/document.xml", &document), ("word/numbering.xml", &numbering)]);
+ let text = crate::to_text_bytes(&bytes, crate::Format::Docx).unwrap();
+ text.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect()
+ }
+
+ #[test]
+ fn num_ids_sharing_an_abstract_share_one_counter() {
+ // Word/LibreOffice: numIds over the same abstractNum continue one
+ // sequence (a numId is only an instance handle, not a new list).
+ let lines = shared_abstract_lines(
+ &[(1, "丁一"), (2, "丁二"), (1, "丁三")],
+ r#"
+ "#,
+ );
+ let joined = lines.join("\n");
+ for want in ["第1项", "第2项", "第3项"] {
+ assert!(joined.contains(want), "missing {want} in:\n{joined}");
+ }
+ assert!(joined.find("第1项") < joined.find("丁一"));
+ assert!(joined.find("丁一") < joined.find("第2项") && joined.find("第2项") < joined.find("丁二"));
+ assert!(joined.find("丁二") < joined.find("第3项") && joined.find("第3项") < joined.find("丁三"));
+ }
+
+ #[test]
+ fn start_override_restarts_the_shared_counter_once() {
+ // startOverride restarts the shared counter at its value on that
+ // numId's first use; later paragraphs (either numId) continue: 1, 5, 6.
+ let lines = shared_abstract_lines(
+ &[(1, "甲"), (2, "乙"), (1, "丙"), (2, "丁")],
+ r#"
+
+ "#,
+ );
+ let joined = lines.join("\n");
+ let order: Vec = ["第1项", "甲", "第5项", "乙", "第6项", "丙", "第7项", "丁"]
+ .iter()
+ .map(|w| joined.find(w).unwrap_or_else(|| panic!("missing {w} in:\n{joined}")))
+ .collect();
+ assert!(order.windows(2).all(|w| w[0] < w[1]), "wrong order in:\n{joined}");
+ }
+
+ #[test]
+ fn orphan_sub_level_consumes_the_upper_start() {
+ // Fork (sznb): Word/LibreOffice — sub-level items ahead of any
+ // top-level item use up the top level's start, so the first real
+ // top-level item is 2 (技术使用手册: 1.1–1.4 then 「2. 网关接口调用」).
+ let p = |ilvl: u8, text: &str| {
+ format!(
+ r#"{text}"#
+ )
+ };
+ let body = [p(1, "甲"), p(1, "乙"), p(0, "丙"), p(1, "丁")].concat();
+ let document = format!(r#"{body}"#);
+ let numbering = format!(
+ r#"
+
+
+
+
+
+ "#
+ );
+ let bytes = docx_parts(&[("word/document.xml", &document), ("word/numbering.xml", &numbering)]);
+ let text = crate::to_text_bytes(&bytes, crate::Format::Docx).unwrap();
+ let order: Vec = ["1.1", "甲", "1.2", "乙", "第2项", "丙", "2.1", "丁"]
+ .iter()
+ .map(|w| text.find(w).unwrap_or_else(|| panic!("missing {w} in:\n{text}")))
+ .collect();
+ assert!(order.windows(2).all(|w| w[0] < w[1]), "wrong order in:\n{text}");
+ assert!(!text.contains("第1项"), "top level must not restart at 1:\n{text}");
+ }
+
+ #[test]
+ fn empty_section_break_carrier_takes_no_number() {
+ // Fork (sznb): an empty numbered paragraph that only holds a section
+ // break (144pages: Heading 1 + sectPr before 第1章) shows no label
+ // and does not advance the list; a plain empty numbered paragraph
+ // still counts, as in Word/LibreOffice.
+ let num_p = |extra: &str, run: &str| {
+ format!(
+ r#"{extra}{run}"#
+ )
+ };
+ let body = [
+ num_p("", ""),
+ num_p("", "甲"),
+ num_p("", ""),
+ num_p("", "乙"),
+ ]
+ .concat();
+ let document = format!(r#"{body}"#);
+ let numbering = format!(
+ r#"
+
+
+
+
+ "#
+ );
+ let bytes = docx_parts(&[("word/document.xml", &document), ("word/numbering.xml", &numbering)]);
+ let text = crate::to_text_bytes(&bytes, crate::Format::Docx).unwrap();
+ assert!(text.contains("第1章 甲") || text.contains("第1章甲"), "got:\n{text}");
+ assert!(text.contains("第3章 乙") || text.contains("第3章乙"), "got:\n{text}");
+ }
+
+ // ---------- Fork (sznb): include_toc / dedup_cells ----------
+
+ fn run_p(text: &str) -> String {
+ format!(r#"{text}"#)
+ }
+
+ fn opts(dedup_cells: bool, include_toc: bool, html_tables: bool) -> crate::ConvertOptions {
+ crate::ConvertOptions { html_tables, dedup_cells, include_toc }
+ }
+
+ #[test]
+ fn toc_sdt_is_dropped_only_without_include_toc() {
+ let sdt = format!(
+ r#"
+ {}{}"#,
+ run_p("目录"),
+ run_p("第一章 总则 1")
+ );
+ // Custom title outside the SDT (技术使用手册: 「公共应用组件调用目录」).
+ let body = [run_p("封面"), run_p("公共应用组件调用目录"), sdt, run_p("正文开始")].concat();
+ let document = format!(r#"{body}"#);
+ let bytes = docx_parts(&[("word/document.xml", &document)]);
+ let keep = crate::to_text_bytes_with(&bytes, crate::Format::Docx, opts(true, true, true)).unwrap();
+ assert!(keep.contains("第一章 总则 1") && keep.contains("目录"), "got:\n{keep}");
+ let drop = crate::to_text_bytes_with(&bytes, crate::Format::Docx, opts(true, false, true)).unwrap();
+ assert!(!drop.contains("总则") && !drop.contains("目录"), "got:\n{drop}");
+ assert!(keep.contains("公共应用组件调用目录"), "got:\n{keep}");
+ assert!(drop.contains("封面") && drop.contains("正文开始"), "got:\n{drop}");
+ }
+
+ #[test]
+ fn bare_toc_field_paragraphs_and_title_are_dropped() {
+ // TOC field begins in the first entry paragraph and ends in the last
+ // one (Word's bare-field form, not wrapped in an SDT).
+ let fld = |kind: &str| format!(r#""#);
+ let first = format!(
+ r#"{} TOC \o "1-3" \h \z \u {}第1章 概况 3"#,
+ fld("begin"),
+ fld("separate")
+ );
+ let middle = run_p("第2章 需求 9");
+ let last = format!(r#"第3章 方案 20{}"#, fld("end"));
+ // A blank paragraph between title and field (bullet_problem).
+ let blank = "".to_string();
+ let body = [run_p("目 录"), blank, first, middle, last, run_p("第1章 概况正文")].concat();
+ let document = format!(r#"{body}"#);
+ let bytes = docx_parts(&[("word/document.xml", &document)]);
+ let keep = crate::to_markdown_bytes_with(&bytes, crate::Format::Docx, opts(true, true, true)).unwrap();
+ assert!(keep.contains("第2章 需求 9") && keep.contains("目 录"), "got:\n{keep}");
+ let drop = crate::to_markdown_bytes_with(&bytes, crate::Format::Docx, opts(true, false, true)).unwrap();
+ for gone in ["目 录", "概况 3", "需求 9", "方案 20"] {
+ assert!(!drop.contains(gone), "{gone} survived:\n{drop}");
+ }
+ assert!(drop.contains("第1章 概况正文"), "body lost:\n{drop}");
+ }
+
+ fn merged_table_docx() -> Vec {
+ // 3×3: row 0 「甲」 spans two columns; column 0 「乙」 spans rows 1–2.
+ let tc = |pr: &str, text: &str| format!(r#"{pr}{}"#, run_p(text));
+ let rows = [
+ [tc(r#""#, "甲"), tc("", "丙")].concat(),
+ [tc(r#""#, "乙"), tc("", "a"), tc("", "b")].concat(),
+ [tc("", ""), tc("", "c"), tc("", "d")].concat(),
+ ]
+ .map(|r| format!("{r}"))
+ .concat();
+ let grid = r#""#;
+ let document = format!(r#"{grid}{rows}"#);
+ docx_parts(&[("word/document.xml", &document)])
+ }
+
+ #[test]
+ fn dedup_cells_false_repeats_merged_content() {
+ let bytes = merged_table_docx();
+ let count = |s: &str, w: &str| s.matches(w).count();
+ // Markdown, HTML form: spans by default, repeated cells without dedup.
+ let html = crate::to_markdown_bytes_with(&bytes, crate::Format::Docx, opts(true, true, true)).unwrap();
+ assert!(html.contains("colspan") && html.contains("rowspan"), "got:\n{html}");
+ assert_eq!((count(&html, "甲"), count(&html, "乙")), (1, 1), "got:\n{html}");
+ let html = crate::to_markdown_bytes_with(&bytes, crate::Format::Docx, opts(false, true, true)).unwrap();
+ assert!(!html.contains("colspan") && !html.contains("rowspan"), "got:\n{html}");
+ assert_eq!((count(&html, "甲"), count(&html, "乙")), (2, 2), "got:\n{html}");
+ // GFM keeps three columns either way.
+ let gfm = crate::to_markdown_bytes_with(&bytes, crate::Format::Docx, opts(false, true, false)).unwrap();
+ assert_eq!((count(&gfm, "甲"), count(&gfm, "乙")), (2, 2), "got:\n{gfm}");
+ assert!(gfm.lines().all(|l| !l.starts_with('|') || l.matches('|').count() == 4), "got:\n{gfm}");
+ // Plain text.
+ let txt = crate::to_text_bytes_with(&bytes, crate::Format::Docx, opts(true, true, true)).unwrap();
+ assert_eq!((count(&txt, "甲"), count(&txt, "乙")), (1, 1), "got:\n{txt}");
+ let txt = crate::to_text_bytes_with(&bytes, crate::Format::Docx, opts(false, true, true)).unwrap();
+ assert_eq!((count(&txt, "甲"), count(&txt, "乙")), (2, 2), "got:\n{txt}");
+ assert!(txt.contains("甲\t甲\t丙") && txt.contains("乙\tc\td"), "got:\n{txt}");
+ }
}
diff --git a/src/formats/docx/numbering.rs b/src/formats/docx/numbering.rs
index a6334008..a42b35eb 100644
--- a/src/formats/docx/numbering.rs
+++ b/src/formats/docx/numbering.rs
@@ -6,7 +6,7 @@ use crate::error::ConvertError;
use crate::package::xml::{Element, ns};
use crate::shared::list::MarkerKind;
use crate::shared::numbering::{NumberPattern, composite_label, parse_percent_pattern};
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
pub const LEVELS: usize = 9;
@@ -36,6 +36,8 @@ impl Default for LevelDef {
#[derive(Debug, Clone)]
struct AbstractNum {
+ /// `w:abstractNumId`: the identity numbering counters are shared under.
+ id: String,
levels: [LevelDef; LEVELS],
/// Paragraph style each level binds to via `w:lvl > w:pStyle`.
pstyles: [Option; LEVELS],
@@ -46,6 +48,16 @@ struct AbstractNum {
pub struct Instance {
pub levels: [LevelDef; LEVELS],
pstyles: [Option; LEVELS],
+ /// Fork (sznb): id of the resolved abstract (after `numStyleLink`
+ /// indirection). Word/LibreOffice share one counter across every `numId`
+ /// pointing at the same abstractNum -- a `numId` is only a list-instance
+ /// handle -- so counters are keyed by this, not by `numId`.
+ counter_key: String,
+ /// Fork (sznb): `w:lvlOverride > w:startOverride` per level. It restarts
+ /// the shared counter at this value the first time this `numId` is used at
+ /// that level; later paragraphs (of any `numId` on the abstract) continue
+ /// from there (LibreOffice: 1 -> 5 (override) -> 6).
+ start_overrides: [Option; LEVELS],
}
impl Instance {
@@ -89,7 +101,7 @@ pub fn parse(
}
let num_style_link =
abs.find(ns::W, "numStyleLink").and_then(|e| e.attr(ns::W, "val")).map(str::to_string);
- abstracts.insert(id, AbstractNum { levels, pstyles, num_style_link });
+ abstracts.insert(id, AbstractNum { id: id.to_string(), levels, pstyles, num_style_link });
}
// First pass: direct abstract references per numId.
@@ -112,6 +124,7 @@ pub fn parse(
};
let mut levels = abs.levels.clone();
let mut pstyles = abs.pstyles.clone();
+ let mut start_overrides: [Option; LEVELS] = [None; LEVELS];
for over in num_elem.find_all(ns::W, "lvlOverride") {
let ilvl: usize = over.attr(ns::W, "ilvl").and_then(|v| v.parse().ok()).unwrap_or(0);
if ilvl >= LEVELS {
@@ -129,9 +142,13 @@ pub fn parse(
.and_then(parse_start)
{
levels[ilvl].start = start;
+ start_overrides[ilvl] = Some(start);
}
}
- numbering.instances.insert(num_id, Instance { levels, pstyles });
+ numbering.instances.insert(
+ num_id,
+ Instance { levels, pstyles, counter_key: abs.id.clone(), start_overrides },
+ );
}
Ok(numbering)
}
@@ -189,6 +206,14 @@ fn parse_level(lvl: &Element) -> LevelDef {
"upperLetter" => Some(MarkerKind::UpperAlpha),
"lowerRoman" => Some(MarkerKind::LowerRoman),
"upperRoman" => Some(MarkerKind::UpperRoman),
+ // Fork (sznb): CJK counting families render 一、二、三 / 壹、贰、叁
+ // instead of degrading to 1、2、3.
+ "chineseCounting" | "chineseCountingThousand" | "ideographDigital"
+ | "japaneseCounting" | "japaneseDigitalTenThousand" | "taiwaneseCounting"
+ | "taiwaneseCountingThousand" | "taiwaneseDigital" => Some(MarkerKind::ChineseCounting),
+ "chineseLegalSimplified" | "ideographLegalTraditional" | "japaneseLegal" => {
+ Some(MarkerKind::ChineseLegal)
+ }
_ => Some(MarkerKind::Decimal),
};
let start = lvl
@@ -214,11 +239,15 @@ fn parse_level(lvl: &Element) -> LevelDef {
LevelDef { marker, start, restart, pattern: NumberPattern { text, legal } }
}
-/// Document-order numbering state: one counter array per instance, shared
-/// across interruptions so continuation works, with `lvlRestart` semantics.
+/// Document-order numbering state: one counter array per abstract numbering
+/// definition (shared by all `numId`s referencing it, as Word/LibreOffice do),
+/// kept across interruptions so continuation works, with `lvlRestart`
+/// semantics.
#[derive(Default)]
pub struct Counters {
- state: HashMap,
+ state: HashMap,
+ /// `(numId, ilvl)` pairs whose `startOverride` restart has been applied.
+ applied_overrides: HashSet<(u64, usize)>,
}
#[derive(Default)]
@@ -234,15 +263,32 @@ impl Counters {
/// reproducible from the marker kind alone, the composite label.
pub fn next(&mut self, num_id: u64, ilvl: usize, instance: &Instance) -> (u64, Option) {
let ilvl = ilvl.min(LEVELS - 1);
- let state = self.state.entry(num_id).or_default();
+ let override_start = instance.start_overrides[ilvl]
+ .filter(|_| self.applied_overrides.insert((num_id, ilvl)));
+ let state = self.state.entry(instance.counter_key.clone()).or_default();
let def = &instance.levels[ilvl];
- if !state.initialized[ilvl] || state.restart_pending[ilvl] {
+ if let Some(start) = override_start {
+ state.value[ilvl] = start;
+ state.initialized[ilvl] = true;
+ state.restart_pending[ilvl] = false;
+ } else if !state.initialized[ilvl] || state.restart_pending[ilvl] {
state.value[ilvl] = def.start;
state.initialized[ilvl] = true;
state.restart_pending[ilvl] = false;
} else {
state.value[ilvl] = state.value[ilvl].saturating_add(1);
}
+ // Fork (sznb): an orphan deeper item consumes each unadvanced upper
+ // level at its start value (Word/LibreOffice: ilvl1, ilvl1, ilvl0,
+ // ilvl1 → 1.1 / 1.2 / 2 / 2.1). Upstream only *rendered* the start
+ // without recording it, so the first real upper item restarted at 1.
+ for upper in 0..ilvl {
+ if !state.initialized[upper] || state.restart_pending[upper] {
+ state.value[upper] = instance.levels[upper].start;
+ state.initialized[upper] = true;
+ state.restart_pending[upper] = false;
+ }
+ }
// Using level `ilvl` schedules restarts for deeper levels according
// to their lvlRestart settings.
for (deeper, level_def) in instance.levels.iter().enumerate().skip(ilvl + 1) {
diff --git a/src/formats/docx/ole.rs b/src/formats/docx/ole.rs
new file mode 100644
index 00000000..b4677b7e
--- /dev/null
+++ b/src/formats/docx/ole.rs
@@ -0,0 +1,601 @@
+//! Fork (sznb): embedded OLE workbook extraction (`excel.sheet.8`).
+//!
+//! Old-style Word OLE charts embed a complete Excel workbook as a BIFF8
+//! `Workbook` stream inside an OLE compound file (`embeddings/oleObject*.bin`).
+//! The stock parser only emitted an "Embedded object" marker line — the data
+//! behind every such chart in a document was lost. This module reads the CFB
+//! container, extracts the `Workbook` stream, and lifts each worksheet into a
+//! [`Table`] (values only, merged ranges included).
+
+use crate::model::{Block, Cell, CellSlot, Inline, Style, Table, TableKind};
+
+// ---------- OLE compound file (CFB) minimal reader ----------
+
+const SIG: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
+const FREESECT: u32 = 0xFFFFFFFF;
+const ENDCHECK: u32 = 0xFFFFFFFE;
+
+struct Cfb {
+ data: Vec,
+ ss: usize, // sector size
+ mss: usize, // mini sector size
+ fat: Vec,
+ mini_fat: Vec,
+ ministream: Vec,
+ entries: Vec<(String, u32, u64)>, // name, start sector, size
+}
+
+/// Bounds-checked reads: record bodies may be truncated at the stream end,
+/// and untrusted data must never panic the parser.
+fn u16at(d: &[u8], o: usize) -> u16 {
+ if o + 2 <= d.len() {
+ u16::from_le_bytes([d[o], d[o + 1]])
+ } else {
+ 0
+ }
+}
+fn u32at(d: &[u8], o: usize) -> u32 {
+ if o + 4 <= d.len() {
+ u32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]])
+ } else {
+ 0
+ }
+}
+
+impl Cfb {
+ fn open(data: &[u8]) -> Option {
+ if data.len() < 512 || data[..8] != SIG {
+ return None;
+ }
+ let ss = 1usize << u16at(data, 30) as usize;
+ let mss = 1usize << u16at(data, 32) as usize;
+ let num_fat = u32at(data, 44) as usize;
+ let first_dir = u32at(data, 48);
+ let cutoff = u32at(data, 56) as u64;
+ let first_mini = u32at(data, 60);
+ let first_difat = u32at(data, 68);
+ let _num_difat = u32at(data, 72) as usize;
+ let sec = |n: u32| -> usize { 512 + n as usize * ss };
+
+ // DIFAT: 109 entries in the header, then a chain of DIFAT sectors.
+ let mut difat: Vec = Vec::with_capacity(num_fat);
+ for i in 0..109 {
+ let v = u32at(data, 76 + i * 4);
+ if v != FREESECT {
+ difat.push(v);
+ }
+ }
+ let mut cur = first_difat;
+ let mut guard = 0;
+ while cur != FREESECT && cur != ENDCHECK && guard < 1 << 16 {
+ let base = sec(cur);
+ for i in 0..(ss / 4 - 1) {
+ let v = u32at(data, base + i * 4);
+ if v != FREESECT {
+ difat.push(v);
+ }
+ }
+ cur = u32at(data, base + ss - 4);
+ guard += 1;
+ }
+ difat.truncate(num_fat.max(difat.len().min(num_fat)));
+
+ // FAT sectors hold the sector chains.
+ let mut fat: Vec = Vec::new();
+ for fs in difat.iter().take(num_fat) {
+ let base = sec(*fs);
+ if base + ss > data.len() {
+ return None;
+ }
+ for i in 0..(ss / 4) {
+ fat.push(u32at(data, base + i * 4));
+ }
+ }
+
+ let chain = |fat: &[u32], mut s: u32| -> Vec {
+ let mut out = Vec::new();
+ let mut guard = 0;
+ while (s as usize) < fat.len() && fat[s as usize] != ENDCHECK && guard < 1 << 22 {
+ out.push(s);
+ s = fat[s as usize];
+ guard += 1;
+ }
+ out
+ };
+
+ // Directory stream.
+ let dir_secs = chain(&fat, first_dir);
+ let mut dir = Vec::with_capacity(dir_secs.len() * ss);
+ for s in &dir_secs {
+ let b = sec(*s);
+ if b + ss > data.len() {
+ return None;
+ }
+ dir.extend_from_slice(&data[b..b + ss]);
+ }
+
+ let mut entries = Vec::new();
+ let mut root: Option<(u32, u64)> = None;
+ for e in dir.chunks(128) {
+ if e.len() < 128 {
+ break;
+ }
+ let nlen = u16at(e, 64) as usize;
+ if nlen < 2 || nlen > 64 {
+ continue;
+ }
+ // Directory entry names are UTF-16LE (nlen includes the NUL).
+ let name = {
+ let units: Vec = e[..nlen - 2]
+ .chunks_exact(2)
+ .map(|c| u16::from_le_bytes([c[0], c[1]]))
+ .collect();
+ String::from_utf16_lossy(&units)
+ };
+ let kind = e[66];
+ let start = u32at(e, 116);
+ let size = u64::from(u32at(e, 120)) | (u64::from(u32at(e, 124)) << 32);
+ if kind == 5 && name == "Root Entry" {
+ root = Some((start, size));
+ }
+ if kind == 2 {
+ entries.push((name, start, size));
+ }
+ }
+
+ // Mini stream + mini FAT.
+ let ministream = root
+ .and_then(|(start, size)| {
+ let secs = chain(&fat, start);
+ let mut buf = Vec::with_capacity(size as usize);
+ for s in secs {
+ let b = sec(s);
+ if b + ss > data.len() {
+ return None;
+ }
+ buf.extend_from_slice(&data[b..b + ss]);
+ }
+ buf.truncate(size as usize);
+ Some(buf)
+ })
+ .unwrap_or_default();
+ let mut mini_fat = Vec::new();
+ for s in chain(&fat, first_mini) {
+ let b = sec(s);
+ if b + ss > data.len() {
+ return None;
+ }
+ for i in 0..(ss / 4) {
+ mini_fat.push(u32at(data, b + i * 4));
+ }
+ }
+
+ let _ = cutoff;
+ Some(Cfb { data: data.to_vec(), ss, mss, fat, mini_fat, ministream, entries })
+ }
+
+ fn stream(&self, want: &str) -> Option> {
+ let (name, start, size) = self.entries.iter().find(|(n, _, _)| n == want)?;
+ let _ = name;
+ let size = *size as usize;
+ if size >= 4096 {
+ let mut out = Vec::with_capacity(size);
+ let mut s = *start;
+ let mut guard = 0;
+ while (s as usize) < self.fat.len() && self.fat[s as usize] != ENDCHECK && guard < 1 << 22 {
+ let b = 512 + s as usize * self.ss;
+ if b + self.ss > self.data.len() {
+ return None;
+ }
+ out.extend_from_slice(&self.data[b..b + self.ss]);
+ s = self.fat[s as usize];
+ guard += 1;
+ }
+ out.truncate(size);
+ Some(out)
+ } else {
+ let mut out = Vec::with_capacity(size);
+ let mut s = *start;
+ let mut guard = 0;
+ while (s as usize) < self.mini_fat.len()
+ && self.mini_fat[s as usize] != ENDCHECK
+ && guard < 1 << 22
+ {
+ let b = s as usize * self.mss;
+ if b + self.mss > self.ministream.len() {
+ return None;
+ }
+ out.extend_from_slice(&self.ministream[b..b + self.mss]);
+ s = self.mini_fat[s as usize];
+ guard += 1;
+ }
+ out.truncate(size);
+ Some(out)
+ }
+ }
+}
+
+// ---------- BIFF8 minimal extraction ----------
+
+struct Segs {
+ parts: Vec>,
+ seg: usize,
+ pos: usize,
+}
+
+impl Segs {
+ fn u8(&mut self) -> Option {
+ loop {
+ let p = self.parts.get(self.seg)?;
+ if self.pos < p.len() {
+ let b = p[self.pos];
+ self.pos += 1;
+ return Some(b);
+ }
+ self.seg += 1;
+ self.pos = 0;
+ }
+ }
+ fn u16(&mut self) -> Option {
+ let lo = self.u8()?;
+ let hi = self.u8()?;
+ Some(u16::from_le_bytes([lo, hi]))
+ }
+ fn u32(&mut self) -> Option {
+ let a = self.u16()?;
+ let b = self.u16()?;
+ Some(u32::from_le_bytes([a.to_le_bytes()[0], a.to_le_bytes()[1],
+ b.to_le_bytes()[0], b.to_le_bytes()[1]]))
+ }
+ /// Read `chars` characters; at a segment boundary the next byte selects
+ /// 8/16-bit for the remainder (CONTINUE semantics).
+ fn xl_string(&mut self, chars: usize, wide: bool) -> Option {
+ let mut out = String::new();
+ let mut wide = wide;
+ let mut done = 0;
+ while done < chars {
+ // Advance past exhausted segments (get() keeps this in bounds).
+ while self.parts.get(self.seg).is_some_and(|p| self.pos >= p.len()) {
+ self.seg += 1;
+ self.pos = 0;
+ }
+ if self.parts.get(self.seg).is_none() {
+ return Some(out);
+ }
+ // A segment boundary mid-string begins with a fresh flags byte.
+ if self.pos == 0 && self.seg > 0 {
+ let Some(flags) = self.u8() else { return Some(out) };
+ wide = flags & 1 == 1;
+ }
+ let ch = if wide {
+ let Some(c) = self.u16() else { return Some(out) };
+ char::from_u32(u32::from(c)).unwrap_or('\u{FFFD}')
+ } else {
+ let Some(c) = self.u8() else { return Some(out) };
+ char::from_u32(u32::from(c)).unwrap_or('\u{FFFD}')
+ };
+ out.push(ch);
+ done += 1;
+ }
+ Some(out)
+ }
+}
+
+/// Short XLUnicodeString in a record body: cch u16, grbit u16.
+fn short_string(rec: &[u8]) -> String {
+ if rec.len() < 4 {
+ return String::new();
+ }
+ let cch = u16at(rec, 0) as usize;
+ let wide = rec[2] & 1 == 1;
+ let bytes = if wide { rec[4..].to_vec() } else { rec[3..].to_vec() };
+ let mut segs = Segs { parts: vec![bytes], seg: 0, pos: 0 };
+ segs.xl_string(cch, wide).unwrap_or_default()
+}
+
+fn rk_value(v: u32) -> f64 {
+ if v & 2 != 0 {
+ let n = (v >> 2) as i32;
+ let mut f = n as f64;
+ if v & 1 != 0 {
+ f /= 100.0;
+ }
+ f
+ } else {
+ let bits = (u64::from(v & 0xFFFFFFFC) << 32);
+ let mut f = f64::from_bits(bits);
+ if v & 1 != 0 {
+ f /= 100.0;
+ }
+ f
+ }
+}
+
+fn num_text(f: f64) -> String {
+ if f == f.trunc() && f.abs() < 1e15 {
+ format!("{}", f as i64)
+ } else {
+ format!("{f}")
+ }
+}
+
+struct SheetBuilder {
+ cells: Vec>>, // [row][col]
+ merged: Vec<(usize, usize, usize, usize)>, // r1, r2, c1, c2 inclusive
+}
+
+impl SheetBuilder {
+ fn put(&mut self, r: usize, c: usize, v: String) {
+ if self.cells.len() <= r {
+ self.cells.resize(r + 1, Vec::new());
+ }
+ if self.cells[r].len() <= c {
+ self.cells[r].resize(c + 1, None);
+ }
+ self.cells[r][c] = Some(v);
+ }
+
+ fn build(self) -> Option
{
+ let rows = self.cells.len();
+ let cols = self.cells.iter().map(|r| r.len()).max()?; // cols == 0 -> None
+ // Trim trailing all-empty rows/cols.
+ let last_row = (0..rows).rev().find(|&r| self.cells[r].iter().any(|c| c.is_some()))?;
+ let last_col = (0..cols).rev().find(|&c|
+ self.cells.iter().any(|r| r.get(c).is_some_and(|v| v.is_some())))?;
+ let covered = |r: usize, c: usize| {
+ self.merged.iter().any(|&(r1, r2, c1, c2)| {
+ r >= r1 && r <= r2 && c >= c1 && c <= c2 && !(r == r1 && c == c1)
+ })
+ };
+ let span_of = |r: usize, c: usize| {
+ self.merged.iter().find_map(|&(r1, r2, c1, c2)| {
+ (r == r1 && c == c1).then_some((c2 - c1 + 1, r2 - r1 + 1))
+ })
+ };
+ let mut grid: Vec> = Vec::with_capacity(last_row + 1);
+ let mut any = false;
+ for r in 0..=last_row {
+ let mut row = Vec::with_capacity(last_col + 1);
+ for c in 0..=last_col {
+ if covered(r, c) {
+ // Find its origin for the back-reference.
+ let org = self.merged.iter().find(|&&(r1, r2, c1, c2)| {
+ r >= r1 && r <= r2 && c >= c1 && c <= c2
+ });
+ let (or, oc) = org.map(|&(r1, _r2, c1, _c2)| (r1, c1)).unwrap_or((r, c));
+ row.push(CellSlot::Covered { origin_row: or, origin_col: oc });
+ } else {
+ let text = self.cells[r].get(c).and_then(|v| v.clone()).unwrap_or_default();
+ if !text.is_empty() {
+ any = true;
+ }
+ let (col_span, row_span) = span_of(r, c).unwrap_or((1, 1));
+ row.push(CellSlot::Origin(Cell {
+ blocks: text_to_block(&text),
+ col_span: col_span as u32,
+ row_span: row_span as u32,
+ }));
+ }
+ }
+ grid.push(row);
+ }
+ any.then(|| Table { grid, header_rows: 0, kind: TableKind::Data, keep_empty_rows: false })
+ }
+}
+
+fn text_to_block(text: &str) -> Vec {
+ if text.is_empty() {
+ return Vec::new();
+ }
+ vec![Block::Paragraph(vec![Inline::Text { text: text.to_string(), style: Style::PLAIN }])]
+}
+
+fn biff_tables(wb: &[u8]) -> Vec
{
+ // Pass 1 (workbook globals): SST + BOUNDSHEET positions.
+ let mut sst: Vec = Vec::new();
+ let mut sheets: Vec = Vec::new();
+ let mut pos = 0usize;
+ let mut sst_pending: Vec> = Vec::new();
+ while pos + 4 <= wb.len() {
+ let t = u16at(wb, pos);
+ let len = u16at(wb, pos + 2) as usize;
+ let body = &wb[pos + 4..(pos + 4 + len).min(wb.len())];
+ match t {
+ 0x0809 if pos == 0 => {} // workbook globals BOF
+ 0x0085 => {
+ // BOUNDSHEET: lbPlyPos u32 | state u16 | sheetType u8 | name
+ if body.len() >= 7 {
+ sheets.push(u32at(body, 0) as usize);
+ }
+ }
+ 0x00FC => {
+ sst_pending.clear();
+ sst_pending.push(body.to_vec());
+ }
+ 0x003C if !sst_pending.is_empty() => {
+ sst_pending.push(body.to_vec());
+ }
+ 0x000A => {
+ if !sst_pending.is_empty() {
+ sst = parse_sst(&sst_pending);
+ sst_pending.clear();
+ }
+ if !sheets.is_empty() || pos > 0 {
+ break; // end of globals
+ }
+ }
+ _ => {}
+ }
+ pos += 4 + len;
+ }
+
+ let mut out = Vec::new();
+ for start in sheets {
+ if let Some(tb) = parse_sheet(wb, start, &sst) {
+ out.push(tb);
+ }
+ }
+ out
+}
+
+fn parse_sst(parts: &[Vec]) -> Vec {
+ let first = &parts[0];
+ if first.len() < 8 {
+ return Vec::new();
+ }
+ let unique = u32at(first, 4) as usize;
+ // String data starts at offset 8 of the first part.
+ let mut segs = Segs {
+ parts: {
+ let mut v: Vec> = parts.to_vec();
+ v[0] = first[8..].to_vec();
+ v
+ },
+ seg: 0,
+ pos: 0,
+ };
+ let mut out = Vec::with_capacity(unique.min(1 << 20));
+ for _ in 0..unique {
+ let Some(cch) = segs.u16() else { break };
+ // One flags byte, not the spec's two: WPS-ET-authored BIFF writes
+ // the SST grbit as a single byte (verified byte-level on the
+ // government-corpus files — a 2-byte read shifts every string).
+ let Some(flags) = segs.u8() else { break };
+ let wide = flags & 1 == 1;
+ let rich = flags & 8 != 0;
+ let far_east = flags & 4 != 0;
+ let c_run = if rich { segs.u16().unwrap_or(0) as usize } else { 0 };
+ let cb_ext = if far_east { segs.u32().unwrap_or(0) as usize } else { 0 };
+ let s = segs.xl_string(cch as usize, wide).unwrap_or_default();
+ // 4 bytes per rich run, then the Far-East string blob.
+ for _ in 0..(4 * c_run + cb_ext) {
+ if segs.u8().is_none() {
+ break;
+ }
+ }
+ out.push(s);
+ }
+ out
+}
+
+fn parse_sheet(wb: &[u8], start: usize, sst: &[String]) -> Option
{
+ let mut b = SheetBuilder { cells: Vec::new(), merged: Vec::new() };
+ // A FORMULA whose result is a string is followed by a STRING record
+ // carrying the value for that (row, col).
+ let mut pending_string_formula: Option<(usize, usize)> = None;
+ let mut pos = start;
+ while pos + 4 <= wb.len() {
+ let t = u16at(wb, pos);
+ let len = u16at(wb, pos + 2) as usize;
+ let body = &wb[pos + 4..(pos + 4 + len).min(wb.len())];
+ match t {
+ 0x00FD => if body.len() >= 10 { // LABELSST: row, col, ixfe, isst
+ let r = u16at(body, 0) as usize;
+ let c = u16at(body, 2) as usize;
+ let idx = u32at(body, 6) as usize;
+ if let Some(s) = sst.get(idx) {
+ b.put(r, c, s.clone());
+ }
+ },
+ 0x0203 => if body.len() >= 14 { // NUMBER
+ let r = u16at(body, 0) as usize;
+ let c = u16at(body, 2) as usize;
+ let mut b8 = [0u8; 8];
+ b8.copy_from_slice(&body[6..14]);
+ b.put(r, c, num_text(f64::from_le_bytes(b8)));
+ },
+ 0x027E => if body.len() >= 10 { // RK
+ let r = u16at(body, 0) as usize;
+ let c = u16at(body, 2) as usize;
+ b.put(r, c, num_text(rk_value(u32at(body, 6))));
+ },
+ 0x00BD => { // MULRK. Standard: row rowLast colFirst {IXFE,RK}* colLast
+ // (len = N*6+8). WPS-ET variant omits colFirst entirely
+ // (len = N*6+6); its first column is colLast-N+1 — verified
+ // byte-level on the government corpus (row 1: cols 1..3,
+ // 56.67 / -14.4 / 0.4).
+ let blen = body.len();
+ if blen >= 12 && blen % 6 == 0 {
+ let n = (blen - 6) / 6;
+ let r = u16at(body, 0) as usize;
+ let c2 = u16at(body, blen - 2) as usize;
+ if c2 + 1 >= n {
+ let c1 = c2 + 1 - n;
+ for i in 0..n {
+ let off = 4 + i * 6;
+ b.put(r, c1 + i, num_text(rk_value(u32at(body, off + 2))));
+ }
+ }
+ } else if blen >= 8 {
+ let r = u16at(body, 0) as usize;
+ let c1 = u16at(body, 4) as usize;
+ let c2 = u16at(body, blen - 2) as usize;
+ for c in c1..=c2 {
+ let off = 6 + (c - c1) * 6;
+ if off + 6 > blen {
+ break;
+ }
+ b.put(r, c, num_text(rk_value(u32at(body, off + 2))));
+ }
+ }
+ },
+ 0x0006 => if body.len() >= 14 { // FORMULA
+ let r = u16at(body, 0) as usize;
+ let c = u16at(body, 2) as usize;
+ let mut b8 = [0u8; 8];
+ b8.copy_from_slice(&body[6..14]);
+ let f = f64::from_le_bytes(b8);
+ if f.is_finite() {
+ b.put(r, c, num_text(f));
+ } else {
+ // Non-numeric result: the value follows in a STRING record.
+ pending_string_formula = Some((r, c));
+ }
+ },
+ 0x0207 => { // STRING: value of the preceding string-result FORMULA
+ if let Some((r, c)) = pending_string_formula.take()
+ && body.len() >= 3
+ {
+ let cch = u16at(body, 0) as usize;
+ let wide = body[2] & 1 == 1;
+ let data = &body[3..];
+ let mut segs = Segs { parts: vec![data.to_vec()], seg: 0, pos: 0 };
+ b.put(r, c, segs.xl_string(cch, wide).unwrap_or_default());
+ }
+ },
+ 0x0204 | 0x00D6 => if body.len() >= 6 { // LABEL / RSTRING
+ let r = u16at(body, 0) as usize;
+ let c = u16at(body, 2) as usize;
+ b.put(r, c, short_string(&body[6..]));
+ },
+ 0x00E5 => { // MERGEDCELLS
+ if body.len() >= 2 {
+ let n = u16at(body, 0) as usize;
+ for i in 0..n {
+ let o = 2 + i * 8;
+ if o + 8 <= body.len() {
+ b.merged.push((
+ u16at(body, o) as usize,
+ u16at(body, o + 2) as usize,
+ u16at(body, o + 4) as usize,
+ u16at(body, o + 6) as usize,
+ ));
+ }
+ }
+ }
+ }
+ 0x000A => break, // EOF: sheet done
+ _ => {}
+ }
+ pos += 4 + len;
+ }
+ b.build()
+}
+
+/// Extract worksheet tables from an `excel.sheet.8` OLE payload.
+pub fn workbook_tables(bytes: &[u8]) -> Option> {
+ let cfb = Cfb::open(bytes)?;
+ let wb = cfb.stream("Workbook")?;
+ let tables = biff_tables(&wb);
+ (!tables.is_empty()).then_some(tables)
+}
diff --git a/src/formats/mod.rs b/src/formats/mod.rs
index d570b264..6d95e7da 100644
--- a/src/formats/mod.rs
+++ b/src/formats/mod.rs
@@ -17,17 +17,24 @@ use crate::error::ConvertError;
use crate::model::Document;
pub fn parse(bytes: &[u8], format: Format) -> Result {
+ parse_with(bytes, format, true)
+}
+
+/// Fork (sznb): parse with the table of contents kept (`include_toc = true`,
+/// the historical behaviour) or dropped. Only the Word frontends (docx, doc /
+/// wps) carry a TOC construct; the flag is inert for every other format.
+pub fn parse_with(bytes: &[u8], format: Format, include_toc: bool) -> Result {
match format {
Format::Excel => sheet::parse(bytes),
Format::Csv => csv::parse(bytes),
- Format::Docx => docx::parse(bytes),
+ Format::Docx => docx::parse_with(bytes, include_toc),
Format::Odt | Format::Ods | Format::Odp => odf::parse(bytes),
Format::Pptx => pptx::parse(bytes),
Format::Epub => epub::parse(bytes),
Format::Rtf => rtf::parse(bytes),
// RTF files wearing a .doc extension are common in the wild.
Format::Doc if bytes.starts_with(b"{\\rtf") => rtf::parse(bytes),
- Format::Doc => doc::parse(bytes),
+ Format::Doc => doc::parse_with(bytes, include_toc),
Format::Ppt => ppt::parse(bytes),
// pdf-inspector produces Markdown directly; there is no document
// model for PDFs. `to_markdown_bytes` routes them to `pdf`.
diff --git a/src/formats/ppt/mod.rs b/src/formats/ppt/mod.rs
index 0b84ede2..edf722c7 100644
--- a/src/formats/ppt/mod.rs
+++ b/src/formats/ppt/mod.rs
@@ -9,10 +9,11 @@
mod styletext;
use crate::error::ConvertError;
-use crate::model::{Block, Document, Inline, Style, inlines_are_empty};
+use crate::model::{Block, Cell, Document, GridBuilder, Inline, Style, TableKind, inlines_are_empty};
use crate::package::limits;
use crate::shared::binary::{get_u32, read_ole_stream, utf16le_units};
use crate::shared::delta::{StyleDelta, rebase_emphasis};
+use crate::shared::header::resolve_header_rows;
use crate::shared::list::{ListEntry, ListKey, MarkerKind, flush_list};
use crate::shared::officeart::record_at;
use crate::shared::text::clean_text;
@@ -414,6 +415,14 @@ impl Extractor {
0x03F0 | 0x03F8 | 0x0FC9 => {}
// Only instance 0 of SlideListWithText holds slide text here.
0x0FF0 if ver_inst >> 4 != 0 => {}
+ // Fork (sznb): a table is a group shape flagged by the
+ // tableProperties property; rebuild its grid from the cell
+ // rectangles instead of emitting every cell as a paragraph.
+ 0xF003 if is_table_group(body) => {
+ if let Some(table) = self.table_from_group(body)? {
+ self.current.push(table);
+ }
+ }
_ => {
if stack.len() >= limits::MAX_RECORD_DEPTH {
return Err(ConvertError::ResourceLimit {
@@ -445,6 +454,109 @@ impl Extractor {
Ok(())
}
+ /// Fork (sznb): text of one shape container as blocks of its own, with
+ /// the slide-level text state set aside so nothing leaks across.
+ fn capture(&mut self, body: &[u8]) -> Result, ConvertError> {
+ self.flush_shape();
+ flush_list(&mut self.current, &mut self.list_run);
+ let saved_current = std::mem::take(&mut self.current);
+ let saved_list = std::mem::take(&mut self.list_run);
+ let walked = self.walk(body);
+ self.flush_shape();
+ flush_list(&mut self.current, &mut self.list_run);
+ let blocks = std::mem::replace(&mut self.current, saved_current);
+ self.list_run = saved_list;
+ walked?;
+ Ok(blocks)
+ }
+
+ /// Fork (sznb): rebuild a table group. PowerPoint 97-2003 stores a table
+ /// as a group of cell rectangles plus border lines; column / row
+ /// boundaries come from the cell anchors, and a rectangle crossing
+ /// boundaries is a merged cell with the matching spans. Without anchored
+ /// cells the group's text is emitted in order and `None` returned.
+ fn table_from_group(&mut self, group: &[u8]) -> Result