chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:54 +08:00
commit 4a4a1fed67
721 changed files with 262090 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
"""LightRAG native DOCX parser package.
The :mod:`parse_document` / :mod:`numbering_resolver` / :mod:`table_extractor` /
:mod:`drawing_image_extractor` / :mod:`utils` / :mod:`omml` modules ship the
upstream DOCX extraction logic verbatim (with imports localized for the new
package path).
The pipeline-side orchestration (extract → IR → sidecar) now lives in
:meth:`lightrag.pipeline._PipelineMixin.parse_native` so the native and
MinerU engines share one shape; see :mod:`lightrag.parser.docx.ir_builder`
for the engine IR builder.
"""
__all__: list[str] = []
@@ -0,0 +1,445 @@
#!/usr/bin/env python3
"""
ABOUTME: Shared drawing/image extraction utilities for DOCX parsing and editing
ABOUTME: Resolves w:drawing -> a:blip relationships, exports embedded images, builds placeholders
"""
from __future__ import annotations
import posixpath
import re
import shutil
import zipfile
from dataclasses import dataclass, field
from html import escape, unescape
from pathlib import Path, PurePosixPath
from typing import Dict, Optional, Tuple
from urllib.parse import urlparse
try:
from defusedxml import ElementTree as ET
except ImportError: # pragma: no cover
from xml.etree import ElementTree as ET
NS = {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"v": "urn:schemas-microsoft-com:vml",
}
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
CONTENT_TYPE_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
IMAGE_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
)
SOURCE_DOCUMENT_PART = "/word/document.xml"
# Match old and new drawing placeholders (requires id/name, allows extra attributes)
DRAWING_PATTERN = re.compile(
r'<drawing\b(?=[^>]*\bid="[^"]*")(?=[^>]*\bname="[^"]*")[^>]*/>'
)
DRAWING_TAG_PATTERN = re.compile(r"<drawing\b[^>]*/>")
DRAWING_ATTR_PATTERN = re.compile(r'([a-zA-Z_][\w:.-]*)="([^"]*)"')
@dataclass
class DrawingRelationship:
"""Relationship metadata for a single relationship ID."""
rel_id: str
target: str
target_mode: str
rel_type: str
part_name: Optional[str] = None
content_type: Optional[str] = None
image_format: Optional[str] = None
@dataclass
class DrawingExtractionContext:
"""Context used to resolve and export drawing images for one DOCX file."""
docx_path: Path
blocks_output_path: Optional[Path] = None
export_dir_name: Optional[str] = None
export_dir_path: Optional[Path] = None
relationships: Dict[str, DrawingRelationship] = field(default_factory=dict)
_exported_part_to_relpath: Dict[str, str] = field(default_factory=dict)
_used_filenames: Dict[str, str] = field(default_factory=dict)
def resolve_relationship(self, rel_id: str) -> Optional[DrawingRelationship]:
return self.relationships.get(rel_id)
def export_embedded_image(self, rel: DrawingRelationship) -> Optional[str]:
"""
Export an embedded image relationship target to export_dir.
Returns:
Relative path like "<blocks_stem>.image/image1.png" if exported,
or None when export is not applicable.
"""
if not self.export_dir_path or not self.export_dir_name:
return None
if rel.target_mode.lower() == "external":
return None
if not rel.part_name:
return None
if rel.part_name in self._exported_part_to_relpath:
return self._exported_part_to_relpath[rel.part_name]
zip_member = rel.part_name.lstrip("/")
try:
with zipfile.ZipFile(self.docx_path, "r") as zf:
blob = zf.read(zip_member)
except Exception:
return None
filename = self._dedupe_filename(PurePosixPath(rel.part_name).name or "image")
output_file = self.export_dir_path / filename
output_file.write_bytes(blob)
rel_path = str(PurePosixPath(self.export_dir_name) / filename)
self._exported_part_to_relpath[rel.part_name] = rel_path
return rel_path
def _dedupe_filename(self, base_name: str) -> str:
if base_name not in self._used_filenames:
self._used_filenames[base_name] = base_name
return base_name
stem = Path(base_name).stem
suffix = Path(base_name).suffix
index = 2
while True:
candidate = f"{stem}_{index}{suffix}"
if candidate not in self._used_filenames:
self._used_filenames[candidate] = candidate
return candidate
index += 1
def _normalize_image_format(ext_or_type: str) -> Optional[str]:
if not ext_or_type:
return None
value = ext_or_type.strip().lower()
# Content-Type
if value.startswith("image/"):
value = value.split("/", 1)[1]
if "+" in value:
value = value.split("+", 1)[0]
if value.startswith("x-"):
value = value[2:]
# Extension (with or without leading dot)
value = value.lstrip(".")
if value == "jpg":
return "jpeg"
if value in {"jpeg", "png", "gif", "bmp", "tiff", "webp", "svg", "emf", "wmf"}:
return value
return value or None
def _infer_format_from_target(target: str) -> Optional[str]:
if not target:
return None
parsed = urlparse(target)
path = parsed.path if parsed.scheme else target
suffix = PurePosixPath(path).suffix
return _normalize_image_format(suffix)
def _resolve_part_name(source_part_name: str, target: str) -> str:
if target.startswith("/"):
return posixpath.normpath(target)
source_dir = posixpath.dirname(source_part_name)
joined = posixpath.join(source_dir, target)
normalized = posixpath.normpath(joined)
if not normalized.startswith("/"):
normalized = "/" + normalized
return normalized
def create_drawing_context(
docx_path: str,
blocks_output_path: Optional[str] = None,
) -> DrawingExtractionContext:
"""
Create extraction context for a DOCX file.
If blocks_output_path is provided, this also prepares `<blocks_stem>.image/`
beside the blocks file and clears any previous content.
"""
docx_file = Path(docx_path)
ctx = DrawingExtractionContext(docx_path=docx_file)
if blocks_output_path:
output_path = Path(blocks_output_path)
export_dir_name = f"{output_path.stem}.image"
export_dir_path = output_path.parent / export_dir_name
if export_dir_path.exists():
shutil.rmtree(export_dir_path)
export_dir_path.mkdir(parents=True, exist_ok=True)
ctx.blocks_output_path = output_path
ctx.export_dir_name = export_dir_name
ctx.export_dir_path = export_dir_path
load_relationships(ctx)
return ctx
def load_relationships(ctx: DrawingExtractionContext) -> None:
rels_xml = "word/_rels/document.xml.rels"
content_types_xml = "[Content_Types].xml"
overrides: Dict[str, str] = {}
defaults: Dict[str, str] = {}
try:
with zipfile.ZipFile(ctx.docx_path, "r") as zf:
if content_types_xml in zf.namelist():
ct_root = ET.parse(zf.open(content_types_xml)).getroot()
for node in ct_root.findall(f".//{{{CONTENT_TYPE_NS}}}Override"):
part_name = node.get("PartName")
content_type = node.get("ContentType")
if part_name and content_type:
overrides[part_name] = content_type
for node in ct_root.findall(f".//{{{CONTENT_TYPE_NS}}}Default"):
ext = node.get("Extension")
content_type = node.get("ContentType")
if ext and content_type:
defaults[ext.lower()] = content_type
if rels_xml not in zf.namelist():
return
rels_root = ET.parse(zf.open(rels_xml)).getroot()
except Exception:
return
for rel in rels_root.findall(f".//{{{REL_NS}}}Relationship"):
rel_id = rel.get("Id")
target = rel.get("Target", "")
target_mode = rel.get("TargetMode", "")
rel_type = rel.get("Type", "")
if not rel_id:
continue
part_name = None
content_type = None
image_format = None
if target_mode.lower() != "external":
part_name = _resolve_part_name(SOURCE_DOCUMENT_PART, target)
if part_name:
content_type = overrides.get(part_name)
if not content_type:
ext = PurePosixPath(part_name).suffix.lower().lstrip(".")
content_type = defaults.get(ext)
image_format = _normalize_image_format(
content_type or _infer_format_from_target(part_name)
)
else:
image_format = _normalize_image_format(_infer_format_from_target(target))
ctx.relationships[rel_id] = DrawingRelationship(
rel_id=rel_id,
target=target,
target_mode=target_mode,
rel_type=rel_type,
part_name=part_name,
content_type=content_type,
image_format=image_format,
)
def _extract_blip_relationship(drawing_elem) -> Optional[Tuple[str, str]]:
for blip in drawing_elem.findall(".//a:blip", NS):
# Prefer explicit external links when both link/embed are present on one blip.
# Word may keep an embedded cache for linked pictures.
rel_link = blip.get(f"{{{NS['r']}}}link")
if rel_link:
return "link", rel_link
rel_embed = blip.get(f"{{{NS['r']}}}embed")
if rel_embed:
return "embed", rel_embed
return None
def _extract_imagedata_relationship(container_elem) -> Optional[str]:
"""Find an image relationship id from a w:pict / w:object via v:imagedata.
These legacy VML containers are how Word references EMF/WMF metafiles
(and the rendered preview of any embedded OLE object). v:imagedata uses
``r:id`` to point at the image part for both embedded and externally
linked images — the relationship's ``TargetMode`` is what disambiguates
the two cases, so the caller must inspect the resolved relationship.
"""
r_id_attr = f"{{{NS['r']}}}id"
for imgdata in container_elem.findall(".//v:imagedata", NS):
rel_id = imgdata.get(r_id_attr)
if rel_id:
return rel_id
return None
def _build_placeholder(attrs: Dict[str, str]) -> str:
ordered_keys = ["id", "name", "path", "format"]
pieces = []
for key in ordered_keys:
if key in attrs and attrs[key] is not None:
pieces.append(f'{key}="{escape(str(attrs[key]), quote=True)}"')
# Preserve extra attributes deterministically (sorted by name)
for key in sorted(k for k in attrs.keys() if k not in ordered_keys):
value = attrs[key]
if value is not None:
pieces.append(f'{key}="{escape(str(value), quote=True)}"')
return f"<drawing {' '.join(pieces)} />"
def extract_drawing_placeholder_from_element(
drawing_elem,
context: Optional[DrawingExtractionContext] = None,
include_extended_attrs: bool = True,
) -> str:
"""
Build a <drawing ... /> placeholder from a w:drawing element.
Behavior:
- Always emits id/name from wp:docPr when present.
- For embedded images (a:blip@r:embed): exports image and sets path/format.
- For linked images (a:blip@r:link): does not download; path is original link target.
- When no image reference exists (e.g. chart drawing): keeps id/name only.
"""
doc_pr = drawing_elem.find(".//wp:docPr", NS)
attrs = {
"id": doc_pr.get("id", "") if doc_pr is not None else "",
"name": doc_pr.get("name", "") if doc_pr is not None else "",
}
if include_extended_attrs:
rel_ref = _extract_blip_relationship(drawing_elem)
if rel_ref is not None and context is not None:
rel_kind, rel_id = rel_ref
rel = context.resolve_relationship(rel_id)
if rel is not None:
if rel_kind == "embed" and rel.rel_type == IMAGE_REL_TYPE:
rel_path = context.export_embedded_image(rel)
if rel_path:
attrs["path"] = rel_path
if rel.image_format:
attrs["format"] = rel.image_format
elif rel_kind == "link":
if rel.target:
attrs["path"] = rel.target
if rel.image_format:
attrs["format"] = rel.image_format
return _build_placeholder(attrs)
def extract_vml_image_placeholder_from_element(
container_elem,
context: Optional[DrawingExtractionContext] = None,
include_extended_attrs: bool = True,
) -> str:
"""
Build a <drawing ... /> placeholder from a w:pict or w:object element.
Legacy Word documents and OLE-embedded objects (Visio diagrams, equation
editor previews, etc.) expose their rendered image via VML rather than
DrawingML. The image is referenced through ``<v:imagedata r:id="..."/>``
inside ``<v:shape>``, and the underlying bytes are commonly EMF/WMF
metafiles. This function exports those bytes through the same context as
DrawingML images so EMF/WMF assets land in the blocks.assets directory
alongside PNG/JPEG ones.
The output placeholder format matches
``extract_drawing_placeholder_from_element`` so downstream consumers
treat both paths uniformly.
"""
shape = container_elem.find(".//v:shape", NS)
attrs = {
"id": shape.get("id", "") if shape is not None else "",
"name": shape.get("alt", "") if shape is not None else "",
}
if include_extended_attrs:
rel_id = _extract_imagedata_relationship(container_elem)
if rel_id and context is not None:
rel = context.resolve_relationship(rel_id)
if rel is not None and rel.rel_type == IMAGE_REL_TYPE:
# VML reuses r:id for both embedded image parts and externally
# linked images; only the resolved TargetMode tells us which.
# Treating an external relationship as embedded would call
# export_embedded_image() (which short-circuits on external)
# and silently drop the linked path.
if rel.target_mode.lower() == "external":
if rel.target:
attrs["path"] = rel.target
if rel.image_format:
attrs["format"] = rel.image_format
else:
rel_path = context.export_embedded_image(rel)
if rel_path:
attrs["path"] = rel_path
if rel.image_format:
attrs["format"] = rel.image_format
return _build_placeholder(attrs)
def parse_drawing_attributes(placeholder: str) -> Dict[str, str]:
"""Parse attributes from a <drawing ... /> placeholder."""
return {
name: unescape(value)
for name, value in DRAWING_ATTR_PATTERN.findall(placeholder)
}
def normalize_drawing_placeholder(
placeholder: str,
include_extended_attrs: bool = False,
) -> str:
"""
Normalize one drawing placeholder into canonical attribute order.
Args:
placeholder: Input placeholder string
include_extended_attrs: If False, keeps only id/name.
"""
attrs = parse_drawing_attributes(placeholder)
normalized = {
"id": attrs.get("id", ""),
"name": attrs.get("name", ""),
}
if include_extended_attrs:
if "path" in attrs:
normalized["path"] = attrs["path"]
if "format" in attrs:
normalized["format"] = attrs["format"]
for key, value in attrs.items():
if key not in {"id", "name", "path", "format"}:
normalized[key] = value
return _build_placeholder(normalized)
def normalize_drawing_placeholders_in_text(
text: str,
include_extended_attrs: bool = False,
) -> str:
"""Normalize all drawing placeholders inside a text blob."""
if not text:
return text
def _replace(match: re.Match) -> str:
return normalize_drawing_placeholder(
match.group(0),
include_extended_attrs=include_extended_attrs,
)
return DRAWING_TAG_PATTERN.sub(_replace, text)
+339
View File
@@ -0,0 +1,339 @@
"""Native DOCX IR builder: ``extract_docx_blocks`` output → :class:`IRDoc`.
Input contract: a list of block dicts as produced by
``lightrag.parser.docx.parse_document.extract_docx_blocks``. Each
block carries ``content`` text in which ``<table>``, ``<equation>`` and
``<drawing …/>`` placeholders are already embedded by the upstream parser.
The builder rewrites those placeholders into IR placeholder tokens
(``{{TBL:k}} / {{EQ:k}} / {{EQI:k}} / {{IMG:k}}``) and builds the matching
``IRTable`` / ``IREquation`` / ``IRDrawing`` items.
Asset bytes are extracted to disk by the upstream parser *before* this
builder runs (via ``DrawingExtractionContext`` passed to
``extract_docx_blocks``). The builder therefore declares assets with
``AssetSpec.source=None`` — the writer records each entry's size without
copying.
Block-vs-inline equation distinction follows the legacy native rule: an
``<equation>…</equation>`` tag is *block* iff each side is either the
content boundary or a ``\\n`` character. Anything else stays inline,
keeps its tag in block text without an id, and never enters
``equations.json``.
Positions are always emitted as ``IRPosition(type="paraid", range=[start,
end])`` where each side may be ``None`` (legacy / non-Word docx authors
sometimes omit ``w14:paraId``). The writer's ``to_jsonable`` faithfully
preserves the per-side null so consumers can distinguish "start missing"
vs "both missing".
"""
from __future__ import annotations
import itertools
import json
import re
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from typing import Any
from lightrag.parser.docx.drawing_image_extractor import (
DRAWING_TAG_PATTERN,
parse_drawing_attributes,
)
from lightrag.sidecar.ir import (
AssetSpec,
IRBlock,
IRDoc,
IRDrawing,
IREquation,
IRPosition,
IRTable,
)
_TABLE_TAG_RE = re.compile(r"<table>(.*?)</table>", re.DOTALL)
_EQUATION_TAG_RE = re.compile(r"<equation>(.*?)</equation>", re.DOTALL)
def _normalize_dimension(rows_value: Any) -> tuple[int, int]:
if not isinstance(rows_value, list):
return 0, 0
num_rows = len(rows_value)
num_cols = max((len(r) for r in rows_value if isinstance(r, list)), default=0)
return num_rows, num_cols
def _placeholder_keyspace() -> Callable[[str], str]:
"""Return a fresh counter producing ``{prefix}{N}`` keys (1-indexed)."""
counter = itertools.count(1)
return lambda prefix: f"{prefix}{next(counter)}"
def _safe_asset_ref_from_path(path_val: str, asset_prefix: str) -> str | None:
"""Return the path inside ``asset_prefix`` only when it is safe.
Native DOCX images are pre-extracted into ``<base>.blocks.assets/``.
Treat a drawing path as local only when the suffix is a clean POSIX
relative path. Unsafe local-looking paths are dropped instead of being
registered as assets or preserved as linked references.
"""
if not asset_prefix or not path_val.startswith(asset_prefix):
return None
rel_raw = path_val[len(asset_prefix) :]
if not rel_raw or "\\" in rel_raw:
return None
rel_path = PurePosixPath(rel_raw)
if rel_path.is_absolute():
return None
if any(part == ".." for part in rel_path.parts):
return None
rel = rel_path.as_posix()
if rel in {"", "."}:
return None
return rel
@dataclass
class _BlockBuilder:
"""Per-block scratch state for the three ``re.sub`` rewrite passes.
Keeping the replacer routines as bound methods (rather than closures
redefined inside the per-block loop) means they're compiled once at
class-load and the state they mutate — ``tables`` / ``drawings`` /
``equations`` / ``table_position`` — is held explicitly rather than
captured implicitly from the enclosing frame.
"""
next_key: Callable[[str], str]
assets: list[AssetSpec]
seen_asset_refs: set[str]
asset_prefix: str
block_table_headers: list[Any]
tables: list[IRTable] = field(default_factory=list)
drawings: list[IRDrawing] = field(default_factory=list)
equations: list[IREquation] = field(default_factory=list)
# Position of the *next* ``<table>`` placeholder within this block,
# used to look up the matching entry in ``block_table_headers``.
table_position: int = 0
def replace_table(self, match: "re.Match[str]") -> str:
table_body_raw = match.group(1)
try:
rows = json.loads(table_body_raw)
if not isinstance(rows, list):
rows = None
except json.JSONDecodeError:
rows = None
if rows is not None:
parsed_rows: list[list[str]] | None = [
[str(c) for c in r] if isinstance(r, list) else [str(r)] for r in rows
]
html: str | None = None
else:
parsed_rows = None
html = table_body_raw
num_rows, num_cols = _normalize_dimension(parsed_rows)
header_pos = self.table_position
self.table_position += 1
header_rows = (
self.block_table_headers[header_pos]
if header_pos < len(self.block_table_headers)
else None
)
# Treat empty list / explicit None identically: no header
# entry on the sidecar item.
table_header = header_rows if header_rows else None
placeholder = self.next_key("tb")
self.tables.append(
IRTable(
placeholder_key=placeholder,
rows=parsed_rows,
html=html,
num_rows=num_rows,
num_cols=num_cols,
caption="",
footnotes=[],
table_header=table_header,
body_override=table_body_raw,
)
)
return f"{{{{TBL:{placeholder}}}}}"
def replace_equation(self, match: "re.Match[str]") -> str:
latex = match.group(1)
source = match.string
start, end = match.start(), match.end()
is_block = (start == 0 or source[start - 1] == "\n") and (
end == len(source) or source[end] == "\n"
)
placeholder = self.next_key("eq")
self.equations.append(
IREquation(
placeholder_key=placeholder,
latex=latex,
is_block=is_block,
caption="",
footnotes=[],
)
)
token = "EQ" if is_block else "EQI"
return f"{{{{{token}:{placeholder}}}}}"
def replace_drawing(self, match: "re.Match[str]") -> str:
attrs = parse_drawing_attributes(match.group(0))
path_val = attrs.get("path", "") or ""
src_val = attrs.get("src", "") or ""
fmt = attrs.get("format", "") or ""
if not fmt and path_val:
fmt = Path(path_val).suffix.lower().lstrip(".")
# Two flavours of <drawing path="…">:
# 1. Local asset under <base>.blocks.assets/ — already
# extracted to disk by DrawingExtractionContext;
# register as AssetSpec(source=None) and let the
# writer resolve the path via asset_paths.
# 2. External/linked path (URL, or any path that does
# not live under asset_prefix) — pass through
# verbatim via IRDrawing.path_override; do NOT emit
# an AssetSpec (no on-disk bytes to materialize).
rel_inside_assets = _safe_asset_ref_from_path(path_val, self.asset_prefix)
if rel_inside_assets is not None:
asset_ref = rel_inside_assets
suggested_name = Path(rel_inside_assets).name or rel_inside_assets
if asset_ref and asset_ref not in self.seen_asset_refs:
self.assets.append(
AssetSpec(
ref=asset_ref,
suggested_name=suggested_name,
source=None, # already extracted to disk
)
)
self.seen_asset_refs.add(asset_ref)
path_override: str | None = None
else:
asset_ref = ""
# Only mark as an external/linked reference when the
# upstream parser actually emitted a path. An empty
# ``path=""`` should fall back to the regular asset-
# resolution path (which will also produce ``path=""``
# downstream) rather than masquerading as an explicit
# builder override.
path_override = (
None
if self.asset_prefix and path_val.startswith(self.asset_prefix)
else path_val or None
)
placeholder = self.next_key("im")
self.drawings.append(
IRDrawing(
placeholder_key=placeholder,
asset_ref=asset_ref,
fmt=fmt,
caption="",
footnotes=[],
src=src_val,
path_override=path_override,
)
)
return f"{{{{IMG:{placeholder}}}}}"
class NativeDocxIRBuilder:
"""Translate ``extract_docx_blocks`` output into an :class:`IRDoc`.
The builder is stateless — instantiate per call. ``asset_dir_name`` is
the relative name (without trailing slash) of ``<base>.blocks.assets/``
that the upstream parser used when emitting ``<drawing path>``
attributes; the builder strips that prefix when building
:attr:`AssetSpec.ref` so the writer's ref↔filename mapping has
predictable keys.
"""
def normalize(
self,
blocks: list[dict[str, Any]],
*,
document_name: str,
asset_dir_name: str,
parse_metadata: dict[str, Any] | None = None,
) -> IRDoc:
next_key = _placeholder_keyspace()
ir_blocks: list[IRBlock] = []
assets: list[AssetSpec] = []
seen_asset_refs: set[str] = set()
asset_prefix = f"{asset_dir_name}/" if asset_dir_name else ""
for block in blocks:
raw_content = block.get("content") or ""
heading = block.get("heading") or ""
level = int(block.get("level", 0) or 0)
parent_headings = list(block.get("parent_headings") or [])
# Preserve per-side nulls in [start, end].
uuid_start = block.get("uuid") or None
uuid_end = block.get("uuid_end") or None
builder = _BlockBuilder(
next_key=next_key,
assets=assets,
seen_asset_refs=seen_asset_refs,
asset_prefix=asset_prefix,
block_table_headers=list(block.get("table_headers") or []),
)
# Rewrite order matches the legacy native flow: tables, then
# equations, then drawings — each ``re.sub`` operates on the
# output of the previous pass.
content_template = _TABLE_TAG_RE.sub(builder.replace_table, raw_content)
content_template = _EQUATION_TAG_RE.sub(
builder.replace_equation, content_template
)
content_template = DRAWING_TAG_PATTERN.sub(
builder.replace_drawing, content_template
)
positions = [
IRPosition(type="paraid", range=[uuid_start, uuid_end]),
]
ir_blocks.append(
IRBlock(
content_template=content_template,
heading=heading,
level=level,
parent_headings=parent_headings,
positions=positions,
tables=builder.tables,
drawings=builder.drawings,
equations=builder.equations,
)
)
# doc_title: parse_metadata["first_heading"] when present, else file
# stem fallback (resolved here so the writer doesn't have to know).
first_heading = ""
if isinstance(parse_metadata, dict):
first_heading = str(parse_metadata.get("first_heading") or "")
doc_title = first_heading or (Path(document_name).stem or document_name)
return IRDoc(
document_name=document_name,
document_format=Path(document_name).suffix.lower().lstrip("."),
doc_title=doc_title,
split_option={},
blocks=ir_blocks,
assets=assets,
bbox_attributes=None,
)
__all__ = ["NativeDocxIRBuilder"]
+423
View File
@@ -0,0 +1,423 @@
#!/usr/bin/env python3
"""
ABOUTME: Resolves automatic numbering labels from DOCX documents
ABOUTME: Parses numbering.xml and computes rendered number strings
"""
import zipfile
from defusedxml import ElementTree as ET
from typing import Dict
NSMAP = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
class NumberingResolver:
"""
Resolves paragraph numbering to rendered label strings.
DOCX stores numbering definitions in numbering.xml:
- abstractNum: Defines format templates (lvlText like "%1.%2.")
- num: Links numId to abstractNumId
Each paragraph references: numId (which definition) + ilvl (which level)
"""
# Number format converters
FORMAT_CONVERTERS = {
"decimal": lambda n: str(n),
"lowerLetter": lambda n: chr(ord("a") + (n - 1) % 26),
"upperLetter": lambda n: chr(ord("A") + (n - 1) % 26),
"lowerRoman": lambda n: NumberingResolver._to_roman(n).lower(),
"upperRoman": lambda n: NumberingResolver._to_roman(n),
"chineseCountingThousand": lambda n: NumberingResolver._to_chinese(n),
"ideographTraditional": lambda n: "甲乙丙丁戊己庚辛壬癸"[(n - 1) % 10],
"bullet": lambda n: "",
"none": lambda n: "",
}
def __init__(self, docx_path: str):
self.abstract_nums: Dict[str, dict] = {} # abstractNumId -> level definitions
self.num_to_abstract: Dict[str, str] = {} # numId -> abstractNumId
self.counters: Dict[
str, Dict[int, int]
] = {} # numId -> {ilvl -> current_count}
self.start_overrides: Dict[
str, Dict[int, int]
] = {} # numId -> {ilvl -> start_value}
self.style_numpr: Dict[
str, dict
] = {} # styleId -> {numId, ilvl} from styles.xml
self.style_based_on: Dict[str, str] = {} # styleId -> basedOn styleId
# Smart numbering merge state (Word's rendering behavior)
self.last_numId: str = None # Previous paragraph's numId
self.last_abstract_id: str = None # Previous paragraph's abstractNumId
self.last_style_id: str = None # Previous paragraph's style ID
self._parse_numbering_xml(docx_path)
self._parse_styles_xml(docx_path)
def _parse_numbering_xml(self, docx_path: str):
"""Parse numbering.xml from DOCX archive"""
try:
with zipfile.ZipFile(docx_path, "r") as zf:
if "word/numbering.xml" not in zf.namelist():
return
tree = ET.parse(zf.open("word/numbering.xml"))
root = tree.getroot()
# Parse abstractNum definitions
for abstract in root.findall(".//w:abstractNum", NSMAP):
abstract_id = abstract.get(f"{{{NSMAP['w']}}}abstractNumId")
levels = {}
for lvl in abstract.findall("w:lvl", NSMAP):
ilvl = int(lvl.get(f"{{{NSMAP['w']}}}ilvl"))
start_elem = lvl.find("w:start", NSMAP)
start = (
int(start_elem.get(f"{{{NSMAP['w']}}}val"))
if start_elem is not None
else 1
)
num_fmt_elem = lvl.find("w:numFmt", NSMAP)
num_fmt = (
num_fmt_elem.get(f"{{{NSMAP['w']}}}val")
if num_fmt_elem is not None
else "decimal"
)
lvl_text_elem = lvl.find("w:lvlText", NSMAP)
lvl_text = (
lvl_text_elem.get(f"{{{NSMAP['w']}}}val")
if lvl_text_elem is not None
else "%1."
)
is_lgl_elem = lvl.find("w:isLgl", NSMAP)
is_lgl = False
if is_lgl_elem is not None:
val = is_lgl_elem.get(f"{{{NSMAP['w']}}}val")
is_lgl = val is None or val not in ("0", "false")
levels[ilvl] = {
"start": start,
"numFmt": num_fmt,
"lvlText": lvl_text,
"isLgl": is_lgl,
}
self.abstract_nums[abstract_id] = levels
# Parse num -> abstractNum mapping and startOverride
for num in root.findall(".//w:num", NSMAP):
num_id = num.get(f"{{{NSMAP['w']}}}numId")
abstract_ref = num.find("w:abstractNumId", NSMAP)
if abstract_ref is not None:
self.num_to_abstract[num_id] = abstract_ref.get(
f"{{{NSMAP['w']}}}val"
)
# Parse lvlOverride/startOverride for this num
for lvl_override in num.findall("w:lvlOverride", NSMAP):
ilvl = int(lvl_override.get(f"{{{NSMAP['w']}}}ilvl"))
start_override = lvl_override.find("w:startOverride", NSMAP)
if start_override is not None:
start_val = int(start_override.get(f"{{{NSMAP['w']}}}val"))
if num_id not in self.start_overrides:
self.start_overrides[num_id] = {}
self.start_overrides[num_id][ilvl] = start_val
except Exception:
# Silently ignore parsing errors - document may not have numbering
pass
def _parse_styles_xml(self, docx_path: str):
"""Parse styles.xml to get style-inherited numbering definitions"""
try:
with zipfile.ZipFile(docx_path, "r") as zf:
if "word/styles.xml" not in zf.namelist():
return
tree = ET.parse(zf.open("word/styles.xml"))
root = tree.getroot()
# Parse style definitions
for style in root.findall(".//w:style", NSMAP):
style_id = style.get(f"{{{NSMAP['w']}}}styleId")
if not style_id:
continue
# Check for basedOn (style inheritance)
based_on = style.find("w:basedOn", NSMAP)
if based_on is not None:
parent_id = based_on.get(f"{{{NSMAP['w']}}}val")
if parent_id:
self.style_based_on[style_id] = parent_id
# Check for numPr in style's pPr
pPr = style.find("w:pPr", NSMAP)
if pPr is not None:
numPr = pPr.find("w:numPr", NSMAP)
if numPr is not None:
num_id_elem = numPr.find("w:numId", NSMAP)
ilvl_elem = numPr.find("w:ilvl", NSMAP)
if num_id_elem is not None:
num_id = num_id_elem.get(f"{{{NSMAP['w']}}}val")
ilvl = (
int(ilvl_elem.get(f"{{{NSMAP['w']}}}val"))
if ilvl_elem is not None
else 0
)
self.style_numpr[style_id] = {
"numId": num_id,
"ilvl": ilvl,
}
except Exception:
# Silently ignore parsing errors
pass
def _get_numbering_from_style(self, style_id: str, visited=None) -> dict:
"""
Get numbering definition from style, following inheritance chain.
Args:
style_id: Style ID to look up
visited: Set of visited style IDs (to prevent circular references)
Returns:
dict with 'numId' and 'ilvl', or None
"""
if visited is None:
visited = set()
# Prevent circular references
if style_id in visited:
return None
visited.add(style_id)
# Check if this style has numPr
if style_id in self.style_numpr:
return self.style_numpr[style_id]
# Check parent style
if style_id in self.style_based_on:
parent_id = self.style_based_on[style_id]
return self._get_numbering_from_style(parent_id, visited)
return None
def reset_tracking_state(self):
"""
Reset numbering tracking state.
Call this when encountering structural breaks that should
interrupt numbering continuity:
- Section breaks (sectPr)
- Table boundaries (before and after tables)
This prevents incorrect numbering continuation across
document structure boundaries.
"""
self.last_numId = None
self.last_abstract_id = None
self.last_style_id = None
def get_label(self, para_element) -> str:
"""
Get rendered numbering label for a paragraph.
Checks both direct numPr and style-inherited numbering. Direct numPr
is a paragraph-local override and applies only to the current
paragraph; subsequent paragraphs that carry only pStyle fall back to
the style's numPr declared in styles.xml.
Args:
para_element: lxml Element for <w:p>
Returns:
Rendered label string (e.g., "1.1", "a)", "第一章") or empty string
"""
try:
pPr = para_element.find(f"{{{NSMAP['w']}}}pPr")
if pPr is None:
return ""
num_id = None
ilvl = 0
style_id = None
# Get pStyle (if present)
pStyle = pPr.find(f"{{{NSMAP['w']}}}pStyle")
if pStyle is not None:
style_id = pStyle.get(f"{{{NSMAP['w']}}}val")
# Check for direct numPr in paragraph
numPr = pPr.find(f"{{{NSMAP['w']}}}numPr")
if numPr is not None:
num_id_elem = numPr.find(f"{{{NSMAP['w']}}}numId")
ilvl_elem = numPr.find(f"{{{NSMAP['w']}}}ilvl")
if num_id_elem is not None:
num_id = num_id_elem.get(f"{{{NSMAP['w']}}}val")
ilvl = (
int(ilvl_elem.get(f"{{{NSMAP['w']}}}val"))
if ilvl_elem is not None
else 0
)
# If no direct numPr, fall back to style-inherited numbering.
# Direct numPr is a paragraph-local override in Word; it must not
# persist as a runtime default for the style, otherwise subsequent
# paragraphs that only carry pStyle will keep following the local
# override instead of the style's declared numPr.
if num_id is None and style_id:
style_num = self._get_numbering_from_style(style_id)
if style_num:
num_id = style_num["numId"]
ilvl = style_num["ilvl"]
# If still no numbering found, clear state and return empty
if num_id is None:
# We should use list structure breaking logic to reset last_numId, last_abstract_id and last_style_id
return ""
# Get abstract definition
abstract_id = self.num_to_abstract.get(num_id)
if abstract_id is None or abstract_id not in self.abstract_nums:
# Clear state for invalid numbering
self.last_numId = None
self.last_abstract_id = None
return ""
levels = self.abstract_nums[abstract_id]
if ilvl not in levels:
# Clear state for invalid level
self.last_numId = None
self.last_abstract_id = None
return ""
# Smart numbering merge: (Word's rendering behavior)
# When consecutive paragraphs have different numId but same abstractNumId,
# Word continues the numbering sequence rather than restarting.
# This happens regardless of whether the numId is new or style matches.
if (
self.last_numId is not None
and self.last_numId != num_id
and self.last_abstract_id == abstract_id
and self.last_numId in self.counters
):
# Merge: copy previous numId's counter to current numId
self.counters[num_id] = self.counters[self.last_numId].copy()
# Initialize/update counter
if num_id not in self.counters:
self.counters[num_id] = {}
# Initialize all parent levels if not present (for deep nested numbering)
for i in range(ilvl):
if i not in self.counters[num_id] and i in levels:
# Use startOverride if exists, otherwise use abstractNum's start value
if (
num_id in self.start_overrides
and i in self.start_overrides[num_id]
):
self.counters[num_id][i] = self.start_overrides[num_id][i]
else:
self.counters[num_id][i] = levels[i]["start"]
# Reset lower levels when higher level increments
for i in range(ilvl + 1, 10):
if i in self.counters[num_id]:
del self.counters[num_id][i]
# Initialize current level if needed
if ilvl not in self.counters[num_id]:
# Use startOverride if exists, otherwise use abstractNum's start value
if (
num_id in self.start_overrides
and ilvl in self.start_overrides[num_id]
):
self.counters[num_id][ilvl] = self.start_overrides[num_id][ilvl]
else:
self.counters[num_id][ilvl] = levels[ilvl]["start"]
else:
self.counters[num_id][ilvl] += 1
# Format the label using lvlText template
label = self._format_label(num_id, ilvl, levels)
# Update tracking state for next paragraph
self.last_numId = num_id
self.last_abstract_id = abstract_id
self.last_style_id = style_id
return label
except Exception:
# Return empty on any error to avoid breaking document parsing
return ""
def _format_label(self, num_id: str, ilvl: int, levels: dict) -> str:
"""Format label string by replacing %1, %2, etc."""
try:
lvl_text = levels[ilvl]["lvlText"]
result = lvl_text
current_is_lgl = levels[ilvl].get("isLgl", False)
for i in range(ilvl + 1):
if i in levels and i in self.counters.get(num_id, {}):
num_fmt = levels[i]["numFmt"]
if current_is_lgl and i < ilvl:
num_fmt = "decimal"
count = self.counters[num_id][i]
converter = self.FORMAT_CONVERTERS.get(num_fmt, lambda n: str(n))
formatted = converter(count)
result = result.replace(f"%{i + 1}", formatted)
return result
except Exception:
return ""
@staticmethod
def _to_roman(n: int) -> str:
"""Convert integer to Roman numeral"""
if n <= 0 or n >= 4000:
return str(n)
values = [
(1000, "M"),
(900, "CM"),
(500, "D"),
(400, "CD"),
(100, "C"),
(90, "XC"),
(50, "L"),
(40, "XL"),
(10, "X"),
(9, "IX"),
(5, "V"),
(4, "IV"),
(1, "I"),
]
result = ""
for value, numeral in values:
while n >= value:
result += numeral
n -= value
return result
@staticmethod
def _to_chinese(n: int) -> str:
"""Convert integer to Chinese numeral"""
digits = "零一二三四五六七八九"
if n <= 0 or n > 99:
return str(n)
if n < 10:
return digits[n]
if n < 20:
return "" + (digits[n % 10] if n % 10 else "")
if n < 100:
tens = n // 10
ones = n % 10
return digits[tens] + "" + (digits[ones] if ones else "")
return str(n)
+10
View File
@@ -0,0 +1,10 @@
"""
ABOUTME: OMML (Office Math Markup Language) to LaTeX conversion
"""
from .ommlparser import OMMLParser
def convert_omml_to_latex(omml_element) -> str:
"""Convert an m:oMath XML element to a LaTeX string."""
return OMMLParser().parse(omml_element)
+38
View File
@@ -0,0 +1,38 @@
"""
Postprocessing functions for cleaning up latex equations in linear format which don't give valid LaTeX.
"""
import re
clean_exps = {
r"\\degf": "&deg;F",
r"\\degc": "&deg;C",
r"(\\cbrt)(\w+)": r"\\sqrt[3]{\2}",
r"(\\qdrt)(\w+)": r"\\sqrt[4]{\2}",
r"\\sfrac": r"\\frac",
r"(\\o[i]+nt)(\w+)": r"\1{\2}",
r"\\bullet(\w+)": r"\\bullet \1",
r"\\sum([a-zA-Z0-9]+)": r"\\sum{\1}",
r"\\prod([a-zA-Z0-9]+)": r"\\prod{\1}",
r"\\amalg([a-zA-Z0-9]+)": r"\\amalg{\1}",
r"\\bigcup([a-zA-Z0-9]+)": r"\\bigcup{\1}",
r"\\bigcap([a-zA-Z0-9]+)": r"\\bigcap{\1}",
r"\\bigvee([a-zA-Z0-9]+)": r"\\bigvee{\1}",
r"\\bigwedge([a-zA-Z0-9]+)": r"\\bigwedge{\1}",
r"\\lfloor([a-zA-Z0-9]+)": r"\\lfloor{\1}",
r"\\lceil([a-zA-Z0-9]+)": r"\\lceil{\1}",
r"\\lim\\below\{(.+)\}\{(.+)\}": r"\\lim_{\1}{\2}",
r"\\min\\below\{(.+)\}\{(.+)\}": r"\\min_{\1}{\2}",
r"\\max\\below\{(.+)\}\{(.+)\}": r"\\max_{\1}{\2}",
}
def clean_exp(exp):
"""
Takes in a linear expression and converts known invalid LaTeX equations to valid LaTeX
:param exp:str - An equation in invalid syntax
:return :str - A valid equation
"""
for e in clean_exps:
exp = re.sub(e, clean_exps[e], exp)
return exp
+511
View File
@@ -0,0 +1,511 @@
from xml.etree.cElementTree import Element
from .utils import qn
class OMMLParser:
"""
Parser class for reading OMML and converting it into LaTeX.
"""
FUNCTION_MAP = {
"sin": "\\sin",
"cos": "\\cos",
"tan": "\\tan",
"cot": "\\cot",
"sec": "\\sec",
"csc": "\\csc",
"sinh": "\\sinh",
"cosh": "\\cosh",
"tanh": "\\tanh",
"coth": "\\coth",
"sech": "\\operatorname{sech}",
"csch": "\\operatorname{csch}",
"log": "\\log",
"ln": "\\ln",
"min": "\\min",
"max": "\\max",
"lim": "\\lim",
}
def _normalize_func_name(self, content: str) -> str:
if not content:
return content
if content.startswith("\\"):
return content
key = content.strip()
mapped = self.FUNCTION_MAP.get(key)
return mapped if mapped else content
def parse(self, root: Element) -> str:
"""
Parses an m:oMath OMML tag into LaTeX.
:param root: An m:oMath OMML tag
:return: The LaTeX representation of the OMML input
"""
text = ""
try:
if root.tag == qn("m:t"):
return self.parse_t(root)
for child in root:
if child.tag in self.parsers:
text += self.parsers[child.tag](self, child)
except AttributeError:
# In case of missing attributes on OMML tags,
# we return an empty string (ref:issue_14)
return ""
return text
def parse_e(self, root: Element) -> str:
text = ""
for child in root:
text += self.parse(child)
return text
def parse_r(self, root: Element) -> str:
# TODO: Add support for m:rPr and m:scr to support different character styles
# For now, we just parse the text content of m:r
text = ""
for child in root:
text += self.parse(child)
return text
def parse_t(self, root: Element):
symbol_map = {
"": "\\triangleq",
"": "\\stackrel{\\tiny def}{=}",
"": "\\stackrel{\\tiny m}{=}",
}
replacements = {
"&lt;": "\\lt ",
"&gt;": "\\gt ",
"&le;": "\\leq ",
"&ge;": "\\geq ",
"": "\\infty ",
"<": "\\lt ",
">": "\\gt ",
"": "\\leq ",
"": "\\geq ",
}
text = root.text.split()
if not text:
return " "
for i, t in enumerate(text):
if t in symbol_map:
text[i] = symbol_map[t]
for key, value in replacements.items():
for i, t in enumerate(text):
text[i] = t.replace(key, value)
return " ".join(text)
def parse_acc(self, root: Element) -> str:
character_map = {
768: "\\grave",
769: "\\acute",
770: "\\hat",
771: "\\tilde",
773: "\\bar",
774: "\\breve",
775: "\\dot",
776: "\\ddot",
780: "\\check",
831: "\\overline{\\overline",
8400: "\\overset\\leftharpoonup",
8401: "\\overset\\rightharpoonup",
8406: "\\overleftarrow",
8407: "\\overrightarrow",
8411: "\\dddot",
8417: "\\overset\\leftrightarrow",
}
text = ""
accent = 770
for child in root:
if child.tag == qn("m:accPr"):
for child2 in child:
if child2.tag == qn("m:chr"):
val = child2.attrib.get(qn("m:val"))
if val:
try:
accent = ord(val)
except TypeError:
pass
accent_cmd = character_map.get(accent)
if accent_cmd is None:
accent_cmd = character_map.get(770, "\\hat")
text += accent_cmd + "{"
for child in root:
if child.tag == qn("m:e"):
text += self.parse(child)
text += "}"
if accent == 831:
text += "}"
return text
def parse_bar(self, root: Element) -> str:
text = "\\overline{"
for child in root:
if child.tag == qn("m:barPr"):
for child2 in child:
if child2.tag == qn("m:pos"):
if child2.attrib.get(qn("m:val")) == "bot":
text = "\\underline{"
for child in root:
if child.tag == qn("m:e"):
text += self.parse(child)
text += "}"
return text
def parse_border_box(self, root: Element) -> str:
text = "\\boxed{"
for child in root:
if child.tag == qn("m:e"):
text += self.parse(child)
text += "}"
return text
def parse_box(self, root: Element) -> str:
text = ""
for child in root:
text += self.parse(child)
return text
def parse_group_chr(self, root: Element) -> str:
character_map = {
"": "\\leftarrow",
"": "\\rightarrow",
"": "\\leftrightarrow",
"": "\\Leftarrow",
"": "\\Rightarrow",
"": "\\Leftrightarrow",
}
text = "\\underbrace{"
bottom = False
for child in root:
if child.tag == qn("m:groupChrPr"):
for child2 in child:
if child2.tag == qn("m:chr"):
char = child2.attrib.get(qn("m:val"))
if char in character_map:
text = character_map[char]
for child2 in child:
if (
child2.tag == qn("m:pos")
and child2.attrib.get(qn("m:val")) == "top"
):
# If m:pos is set to "top", the symbol is supposed to
# be on top and the text is actually supposed to be under
bottom = True
content = ""
for child in root:
if child.tag == qn("m:e"):
content = self.parse(child)
if text == "\\underbrace{":
if bottom:
text = "\\overbrace{" + content + "}"
else:
text += content + "}"
else:
if not bottom:
text = "\\overset{" + content + "}" + "{" + text + "}"
else:
text = "\\underset{" + content + "}" + "{" + text + "}"
return text
def parse_d(self, root: Element) -> str:
bracket_map = {
"(": "\\left(",
")": "\\right)",
"[": "\\left[",
"]": "\\right]",
"{": "\\left{",
"}": "\\right}",
"": "\\left\\langle",
"": "\\right\\rangle",
"": "\\left\\langle",
"": "\\right\\rangle",
"": "\\left\\lfloor",
"": "\\right\\rfloor",
"": "\\left\\lceil",
"": "\\right\\rceil",
"|": "\\left|",
"": "\\left\\|",
"": "[\\![",
"": "]\\!]",
}
text = ""
start_bracket = "("
end_bracket = ")"
seperator = "|"
is_matrix = False
for child in root:
for child2 in child:
if child.tag == qn("m:dPr"):
if child2.tag == qn("m:begChr"):
start_bracket = child2.attrib.get(qn("m:val"))
if child2.tag == qn("m:endChr"):
end_bracket = child2.attrib.get(qn("m:val"))
if child2.tag == qn("m:sepChr"):
seperator = child2.attrib.get(qn("m:val"))
if child2.tag == qn("m:m"):
is_matrix = True
for child in root:
if child.tag == qn("m:e"):
if text:
text += seperator
text += self.parse(child)
end_bracket_replacements = {
"|": "\\right|",
"": "\\right\\|",
"[": "\\right[",
}
start_bracket_replacements = {
"]": "\\left]",
}
start = ""
end = ""
if start_bracket:
if start_bracket in start_bracket_replacements:
start = start_bracket_replacements[start_bracket] + " "
elif start_bracket in bracket_map:
start = bracket_map[start_bracket] + " "
else:
start = "\\left(" + " "
if end_bracket:
if end_bracket in end_bracket_replacements:
end = " " + end_bracket_replacements[end_bracket]
elif end_bracket in bracket_map:
end = " " + bracket_map[end_bracket]
else:
end = " " + "\\right)"
# If there is no end bracket and this tag contains an m:eqArr tag as a
# child, we assume that the eqArr should be translated to a cases environment
# instead of an eqnarray* environment.
else:
for child in root:
if child.tag == qn("m:e"):
for child2 in child:
if child2.tag == qn("m:eqArr"):
text = text.replace("\\begin{eqnarray*}", "")
text = text.replace("\\end{eqnarray*}", "")
return "\\begin{cases} " + text + " \\end{cases}"
if is_matrix:
if start_bracket == "(" and end_bracket == ")":
return text.replace("{matrix}", "{pmatrix}")
elif start_bracket == "|" and end_bracket == "|":
return text.replace("{matrix}", "{vmatrix}")
elif start_bracket == "" and end_bracket == "":
return text.replace("{matrix}", "{Vmatrix}")
else:
return text.replace("{matrix}", "{bmatrix}")
return start + text + end
def parse_eq_arr(self, root: Element) -> str:
text = "\\begin{eqnarray*}"
for child in root:
if child.tag == qn("m:e"):
text += self.parse(child) + " \\\\"
text += "\\end{eqnarray*}"
return text
def parse_f(self, root: Element) -> str:
text = "\\frac{"
num = ""
den = ""
is_binom = False
for child in root:
if child.tag == qn("m:fPr"):
for child2 in child:
if (
child2.tag == qn("m:type")
and child2.attrib.get(qn("m:val")) == "noBar"
):
is_binom = True
if child.tag == qn("m:num"):
num = self.parse(child)
if child.tag == qn("m:den"):
den = self.parse(child)
if is_binom:
text = "\\genfrac{}{}{0pt}{}{"
text += num + "}{" + den + "}"
return text
def parse_m(self, root: Element) -> str:
text = "\\begin{matrix} "
text += self.parse(root)[:-3] # Remove the last ' \\'
text += "\\end{matrix}"
return text
def parse_mr(self, root: Element) -> str:
text = ""
for child in root:
if child.tag == qn("m:e"):
text += self.parse(child) + " & "
return text[:-2] + "\\\\ " # Remove the last ' & '
def parse_func(self, root: Element) -> str:
subscript = ""
superscript = ""
text = ""
func_name = "sin"
for child in root:
if child.tag == qn("m:fName"):
for child2 in child:
if child2.tag in [qn("m:sSup"), qn("m:sSub"), qn("m:r")]:
for child3 in child2:
if child3.tag == qn("m:sub"):
subscript = self.parse(child3)
if child3.tag == qn("m:sup"):
superscript = self.parse(child3)
if child3.tag == qn("m:t") or child3.tag == qn("m:e"):
func_name = self.parse(child3)
elif child2.tag == qn("m:limLow"):
for child3 in child2:
if child3.tag == qn("m:lim"):
for child4 in child3:
subscript += self.parse(child4)
if child3.tag == qn("m:e"):
func_name = self.parse(child3)
if child.tag == qn("m:e"):
text += self.parse(child)
if func_name in ["lim", "max", "min"]:
return f"\\{func_name}\\limits_{{{subscript}}}^{{{superscript}}}{{{text}}}"
if func_name not in self.FUNCTION_MAP:
return f"{{{func_name}}}^{{{superscript}}}_{{{subscript}}}{{{text}}}"
return (
self.FUNCTION_MAP[func_name]
+ f"_{{{subscript}}}^{{{superscript}}}{{{text}}}"
)
def parse_s_sup(self, root: Element) -> str:
content = ""
exp_content = ""
for child in root:
if child.tag == qn("m:e"):
content = self.parse(child)
if child.tag == qn("m:sup"):
exp_content = self.parse(child)
content = self._normalize_func_name(content)
return f"{{{content}}}^{{{exp_content}}}"
def parse_s_sub(self, root: Element) -> str:
content = ""
sub_content = ""
for child in root:
if child.tag == qn("m:e"):
content = self.parse(child)
if child.tag == qn("m:sub"):
sub_content = self.parse(child)
content = self._normalize_func_name(content)
return f"{{{content}}}_{{{sub_content}}}"
def parse_s_sub_sup(self, root: Element) -> str:
content = ""
sub_content = ""
exp_content = ""
for child in root:
if child.tag == qn("m:e"):
content = self.parse(child)
if child.tag == qn("m:sub"):
sub_content = self.parse(child)
if child.tag == qn("m:sup"):
exp_content = self.parse(child)
content = self._normalize_func_name(content)
return f"{{{content}}}_{{{sub_content}}}^{{{exp_content}}}"
def parse_s_pre(self, root: Element) -> str:
content = ""
sub_content = ""
exp_content = ""
for child in root:
if child.tag == qn("m:e"):
content = self.parse(child)
if child.tag == qn("m:sub"):
sub_content = self.parse(child)
if child.tag == qn("m:sup"):
exp_content = self.parse(child)
return "{}^{" + exp_content + "}_{" + sub_content + "}{" + content + "}"
def parse_rad(self, root: Element) -> str:
content = ""
order = ""
for child in root:
if child.tag == qn("m:deg"):
order = self.parse(child)
if child.tag == qn("m:e"):
content += self.parse(child)
if order:
return f"\\sqrt[{order}]{{{content}}}"
return f"\\sqrt{{{content}}}"
def parse_nary(self, root: Element) -> str:
character_map = {
8719: "\\prod",
8720: "\\coprod",
8721: "\\sum",
8747: "\\int",
8748: "\\iint",
8749: "\\iiint",
8750: "\\oint",
8751: "\\oiint",
8752: "\\oiiint",
8896: "\\bigwedge",
8897: "\\bigvee",
8898: "\\bigcap",
8899: "\\bigcup",
}
char = 8747
for child in root:
if child.tag == qn("m:naryPr"):
for child2 in child:
if child2.tag == qn("m:chr"):
val = child2.attrib.get(qn("m:val"))
if val:
try:
char = ord(val)
except TypeError:
pass
text = character_map.get(char, character_map[8721])
sub = ""
sup = ""
content = ""
for child in root:
if child.tag == qn("m:sub"):
sub = self.parse(child)
if child.tag == qn("m:sup"):
sup = self.parse(child)
if child.tag == qn("m:e"):
content = self.parse(child)
if sub:
text += f"_{{{sub}}}"
if sup:
text += f"^{{{sup}}}"
text += "{" + content + "}"
return text
parsers = {
qn("m:r"): parse_r,
qn("m:acc"): parse_acc,
qn("m:borderBox"): parse_border_box,
qn("m:bar"): parse_bar,
qn("m:box"): parse_box,
qn("m:d"): parse_d,
qn("m:e"): parse_e,
qn("m:groupChr"): parse_group_chr,
qn("m:f"): parse_f,
qn("m:sSup"): parse_s_sup,
qn("m:sSub"): parse_s_sub,
qn("m:sSubSup"): parse_s_sub_sup,
qn("m:sPre"): parse_s_pre,
qn("m:t"): parse_t,
qn("m:rad"): parse_rad,
qn("m:nary"): parse_nary,
qn("m:eqArr"): parse_eq_arr,
qn("m:func"): parse_func,
qn("m:m"): parse_m,
qn("m:mr"): parse_mr,
}
+40
View File
@@ -0,0 +1,40 @@
"""
Utility functions to extract text from the supported mathematical equations from xml tags and
convert them into LaTeX
"""
from .cleaners import clean_exp
ns_map = {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"m": "http://schemas.openxmlformats.org/officeDocument/2006/math",
}
def linear_expression(tag):
"""
Just returns the text contained in the given tag while setting docxlatex_skip_iteration flags
for all its children.
:param tag:defusedxml.Element - An xml element which contains a math equation in linear form
:return text:str - The equation in valid LaTeX syntax
"""
text = ""
for child in tag.iter():
child.set("docxlatex_skip_iteration", True)
text += child.text if child.text is not None else ""
text = clean_exp(text)
return text
def qn(tag):
"""
A utility function to turn a namespace
prefixed tag name into a Clark-notation qualified tag name for lxml. For
example, qn('m:oMath') returns '{http://schemas.openxmlformats.org/officeDocument/2006/math}oMath'
:param tag:str - A namespace-prefixed tag name
:return qn:str - A Clark-notation qualified name tag for lxml.
"""
prefix, tag_root = tag.split(":")
uri = ns_map[prefix]
return "{{{}}}{}".format(uri, tag_root)
+932
View File
@@ -0,0 +1,932 @@
#!/usr/bin/env python3
"""
ABOUTME: Parses DOCX documents into text blocks using python-docx
ABOUTME: Extracts automatic numbering, splits by headings, converts tables to JSON
"""
import json
import sys
try:
from docx import Document
from docx.opc.exceptions import PackageNotFoundError
except ImportError as exc:
# Raise instead of sys.exit: this module is imported in-process by the
# gunicorn/uvicorn worker, where a SystemExit would tear down the whole
# worker rather than surfacing a normal, catchable error.
raise ImportError(
"python-docx not installed. Run: pip install python-docx"
) from exc
from lightrag.parser._markdown import (
render_heading_line,
strip_heading_markdown_prefix,
)
from .numbering_resolver import NumberingResolver
from .table_extractor import TableExtractor
from .drawing_image_extractor import (
DrawingExtractionContext,
extract_drawing_placeholder_from_element,
extract_vml_image_placeholder_from_element,
)
# Constants for content validation (character-based for UI/display)
MAX_HEADING_LENGTH = 200 # Maximum heading length in characters (UI constraint)
# OOXML tracked-change/comment tags whose subtree must be dropped so we only
# surface the *final* revised text. w:ins / w:moveTo are kept via default
# recursion so inserted/moved-in content survives.
_SKIP_REVISION_TAGS = frozenset({"del", "moveFrom"})
_SKIP_COMMENT_TAGS = frozenset(
{"commentRangeStart", "commentRangeEnd", "commentReference", "annotationRef"}
)
_SKIP_PARAGRAPH_TAGS = _SKIP_REVISION_TAGS | _SKIP_COMMENT_TAGS
class DocxContentError(ValueError):
"""DOCX content violates a parsing constraint (heading/table/anchor limits).
Raised instead of calling ``sys.exit`` so the pipeline's per-document
``except Exception`` handler marks just that document FAILED while the
gunicorn/uvicorn worker process keeps running. Subclasses ``ValueError``
(i.e. an ``Exception``, not ``BaseException``) so the existing pipeline
handlers catch it.
"""
def format_error(title: str, details: str, solution: str) -> str:
"""
Build a friendly, formatted error message (title / details / SOLUTION).
Args:
title: Error title
details: Detailed error information
solution: Suggested solution steps
Returns:
str: The formatted multi-line message.
"""
return (
"\n"
+ "=" * 68
+ f"\nERROR: {title}\n"
+ "=" * 68
+ f"\n\n{details}"
+ "\n\nSOLUTION:\n"
+ solution
+ "\n\n"
+ "=" * 68
+ "\n"
)
def print_error(title: str, details: str, solution: str):
"""Print a friendly, formatted error message to stderr."""
print(format_error(title, details, solution), file=sys.stderr)
def _diagnose_invalid_docx(file_path: str) -> tuple[str, str]:
"""Diagnose why a ``.docx`` file is not a valid OOXML/ZIP package.
python-docx raises ``PackageNotFoundError("Package not found at '...'")``
both when the path is missing AND when the file exists but is not a valid
zip. By the time this runs the file has already been confirmed to exist
(the native worker validates ``p.exists()`` first), so the real cause is a
corrupt file or a non-DOCX payload wearing a ``.docx`` extension. Sniff the
magic bytes to name the actual format so the error message reflects the
real problem instead of an empty "not found".
Returns a ``(details, solution)`` tuple for :func:`format_error`. Reads only
the file header and never raises — any IO failure degrades to a generic
"cannot read" diagnosis.
"""
import zipfile
convert_solution = (
" 1. Open the file in Microsoft Word or WPS\n"
' 2. Use "Save As" and choose "Word Document (*.docx)"\n'
" 3. Re-upload the converted .docx to LightRAG"
)
try:
with open(file_path, "rb") as f:
head = f.read(8)
except OSError as exc:
return (
f"The file at '{file_path}' could not be read: {exc}",
" 1. Verify the file exists and is readable\n"
" 2. Re-upload it to LightRAG",
)
if not head:
return (
f"The file at '{file_path}' is empty (0 bytes). The upload was "
"likely truncated or the source file is corrupt.",
" 1. Check the original document opens correctly\n"
" 2. Re-upload a complete copy to LightRAG",
)
if head.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"):
# OLE2 Compound File — the legacy binary Word 97-2003 .doc format.
return (
f"The file at '{file_path}' is a legacy Word 97-2003 (.doc) "
"document saved with a .docx extension. The .doc binary format is "
"not a ZIP/OOXML package and cannot be parsed by the native engine.",
convert_solution,
)
if head.startswith(b"{\\rtf"):
return (
f"The file at '{file_path}' is an RTF document saved with a .docx "
"extension. RTF is not a ZIP/OOXML package.",
convert_solution,
)
if head.startswith(b"%PDF"):
return (
f"The file at '{file_path}' is a PDF saved with a .docx extension. "
"It is not a ZIP/OOXML package.",
" 1. Convert the PDF to .docx, or upload it through a PDF-capable "
"parser engine (e.g. mineru/docling)\n"
" 2. Re-upload to LightRAG",
)
stripped = head.lstrip()
if stripped.startswith(b"<"):
# <?xml ...>, <html ...>, or Word 2003 "<w:wordDocument>" flat XML.
return (
f"The file at '{file_path}' is an HTML or XML document saved with a "
".docx extension, not a ZIP/OOXML package.",
convert_solution,
)
if head.startswith(b"PK\x03\x04") and not zipfile.is_zipfile(file_path):
# Has the ZIP local-file-header magic but the archive is unreadable.
return (
f"The file at '{file_path}' starts like a ZIP archive but is "
"truncated or corrupt, so it cannot be opened as a DOCX package.",
" 1. Check the original document opens correctly\n"
" 2. Re-upload a complete, uncorrupted copy to LightRAG",
)
return (
f"The file at '{file_path}' is not a valid DOCX (ZIP/OOXML) package. "
"It is either corrupt or a different file format saved with a .docx "
"extension.",
convert_solution,
)
def truncate_heading(heading_text: str, para_id: str = None) -> str:
"""
Truncate heading if it exceeds MAX_HEADING_LENGTH.
Args:
heading_text: The heading text to check
para_id: Optional paragraph ID for warning message
Returns:
str: Original heading if within limit, truncated heading with "..." if too long
"""
if len(heading_text) > MAX_HEADING_LENGTH:
truncated = heading_text[: MAX_HEADING_LENGTH - 3] + "..."
location = f" (para_id: {para_id})" if para_id else ""
print(
f"Warning: Heading truncated (length {len(heading_text)} > max {MAX_HEADING_LENGTH}){location}: "
f'"{truncated}"',
file=sys.stderr,
)
return truncated
return heading_text
def validate_heading_length(heading_text: str, para_id: str):
"""
Validate that heading length does not exceed MAX_HEADING_LENGTH.
Args:
heading_text: The heading text to validate
para_id: The paragraph ID for error reporting
Raises:
DocxContentError: if heading exceeds maximum length
"""
if len(heading_text) > MAX_HEADING_LENGTH:
preview = (
heading_text[:100] + "..." if len(heading_text) > 100 else heading_text
)
raise DocxContentError(
format_error(
f"Heading too long ({len(heading_text)} characters, max {MAX_HEADING_LENGTH})",
f"The following heading exceeds the maximum allowed length:\n\n{preview}\n\n"
f"Location(para_id): {para_id}\n"
f"Actual length: {len(heading_text)} characters",
" 1. Open the document in Microsoft Word\n"
f" 2. Shorten this heading to {MAX_HEADING_LENGTH} characters or less\n"
" 3. Re-upload it to LightRAG",
)
)
def find_first_valid_para_id(para_ids: list) -> str | None:
"""
Find the first valid paraId in a 2D array of paraIds.
Args:
para_ids: 2D list of paraIds from table cells
Returns:
First non-None paraId found, or None when every cell lacks a paraId.
Callers must tolerate ``None`` and treat it as a tracking gap rather
than a fatal error (legacy / non-Word docx authors omit ``w14:paraId``
attributes and we want to keep parsing).
"""
for row in para_ids:
for para_id in row:
if para_id:
return para_id
return None
def find_last_valid_para_id(para_ids: list) -> str | None:
"""
Find the last valid paraId in a 2D array of paraIds.
Returns the last non-None paraId, falling back to the first valid one
when reverse-iteration does not yield anything (single-paraId tables),
and finally ``None`` when every cell lacks a paraId.
"""
for row in reversed(para_ids):
for para_id in reversed(row):
if para_id:
return para_id
return find_first_valid_para_id(para_ids)
def _table_has_any_paraid(para_ids: list) -> bool:
"""True when at least one cell in the 2D paraId grid carries an id."""
return find_first_valid_para_id(para_ids) is not None
def extract_para_id(para_element) -> str:
"""
Extract w14:paraId attribute from paragraph element.
Args:
para_element: lxml paragraph element
Returns:
8-character hex paraId, or ``None`` when the paragraph carries no
``w14:paraId`` attribute (legacy / non-Word docx authors). Callers
propagate the ``None`` upward — the LightRAG adapter counts these
and surfaces a single warning per document.
"""
return para_element.get(
"{http://schemas.microsoft.com/office/word/2010/wordml}paraId"
)
def parse_styles_outline_levels(docx_path: str) -> dict:
"""
Parse styles.xml to extract outlineLvl definitions for each style,
following style inheritance chain (basedOn).
Args:
docx_path: Path to DOCX file
Returns:
dict: styleId -> outlineLvl (0-8 for headings, 9 for body text)
"""
import zipfile
try:
from defusedxml import ElementTree as ET
except ImportError:
from xml.etree import ElementTree as ET
styles_outline = {} # styleId -> outlineLvl (directly defined)
style_based_on = {} # styleId -> parent styleId
try:
with zipfile.ZipFile(docx_path, "r") as zf:
if "word/styles.xml" not in zf.namelist():
return styles_outline
tree = ET.parse(zf.open("word/styles.xml"))
root = tree.getroot()
ns = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
# First pass: collect outlineLvl and basedOn for all styles
for style in root.findall(f".//{{{ns}}}style"):
style_id = style.get(f"{{{ns}}}styleId")
if not style_id:
continue
# Check for basedOn (style inheritance)
based_on = style.find(f"{{{ns}}}basedOn")
if based_on is not None:
parent_id = based_on.get(f"{{{ns}}}val")
if parent_id:
style_based_on[style_id] = parent_id
# Check for outlineLvl in style's pPr
pPr = style.find(f"{{{ns}}}pPr")
if pPr is not None:
outline_lvl_elem = pPr.find(f"{{{ns}}}outlineLvl")
if outline_lvl_elem is not None:
level = int(outline_lvl_elem.get(f"{{{ns}}}val"))
styles_outline[style_id] = level
# Second pass: resolve inheritance chain for styles without direct outlineLvl
def get_outline_level(style_id: str, visited: set = None) -> int:
if visited is None:
visited = set()
if style_id in visited:
return None # Prevent circular references
visited.add(style_id)
# If this style directly defines outlineLvl, return it
if style_id in styles_outline:
return styles_outline[style_id]
# Otherwise check parent style
if style_id in style_based_on:
parent_id = style_based_on[style_id]
return get_outline_level(parent_id, visited)
return None
# Fill in missing outlineLvl from inheritance chain
all_style_ids = set(styles_outline.keys()) | set(style_based_on.keys())
for style_id in all_style_ids:
if style_id not in styles_outline:
level = get_outline_level(style_id)
if level is not None:
styles_outline[style_id] = level
except Exception:
# Silently ignore parsing errors
pass
return styles_outline
def get_heading_level(para_element, styles_outline_map: dict) -> int:
"""
Get heading level from paragraph, checking both direct format and style.
Priority: paragraph outlineLvl > style outlineLvl
Args:
para_element: lxml paragraph element
styles_outline_map: dict of styleId -> outlineLvl from styles.xml
Returns:
int: 0-8 for heading levels (0=level 1, 1=level 2, etc.), None for non-heading
"""
# 1. Check paragraph direct format
pPr = para_element.find(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pPr"
)
if pPr is not None:
outline_elem = pPr.find(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}outlineLvl"
)
if outline_elem is not None:
level = int(
outline_elem.get(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val"
)
)
# Only 0-8 are true heading levels (9 is body text)
if level < 9:
return level
else:
return None # Level 9 is body text
# 2. Check style definition's outlineLvl
if pPr is not None:
pStyle_elem = pPr.find(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pStyle"
)
if pStyle_elem is not None:
style_id = pStyle_elem.get(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val"
)
if style_id and style_id in styles_outline_map:
level = styles_outline_map[style_id]
if level < 9:
return level
else:
return None
return None
def extract_text_from_run(
run,
ns: dict,
drawing_context: DrawingExtractionContext = None,
) -> str:
"""
Extract text from a run element, preserving superscript/subscript with markup.
Converts Word formatting to HTML-like tags:
- Superscript: <sup>text</sup>
- Subscript: <sub>text</sub>
- Normal text: unchanged
Args:
run: lxml run element (w:r)
ns: XML namespace dictionary
Returns:
Text string with <sup>/<sub> markup for formatted portions
"""
text = ""
# Check for vertAlign in rPr (superscript/subscript)
vert_align = None
rPr = run.find("w:rPr", ns)
if rPr is not None:
vert_elem = rPr.find("w:vertAlign", ns)
if vert_elem is not None:
vert_align = vert_elem.get(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val"
)
# Extract text content from run children
for child in run:
tag = child.tag.split("}")[-1] # Remove namespace
if tag == "t" and child.text:
text += child.text
elif tag == "tab":
text += "\t"
elif tag == "br":
# Handle line breaks - textWrapping or no type = soft line break
br_type = child.get(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}type"
)
if br_type in (None, "textWrapping"):
text += "\n"
# Skip page and column breaks (layout elements)
elif tag == "drawing":
text += extract_drawing_placeholder_from_element(
child,
context=drawing_context,
include_extended_attrs=True,
)
elif tag in ("pict", "object"):
text += extract_vml_image_placeholder_from_element(
child,
context=drawing_context,
include_extended_attrs=True,
)
# Apply superscript/subscript markup if needed
if text and vert_align == "superscript":
return f"<sup>{text}</sup>"
elif text and vert_align == "subscript":
return f"<sub>{text}</sub>"
return text
def extract_paragraph_content(
element,
ns,
drawing_context: DrawingExtractionContext = None,
) -> str:
"""
Extract text and equations from a paragraph element in document order.
Handles w:r (text runs), m:oMath (inline equations), and m:oMathPara
(block equations). Recurses into container elements (e.g., w:hyperlink,
w:ins, w:sdt, w:fldSimple, w:smartTag) to avoid dropping content.
Args:
element: lxml paragraph element (w:p)
ns: XML namespace dictionary
Returns:
Text string with equations wrapped in <equation> tags
"""
parts = []
def append_from(node) -> None:
tag = node.tag.split("}")[-1]
# Drop tracked-change deletions (w:del/w:moveFrom) and comment markers
# (w:commentRangeStart/End, w:commentReference, w:annotationRef) so the
# output only contains the final revised text without annotation glyphs.
if tag in _SKIP_PARAGRAPH_TAGS:
return
if tag == "r":
parts.append(
extract_text_from_run(node, ns, drawing_context=drawing_context)
)
return
if tag == "oMath":
from .omml import convert_omml_to_latex
latex = convert_omml_to_latex(node)
if latex:
parts.append(f"<equation>{latex}</equation>")
return
if tag == "oMathPara":
from .omml import convert_omml_to_latex
for omath in node:
if omath.tag.split("}")[-1] == "oMath":
latex = convert_omml_to_latex(omath)
if latex:
parts.append(f"<equation>{latex}</equation>")
return
for child in node:
append_from(child)
for child in element:
append_from(child)
return "".join(parts)
def _is_table_empty(rows: list) -> bool:
"""Return True iff every cell in ``rows`` is whitespace-only."""
return all(not (cell or "").strip() for row in rows for cell in row)
def _collect_table_headers(paragraphs: list) -> list:
"""Collect per-table cross-page header rows from ``is_table`` paragraphs.
The returned list is aligned 1:1 with the order of ``<table>`` placeholder
tags emitted into the block's content; entries are either the list of
header rows captured from ``w:tblHeader`` or ``None`` when the table has
no cross-page repeating header.
"""
return [p.get("_table_header") for p in paragraphs if p.get("is_table")]
def _build_unsplit_block(
heading: str, paragraphs: list, parent_headings: list, level: int
) -> dict:
"""Build a single block from paragraphs without size-based splitting."""
last_para = paragraphs[-1]
block = {
"uuid": paragraphs[0]["para_id"],
"uuid_end": last_para.get("para_id_end") or last_para.get("para_id"),
"heading": heading,
"content": "\n".join(p["text"] for p in paragraphs),
"type": "text",
"parent_headings": parent_headings,
"level": level,
}
table_headers = _collect_table_headers(paragraphs)
if table_headers:
block["table_headers"] = table_headers
return block
def _flush_current_block(
blocks: list,
heading: str,
paragraphs: list,
parent_headings: list,
level: int,
) -> None:
"""Flush accumulated paragraphs into a single heading-scoped block.
The native parser performs only heading-driven structural splitting; block
sizing (long-block anchor splitting, table row splitting, small-block
merging) is the downstream paragraph-semantic chunker's responsibility.
"""
if not paragraphs:
return
blocks.append(_build_unsplit_block(heading, paragraphs, parent_headings, level))
def extract_docx_blocks(
file_path: str,
drawing_context: DrawingExtractionContext = None,
parse_warnings: dict | None = None,
parse_metadata: dict | None = None,
) -> list:
"""
Extract heading-scoped text blocks from a DOCX file.
Uses python-docx with a custom numbering resolver to:
1. Capture automatic numbering (list labels)
2. Split the document into one block per heading (structural splitting)
3. Convert tables to JSON (2D array) and emit them as <table> placeholders
4. Preserve superscript/subscript formatting with <sup>/<sub> markup
Block sizing — long-block anchor splitting, table row splitting, and
small-block merging — is intentionally NOT done here; it is the downstream
paragraph-semantic chunker's responsibility. Blocks emitted here may
therefore be arbitrarily large.
Args:
file_path: Path to the DOCX file
parse_warnings: Optional out-dict that this function mutates with
non-fatal warnings observed during parsing. Currently used for
``missing_paraid_count`` — incremented once per body-level
paragraph (heading or text) that lacks a ``w14:paraId`` and once
per table whose every cell lacks one. Callers (the LightRAG
adapter / debug CLI) read this to surface a one-line warning per
document instead of crashing.
parse_metadata: Optional out-dict that this function mutates with
document-level metadata derived during parsing. Currently used
for ``first_heading`` — the text of the first heading encountered
in document order (regardless of level). Used by the LightRAG
adapter to populate ``meta.doc_title`` in ``.blocks.jsonl``.
Returns:
List of block dictionaries with heading, content, type, and metadata
"""
try:
doc = Document(file_path)
except PackageNotFoundError as exc:
# python-docx surfaces a misleading "Package not found at '...'" for any
# file it cannot open as a ZIP/OOXML package — including files that
# exist but are corrupt or a different format wearing a .docx extension.
# Diagnose the real cause from the magic bytes and raise a DocxContentError
# (a ValueError) so the pipeline's per-document handler marks just this
# document FAILED with an accurate, actionable message.
details, solution = _diagnose_invalid_docx(file_path)
raise DocxContentError(
format_error("File is not a valid DOCX document", details, solution)
) from exc
resolver = NumberingResolver(file_path)
styles_outline = parse_styles_outline_levels(file_path)
blocks = []
current_heading = "Preface/Uncategorized"
current_heading_level = 1 # Default level for "Preface/Uncategorized"
current_heading_stack = {} # {level: heading_text} - Use dict to correctly track heading hierarchy
current_parent_headings = [] # Parent headings for current block
current_paragraphs = [] # Track paragraphs with metadata for splitting
first_heading_recorded = (
False # Track whether the document's first heading has been captured
)
# Iterate through document body elements (paragraphs and tables)
body = doc._element.body
for element in body:
tag = element.tag.split("}")[-1] # Remove namespace
if tag == "sectPr": # Document-level section break
resolver.reset_tracking_state()
continue
if tag == "p": # Paragraph
# Get paragraph text with superscript/subscript markup and equations
para_text = ""
ns = {
"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
"m": "http://schemas.openxmlformats.org/officeDocument/2006/math",
}
para_text = extract_paragraph_content(
element,
ns,
drawing_context=drawing_context,
)
para_text = para_text.strip()
if not para_text:
continue
# Get numbering label using our resolver
label = resolver.get_label(element)
full_text = f"{label} {para_text}".strip() if label else para_text
# Check if this is a heading using the new function
outline_level = get_heading_level(element, styles_outline)
# A "heading" longer than MAX_HEADING_LENGTH is not a real heading.
# The common cause (WPS/Word): the author set an outline level on a
# paragraph but typed the body with soft line breaks (Shift+Enter →
# <w:br/> → '\n') instead of starting a new paragraph, so heading
# text + body live in one <w:p>. Split at the first soft break: the
# first line stays the heading, the remainder becomes body text. If
# there is no usable soft break (a genuine single-line over-long
# heading), demote the whole paragraph to body text. Either way we
# avoid crashing via validate_heading_length() and never drop content.
demoted_body_text = None
if outline_level is not None and len(full_text) > MAX_HEADING_LENGTH:
head, sep, rest = full_text.partition("\n")
if sep and len(head) <= MAX_HEADING_LENGTH:
full_text = head
demoted_body_text = rest.strip() or None
if parse_warnings is not None:
parse_warnings["heading_softbreak_split_count"] = (
parse_warnings.get("heading_softbreak_split_count", 0) + 1
)
print(
f"Warning: heading paragraph exceeded {MAX_HEADING_LENGTH} "
"chars; split at soft line break — kept first line as "
"heading, rest as body.",
file=sys.stderr,
)
else:
outline_level = None
if parse_warnings is not None:
parse_warnings["demoted_oversize_heading_count"] = (
parse_warnings.get("demoted_oversize_heading_count", 0) + 1
)
print(
f"Warning: paragraph has outline level but is "
f"{len(full_text)} chars (> {MAX_HEADING_LENGTH}); treating "
"as body text, not a heading.",
file=sys.stderr,
)
if outline_level is not None:
# This is a heading (outline level 0-8)
# Convert 0-based to 1-based level
level = outline_level + 1
# Extract paraId for this heading
heading_para_id = extract_para_id(element)
if parse_warnings is not None and not heading_para_id:
parse_warnings["missing_paraid_count"] = (
parse_warnings.get("missing_paraid_count", 0) + 1
)
# Validate heading length
validate_heading_length(full_text, heading_para_id)
# Truncate heading if needed before storing
truncated_text = truncate_heading(full_text, heading_para_id)
clean_heading_text = strip_heading_markdown_prefix(truncated_text)
# Record the document's first heading (any level) for meta.doc_title.
if not first_heading_recorded:
if parse_metadata is not None:
parse_metadata["first_heading"] = clean_heading_text
first_heading_recorded = True
# Every recognized heading starts its own block. Always flush the
# accumulated paragraphs so a heading with no body becomes a
# standalone block whose content is just the heading text,
# instead of being folded into the next heading's block.
if current_paragraphs:
_flush_current_block(
blocks,
current_heading,
current_paragraphs,
current_parent_headings,
current_heading_level,
)
# Reset for new block
current_paragraphs = []
# Add heading to current_paragraphs. The content line gets
# a markdown ``#`` prefix (capped at 6) via
# render_heading_line; ``clean_heading_text`` is kept
# for the heading field / stack / parent_headings below.
current_paragraphs.append(
{
"text": render_heading_line(level, truncated_text),
"para_id": heading_para_id,
"is_table": False,
}
)
# Update current_heading and parent_headings for the FIRST heading in a block
# (when current_paragraphs just had this heading added as its first element)
if len(current_paragraphs) == 1:
current_heading = clean_heading_text
current_heading_level = level # Only set level when setting heading
# Parent headings = all headings from levels strictly less than current level
# Sort by level to maintain hierarchy order
current_parent_headings = [
current_heading_stack[lvl]
for lvl in sorted(current_heading_stack.keys())
if lvl < level
]
# Update heading stack: remove current level and all lower levels, then add current
current_heading_stack = {
k: v for k, v in current_heading_stack.items() if k < level
}
current_heading_stack[level] = clean_heading_text
# Carry the body text that followed a soft break in an over-long
# heading paragraph as a regular body paragraph in the same block.
if demoted_body_text:
current_paragraphs.append(
{
"text": demoted_body_text,
"para_id": heading_para_id,
"is_table": False,
}
)
else:
# Regular paragraph content
para_id = extract_para_id(element)
if parse_warnings is not None and not para_id:
parse_warnings["missing_paraid_count"] = (
parse_warnings.get("missing_paraid_count", 0) + 1
)
# Store paragraph with metadata for potential splitting
current_paragraphs.append(
{"text": full_text, "para_id": para_id, "is_table": False}
)
# Check for paragraph-level section break (after processing paragraph)
# sectPr in pPr means this paragraph ends a section
pPr = element.find(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}pPr"
)
if pPr is not None:
sectPr = pPr.find(
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}sectPr"
)
if sectPr is not None:
# Section break after this paragraph - reset tracking
resolver.reset_tracking_state()
elif tag == "tbl": # Table
# Reset numbering tracking before table (table start boundary)
resolver.reset_tracking_state()
# Directly create Table object from XML element to avoid index mismatch
# (doc.tables may have different order due to nested tables)
from docx.table import Table
table = Table(element, doc)
table_metadata = TableExtractor.extract_with_metadata(
table,
numbering_resolver=resolver,
drawing_context=drawing_context,
)
table_rows = table_metadata["rows"]
para_ids = table_metadata["para_ids"]
para_ids_end = table_metadata["para_ids_end"] # Last paraId in each cell
header_indices = table_metadata["header_indices"]
# Skip tables whose every cell is whitespace-only — otherwise an
# empty `<table>[[""]]</table>` placeholder would leak into block
# content and a useless IRTable would appear in tables.json.
if _is_table_empty(table_rows):
resolver.reset_tracking_state()
continue
# Count tables whose cells carry no w14:paraId. Legacy / non-Word
# docx authors omit these attributes; we no longer fail-fast, but
# the adapter surfaces a single warning so the user knows the edit
# range hints will be missing for these tables.
if parse_warnings is not None and not _table_has_any_paraid(para_ids):
parse_warnings["missing_paraid_count"] = (
parse_warnings.get("missing_paraid_count", 0) + 1
)
# Convert table to JSON
table_json = json.dumps(table_rows, ensure_ascii=False)
# Extract cross-page repeating header rows (w:tblHeader) once per
# table so we can surface them to the sidecar via the block-level
# ``table_headers`` list.
header_rows = []
if header_indices:
header_rows = [
table_rows[idx] for idx in header_indices if idx < len(table_rows)
]
header_rows_or_none = header_rows if header_rows else None
# Emit the whole table as a single <table> placeholder. Token-based
# table row splitting is the downstream chunker's responsibility.
# Use first valid paraId from table, and last valid paraId (from
# para_ids_end) for uuid_end.
table_para_id = find_first_valid_para_id(para_ids)
table_para_id_end = find_last_valid_para_id(para_ids_end)
current_paragraphs.append(
{
"text": f"<table>{table_json}</table>",
"para_id": table_para_id,
"para_id_end": table_para_id_end, # Store end paraId for uuid_end calculation
"is_table": True,
"_table_header": header_rows_or_none,
}
)
# Reset numbering tracking after table (table end boundary)
resolver.reset_tracking_state()
# Save final block
_flush_current_block(
blocks,
current_heading,
current_paragraphs,
current_parent_headings,
current_heading_level,
)
return blocks
+96
View File
@@ -0,0 +1,96 @@
"""Native DOCX engine adapter (implements NativeParserBase hooks)."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any
from lightrag.constants import PARSER_ENGINE_NATIVE
from lightrag.parser.native_base import NativeParserBase
from lightrag.utils import logger
if TYPE_CHECKING:
from lightrag.sidecar.ir import IRDoc
class NativeDocxParser(NativeParserBase):
"""Native DOCX parser for LightRAG's production parsing path.
``extract_docx_blocks`` performs only heading-driven structural splitting
(one block per DOCX heading). Block sizing is intentionally left to the
downstream paragraph-semantic chunker, so this parser emits the
one-heading-one-block sidecar contract that chunking consumes.
"""
engine_name = PARSER_ENGINE_NATIVE
sidecar_path_style = "basename_only" # legacy native docx convention
empty_content_label = "DOCX"
def validate_source(self, source: Path, file_path: str) -> None:
if not (
source.exists() and source.is_file() and source.suffix.lower() == ".docx"
):
raise ValueError(
f"Native parser does not support pending file: {file_path}"
)
def extract(
self, source: Path, *, parsed_dir: Path, asset_dir: Path, base_name: str
) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, Any]]:
"""Extract heading-scoped DOCX blocks (sizing left to the chunker)."""
from lightrag.parser.docx.drawing_image_extractor import (
DrawingExtractionContext,
load_relationships,
)
from lightrag.parser.docx.parse_document import extract_docx_blocks
ctx = DrawingExtractionContext(
docx_path=source,
blocks_output_path=parsed_dir / f"{base_name}.blocks.jsonl",
export_dir_name=asset_dir.name,
export_dir_path=asset_dir,
)
load_relationships(ctx)
warnings: dict[str, Any] = {}
metadata: dict[str, Any] = {}
blocks = extract_docx_blocks(
str(source),
drawing_context=ctx,
parse_warnings=warnings,
parse_metadata=metadata,
)
return blocks, warnings, metadata
def build_ir(
self,
blocks: list[dict[str, Any]],
*,
document_name: str,
asset_dir_name: str,
metadata: dict[str, Any],
) -> "IRDoc":
from lightrag.parser.docx.ir_builder import NativeDocxIRBuilder
return NativeDocxIRBuilder().normalize(
blocks,
document_name=document_name,
asset_dir_name=asset_dir_name,
parse_metadata=metadata,
)
def surface_warnings(
self, warnings: dict[str, Any], source: Path
) -> dict[str, Any] | None:
missing = int(warnings.get("missing_paraid_count", 0) or 0)
if missing > 0:
# Surface once per document; affected blocks emit
# ``positions: [{"type": "paraid", "range": null}]``.
logger.warning(
"[parse_native] %s: %d paragraphs lack paraId; "
"Re-saving file in Word 2013+ to regenerate ids.",
source.name,
missing,
)
return {"missing_paraid_count": missing}
return None
+419
View File
@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""
ABOUTME: Extracts tables from DOCX with proper merged cell handling
ABOUTME: Vertically merged cells: content repeated in all rows with shared paraId
ABOUTME: Horizontally merged cells: content in first cell only
ABOUTME: Preserves superscript/subscript formatting with <sup>/<sub> markup
"""
from docx.table import Table
from docx.oxml.ns import qn
from typing import List
from .drawing_image_extractor import (
DrawingExtractionContext,
extract_drawing_placeholder_from_element,
extract_vml_image_placeholder_from_element,
)
# Keep in sync with parse_document._SKIP_PARAGRAPH_TAGS — duplicated here to
# avoid a circular import between parse_document and table_extractor.
_SKIP_PARAGRAPH_TAGS = frozenset(
{
"del",
"moveFrom",
"commentRangeStart",
"commentRangeEnd",
"commentReference",
"annotationRef",
}
)
def extract_text_from_run_table(
run_elem,
qn_func,
drawing_context: DrawingExtractionContext = None,
) -> str:
"""
Extract text from a run element in table cell, preserving superscript/subscript with markup.
Converts Word formatting to HTML-like tags:
- Superscript: <sup>text</sup>
- Subscript: <sub>text</sub>
- Normal text: unchanged
Args:
run_elem: lxml run element (w:r)
qn_func: qn function for namespace handling
Returns:
Text string with <sup>/<sub> markup for formatted portions
"""
text = ""
# Check for vertAlign in rPr (superscript/subscript)
vert_align = None
rPr = run_elem.find(qn_func("w:rPr"))
if rPr is not None:
vert_elem = rPr.find(qn_func("w:vertAlign"))
if vert_elem is not None:
vert_align = vert_elem.get(qn_func("w:val"))
# Extract text content from run children
for child in run_elem:
tag = child.tag.split("}")[-1] # Remove namespace
if tag == "t" and child.text:
text += child.text
elif tag == "tab":
text += "\t"
elif tag == "br":
# Handle line breaks - textWrapping or no type = soft line break
br_type = child.get(qn_func("w:type"))
if br_type in (None, "textWrapping"):
text += "\n"
# Skip page and column breaks (layout elements)
elif tag == "drawing":
text += extract_drawing_placeholder_from_element(
child,
context=drawing_context,
include_extended_attrs=True,
)
elif tag in ("pict", "object"):
text += extract_vml_image_placeholder_from_element(
child,
context=drawing_context,
include_extended_attrs=True,
)
# Apply superscript/subscript markup if needed
if text and vert_align == "superscript":
return f"<sup>{text}</sup>"
elif text and vert_align == "subscript":
return f"<sub>{text}</sub>"
return text
def extract_paragraph_content_table(
para_elem,
qn_func,
drawing_context: DrawingExtractionContext = None,
) -> str:
"""
Extract text and equations from a table cell paragraph in document order.
Handles w:r (text runs), m:oMath (inline equations), and m:oMathPara
(block equations). Recurses into container elements (e.g., w:hyperlink,
w:ins, w:sdt, w:fldSimple, w:smartTag) to avoid dropping content.
Args:
para_elem: lxml paragraph element (w:p)
qn_func: qn function for namespace handling
Returns:
Text string with equations wrapped in <equation> tags
"""
parts = []
def append_from(node) -> None:
tag = node.tag.split("}")[-1]
# Drop tracked-change deletions (w:del/w:moveFrom) and comment markers
# (w:commentRangeStart/End, w:commentReference, w:annotationRef) so the
# output only contains the final revised text without annotation glyphs.
if tag in _SKIP_PARAGRAPH_TAGS:
return
if tag == "r":
parts.append(
extract_text_from_run_table(
node,
qn_func,
drawing_context=drawing_context,
)
)
return
if tag == "oMath":
from omml import convert_omml_to_latex
latex = convert_omml_to_latex(node)
if latex:
parts.append(f"<equation>{latex}</equation>")
return
if tag == "oMathPara":
from omml import convert_omml_to_latex
for omath in node:
if omath.tag.split("}")[-1] == "oMath":
latex = convert_omml_to_latex(omath)
if latex:
parts.append(f"<equation>{latex}</equation>")
return
for child in node:
append_from(child)
for child in para_elem:
append_from(child)
return "".join(parts)
class TableExtractor:
"""
Extract table content handling merged cells correctly.
Merged cells in DOCX:
- Horizontal: w:gridSpan specifies how many columns cell spans
- Vertical: w:vMerge with val="restart" starts merge, subsequent cells continue
Output format:
- 2D list of strings
- Vertically merged cells: content repeated in all rows, all rows use the same paraId (from start cell)
- Horizontally merged cells: content in left-most position only, other positions empty
"""
@staticmethod
def extract(
table: Table,
numbering_resolver=None,
drawing_context: DrawingExtractionContext = None,
) -> List[List[str]]:
"""
Extract table to 2D string array.
Args:
table: python-docx Table object
numbering_resolver: Optional NumberingResolver for extracting numbering
Returns:
List of rows, each row is list of cell text strings
"""
result = TableExtractor.extract_with_metadata(
table,
numbering_resolver=numbering_resolver,
drawing_context=drawing_context,
)
return result["rows"]
@staticmethod
def extract_with_metadata(
table: Table,
numbering_resolver=None,
drawing_context: DrawingExtractionContext = None,
) -> dict:
"""
Extract table to 2D string array with metadata (paraIds, header info).
Vertical merge behavior:
- All rows in a vertically merged region share the same content
- All rows use the paraId from the merge start cell (for precise edit targeting)
Args:
table: python-docx Table object
numbering_resolver: Optional NumberingResolver for extracting numbering
Returns:
Dict with:
- rows: 2D list of cell text strings
- para_ids: 2D list of paraIds (first paraId in each cell, or None)
For vertically merged cells, all rows share the start cell's paraId
- para_ids_end: 2D list of paraIds (last paraId in each cell, or None)
For vertically merged cells, all rows share the start cell's paraId
- header_indices: List of row indices marked as table headers
"""
tbl = table._tbl
# Get number of columns from tblGrid
tbl_grid = tbl.find(qn("w:tblGrid"))
num_cols = 0
if tbl_grid is not None:
num_cols = len(tbl_grid.findall(qn("w:gridCol")))
if num_cols == 0:
return {
"rows": [],
"para_ids": [],
"para_ids_end": [],
"header_indices": [],
}
# Detect header rows using w:tblHeader attribute
header_indices = []
for idx, tr in enumerate(tbl.findall(qn("w:tr"))):
trPr = tr.find(qn("w:trPr"))
if trPr is not None:
tbl_header = trPr.find(qn("w:tblHeader"))
if tbl_header is not None:
header_indices.append(idx)
# Process each row by directly iterating <w:tr> elements
grid = []
para_ids_grid = []
para_ids_end_grid = [] # Track last paraId in each cell
vmerge_content = {} # Track vertical merge by column: {col: {'text': str, 'para_id': str, 'para_id_end': str}}
for tr in tbl.findall(qn("w:tr")):
row_data = [""] * num_cols # Pre-fill with empty strings
row_para_ids = [None] * num_cols # Pre-fill with None
row_para_ids_end = [None] * num_cols # Pre-fill with None for last paraId
grid_col = 0
# Iterate actual <w:tc> elements (each physical cell appears once)
for tc in tr.findall(qn("w:tc")):
# Reset numbering state when cell changes to prevent incorrect continuation
if numbering_resolver is not None:
numbering_resolver.reset_tracking_state()
tcPr = tc.find(qn("w:tcPr"))
# Check gridSpan (horizontal merge)
grid_span = 1
if tcPr is not None:
gs = tcPr.find(qn("w:gridSpan"))
if gs is not None:
grid_span = int(gs.get(qn("w:val")))
# Check vMerge (vertical merge)
vmerge_elem = None
vmerge_val = None
if tcPr is not None:
vmerge_elem = tcPr.find(qn("w:vMerge"))
if vmerge_elem is not None:
vmerge_val = vmerge_elem.get(
qn("w:val")
) # 'restart' or None (means 'continue')
# Determine vMerge status
is_vmerge_restart = vmerge_elem is not None and vmerge_val == "restart"
is_vmerge_continue = vmerge_elem is not None and vmerge_val in (
None,
"continue",
)
is_normal_cell = vmerge_elem is None
cell_text = ""
cell_para_id = None
cell_para_id_end = None # Track last paraId in cell
# Handle different vMerge cases
if is_vmerge_restart or is_normal_cell:
# Extract content for restart or normal cells
# Get cell text with numbering support and format preservation
if numbering_resolver is not None:
# Extract text with numbering labels and superscript/subscript markup
cell_paragraphs = []
for para_elem in tc.findall(qn("w:p")):
# Capture paraId from each paragraph
para_id_attr = para_elem.get(
"{http://schemas.microsoft.com/office/word/2010/wordml}paraId"
)
if para_id_attr:
if cell_para_id is None:
cell_para_id = para_id_attr # First paraId
cell_para_id_end = (
para_id_attr # Always update to get last
)
# Get text content with format preservation (superscript/subscript/equations)
para_text = extract_paragraph_content_table(
para_elem,
qn,
drawing_context=drawing_context,
)
# Get numbering label
label = numbering_resolver.get_label(para_elem)
# Combine label and text
if label:
full_text = f"{label} {para_text}".strip()
else:
full_text = para_text.strip()
if full_text:
cell_paragraphs.append(full_text)
cell_text = "\n".join(cell_paragraphs).replace("\x07", "")
else:
# Fallback to simple text extraction with format preservation
# Cannot use cell.text here, must extract from XML
para_texts = []
for para_elem in tc.findall(qn("w:p")):
# Capture paraId from each paragraph
para_id_attr = para_elem.get(
"{http://schemas.microsoft.com/office/word/2010/wordml}paraId"
)
if para_id_attr:
if cell_para_id is None:
cell_para_id = para_id_attr # First paraId
cell_para_id_end = (
para_id_attr # Always update to get last
)
# Extract text with format preservation (superscript/subscript/equations)
para_text = extract_paragraph_content_table(
para_elem,
qn,
drawing_context=drawing_context,
)
if para_text:
para_texts.append(para_text.strip())
cell_text = "\n".join(para_texts).replace("\x07", "")
# Store content and paraIds for vMerge restart
if is_vmerge_restart:
vmerge_content[grid_col] = {
"text": cell_text,
"para_id": cell_para_id,
"para_id_end": cell_para_id_end,
}
elif is_normal_cell:
# For normal cells: if empty and we have active vMerge, copy all from start
# If non-empty, this ends the vMerge region
if not cell_text and grid_col in vmerge_content:
# Empty cell in vMerge region - copy content and paraIds from start
cell_text = vmerge_content[grid_col]["text"]
cell_para_id = vmerge_content[grid_col]["para_id"]
cell_para_id_end = vmerge_content[grid_col]["para_id_end"]
elif cell_text:
# Non-empty cell - this ends the vMerge for this column
vmerge_content.pop(grid_col, None)
elif is_vmerge_continue:
# Copy content and para_id from previous merge start
# But extract actual para_id_end from this continue cell for range boundary
if grid_col in vmerge_content:
cell_text = vmerge_content[grid_col]["text"]
cell_para_id = vmerge_content[grid_col][
"para_id"
] # Use restart's paraId for edit targeting
# Extract actual paraId from this continue cell for uuid_end (range boundary)
for para_elem in tc.findall(qn("w:p")):
para_id_attr = para_elem.get(
"{http://schemas.microsoft.com/office/word/2010/wordml}paraId"
)
if para_id_attr:
cell_para_id_end = (
para_id_attr # Use actual paraId for range boundary
)
# Place content at starting grid position only
if grid_col < num_cols:
row_data[grid_col] = cell_text
row_para_ids[grid_col] = cell_para_id
row_para_ids_end[grid_col] = cell_para_id_end
# Move grid position by gridSpan
grid_col += grid_span
grid.append(row_data)
para_ids_grid.append(row_para_ids)
para_ids_end_grid.append(row_para_ids_end)
return {
"rows": grid,
"para_ids": para_ids_grid,
"para_ids_end": para_ids_end_grid,
"header_indices": header_indices,
}
+791
View File
@@ -0,0 +1,791 @@
#!/usr/bin/env python3
"""
ABOUTME: Shared token estimation utilities for audit scripts
ABOUTME: XML sanitization helpers for document processing
"""
import json
import os
import re
try:
from google import genai
from google.genai import types
HAS_GEMINI = True
except ImportError: # pragma: no cover - optional dependency
genai = None
types = None
HAS_GEMINI = False
try:
import openai
HAS_OPENAI = True
except ImportError: # pragma: no cover - optional dependency
openai = None
HAS_OPENAI = False
def estimate_tokens(text: str) -> int:
"""
Estimate token count for LLM context management.
Uses a weighted formula based on character types:
- Chinese characters: ~0.75 tokens per character (subword tokenization)
- JSON structural characters (brackets, quotes, commas): ~1 tokens per character
- Other characters (English, numbers, symbols): ~0.4 tokens per character (~3 chars/token)
Includes 5% buffer and safety offset for special formatting and system prompt overhead.
Args:
text: Input text to estimate tokens for
Returns:
int: Estimated token count
"""
if not text:
return 0
chinese_count = len(re.findall(r"[\u4e00-\u9fa5]", text))
json_chars_count = len(re.findall(r'[\[\]",{}]', text))
other_count = len(text) - chinese_count - json_chars_count
base_estimate = (
(chinese_count * 0.75) + (json_chars_count * 1) + (other_count * 0.4)
)
final_tokens = int(base_estimate * 1.05) + 2
return final_tokens
def sanitize_xml_string(text: str) -> str:
"""
Remove control characters that are illegal in XML 1.0.
XML 1.0 allows: #x9 (tab), #xA (LF), #xD (CR), and #x20-#xD7FF, #xE000-#xFFFD, #x10000-#x10FFFF
This function removes all other control characters (0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F).
Args:
text: Text that may contain control characters
Returns:
Sanitized text safe for XML. Returns input unchanged if not a non-empty string.
"""
if not text or not isinstance(text, str):
return text
# Build a translation table to remove illegal control characters
# Keep: \t (0x09), \n (0x0A), \r (0x0D)
# Remove: 0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F
illegal_chars = "".join(chr(c) for c in range(0x20) if c not in (0x09, 0x0A, 0x0D))
return text.translate(str.maketrans("", "", illegal_chars))
def is_vertex_ai_mode() -> bool:
"""
Check if Vertex AI mode is enabled via environment variable.
Returns:
True if GOOGLE_GENAI_USE_VERTEXAI is set to 'true', False otherwise
"""
return os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "").lower() == "true"
def create_gemini_client(use_async: bool = False):
"""
Create Gemini client for AI Studio or Vertex AI.
Supports two modes:
- AI Studio (default): Uses GOOGLE_API_KEY for authentication
- Vertex AI: Uses ADC (GOOGLE_APPLICATION_CREDENTIALS or gcloud auth)
Environment variables for Vertex AI mode:
- GOOGLE_GENAI_USE_VERTEXAI: Set to 'true' to enable Vertex AI mode
- GOOGLE_CLOUD_PROJECT: Required GCP project ID
- GOOGLE_CLOUD_LOCATION: Optional region (default: us-central1)
- GOOGLE_VERTEX_BASE_URL: Optional custom API endpoint (for API gateway proxies)
- GOOGLE_APPLICATION_CREDENTIALS: Path to service account JSON (or use gcloud auth)
Args:
use_async: If True, return the async client (.aio), otherwise return sync client
Returns:
Gemini client instance (sync or async based on use_async parameter)
Raises:
ValueError: If required environment variables are not set
"""
use_vertex = is_vertex_ai_mode()
if use_vertex:
# Vertex AI mode - uses ADC (GOOGLE_APPLICATION_CREDENTIALS or gcloud auth)
project = os.getenv("GOOGLE_CLOUD_PROJECT")
location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
base_url = os.getenv("GOOGLE_VERTEX_BASE_URL")
if not project:
raise ValueError(
"GOOGLE_CLOUD_PROJECT is required for Vertex AI mode. "
"Set GOOGLE_GENAI_USE_VERTEXAI=false to use AI Studio mode instead."
)
# Build http_options only if custom base_url is specified
http_options = None
if base_url:
http_options = {"base_url": base_url}
# Note: ADC handles authentication automatically
# via GOOGLE_APPLICATION_CREDENTIALS env var or gcloud auth
client = genai.Client(
vertexai=True, project=project, location=location, http_options=http_options
)
else:
# AI Studio mode - requires API key
api_key = os.getenv("GOOGLE_API_KEY")
if not api_key:
raise ValueError(
"GOOGLE_API_KEY is required for AI Studio mode. "
"Set GOOGLE_GENAI_USE_VERTEXAI=true and configure GCP credentials for Vertex AI mode."
)
client = genai.Client(api_key=api_key)
# Return async or sync client based on parameter
return client.aio if use_async else client
def get_gemini_provider_name() -> str:
"""
Get the Gemini provider name based on current mode.
Returns:
Provider name string for display purposes
"""
if is_vertex_ai_mode():
project = os.getenv("GOOGLE_CLOUD_PROJECT", "unknown")
location = os.getenv("GOOGLE_CLOUD_LOCATION", "us-central1")
return f"Google Gemini (Vertex AI: {project}/{location})"
return "Google Gemini (AI Studio)"
def create_openai_client(use_async: bool = True):
"""
Create OpenAI client with optional custom base URL.
Environment variables:
- OPENAI_API_KEY: Required API key
- OPENAI_BASE_URL: Optional custom API endpoint (for proxies, Azure, etc.)
Args:
use_async: If True, return AsyncOpenAI, otherwise return OpenAI
Returns:
OpenAI client instance (async or sync based on use_async parameter)
Raises:
ValueError: If OPENAI_API_KEY is not set
"""
if not HAS_OPENAI:
raise ValueError("openai library is not installed.")
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY is required for OpenAI mode.")
base_url = os.getenv("OPENAI_BASE_URL")
if use_async:
return openai.AsyncOpenAI(base_url=base_url)
return openai.OpenAI(base_url=base_url)
def get_openai_provider_name() -> str:
"""
Get the OpenAI provider name, including custom endpoint if configured.
Returns:
Provider name string for display purposes
"""
base_url = os.getenv("OPENAI_BASE_URL")
if base_url:
return f"OpenAI (Custom: {base_url})"
return "OpenAI"
def is_openai_reasoning_model(model_name: str) -> bool:
"""
Check if the OpenAI model supports reasoning_effort parameter.
Models that support reasoning_effort:
- o-series: o1, o3, o4 and their variants (o1-mini, o1-2024-12-17, etc.)
- gpt-5 series: gpt-5, gpt-5.2, gpt-5-turbo, etc.
Non-reasoning models like gpt-4.1, gpt-4o, etc. will reject this parameter.
Handles proxy/router prefixes like "openai/o1-mini" or "openrouter/gpt-5.2".
Args:
model_name: The OpenAI model name (may include path prefix)
Returns:
True if the model supports reasoning_effort, False otherwise
"""
model_lower = model_name.lower()
# Handle proxy/router prefixes like "openai/o1-mini", "openrouter/gpt-5.2"
# Extract the base model name after the last "/"
if "/" in model_lower:
model_lower = model_lower.rsplit("/", 1)[-1]
# Match o-series and gpt-5 series
return model_lower.startswith(("o1", "o3", "o4", "gpt-5"))
def is_openai_retryable(error: Exception) -> bool:
"""
Determine if an OpenAI error should be retried.
Non-retryable errors:
- AuthenticationError (401): Invalid API key
- PermissionDeniedError (403): No access to resource
- BadRequestError (400): Invalid request format
- NotFoundError (404): Model or resource not found
Retryable errors:
- RateLimitError (429): Rate limit exceeded
- APIConnectionError: Network issues
- InternalServerError (500): Server errors
- APIStatusError with 502, 503, 504: Gateway/service errors
Args:
error: The exception from OpenAI API call
Returns:
True if the error should be retried, False otherwise
"""
if not HAS_OPENAI:
return True
# Authentication error - invalid API key (401)
if isinstance(error, openai.AuthenticationError):
return False
# Permission denied - no access to resource (403)
if isinstance(error, openai.PermissionDeniedError):
return False
# Bad request - invalid request format (400)
if isinstance(error, openai.BadRequestError):
return False
# Not found - model or resource doesn't exist (404)
if isinstance(error, openai.NotFoundError):
return False
# Rate limit exceeded - should retry with backoff (429)
if isinstance(error, openai.RateLimitError):
return True
# API connection error - network issues, should retry
if isinstance(error, openai.APIConnectionError):
return True
# Internal server error - should retry (500)
if isinstance(error, openai.InternalServerError):
return True
# For other APIStatusError, check HTTP status code
if isinstance(error, openai.APIStatusError):
# Retryable server-side errors
return error.status_code in (429, 500, 502, 503, 504)
# For unknown errors, default to retry (network issues, timeouts, etc.)
return True
def is_gemini_retryable(error: Exception) -> bool:
"""
Determine if a Gemini error should be retried.
Uses string matching on error messages since google-genai may not have
well-defined exception types for all error cases.
Non-retryable errors:
- API key errors
- Authentication/permission errors
- Invalid request errors
- Model not found errors
- Billing/quota permanently exceeded
Retryable errors:
- Rate limit (429)
- Server errors (500, 502, 503, 504)
- Timeout/connection errors
Args:
error: The exception from Gemini API call
Returns:
True if the error should be retried, False otherwise
"""
error_str = str(error).lower()
# API key / authentication errors - do not retry
if "api_key" in error_str or "api key" in error_str:
return False
if "authentication" in error_str or "authenticate" in error_str:
return False
if "invalid_api_key" in error_str or "invalid api key" in error_str:
return False
# Permission / forbidden errors - do not retry
if "permission" in error_str and "denied" in error_str:
return False
if "forbidden" in error_str or "403" in error_str:
return False
# Invalid request errors - do not retry
if "invalid" in error_str and ("request" in error_str or "argument" in error_str):
return False
if "400" in error_str and "bad request" in error_str:
return False
# Model not found - do not retry
if "model" in error_str and ("not found" in error_str or "not exist" in error_str):
return False
if "404" in error_str:
return False
# Billing / permanent quota errors - do not retry
if "billing" in error_str:
return False
if "quota" in error_str and ("exceeded" in error_str or "exhausted" in error_str):
# Check if it mentions billing which indicates permanent quota issue
if "billing" in error_str or "payment" in error_str:
return False
# Temporary quota (rate limit) - should retry
return True
# Rate limit errors - should retry (429)
if "rate" in error_str and "limit" in error_str:
return True
if "429" in error_str or "resource_exhausted" in error_str:
return True
# Server errors - should retry (500, 502, 503, 504)
if any(code in error_str for code in ["500", "502", "503", "504"]):
return True
if "internal" in error_str and ("error" in error_str or "server" in error_str):
return True
if "service" in error_str and "unavailable" in error_str:
return True
if "gateway" in error_str:
return True
# Timeout / connection errors - should retry
if "timeout" in error_str or "timed out" in error_str:
return True
if "connection" in error_str:
return True
if "network" in error_str:
return True
# Unknown errors - default to retry with limited attempts
return True
# JSON Schema for LLM structured output
AUDIT_RESULT_SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"is_violation": {
"type": "boolean",
"description": "Whether any violations were found",
},
"violations": {
"type": "array",
"description": "List of violations found",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"rule_id": {
"type": "string",
"description": "ID of the violated rule (e.g., R001)",
},
"violation_text": {
"type": "string",
"description": "The problematic text directly verbatim quote from the source content, and not span multiple cells",
},
"violation_reason": {
"type": "string",
"description": "Explanation of why this violates the rule",
},
"fix_action": {
"type": "string",
"enum": ["replace", "manual"],
"description": "Action type: replace substitutes text (including deletion-via-replace), manual requires human review",
},
"revised_text": {
"type": "string",
"description": "For replace: complete replacement text (including deletion-via-replace). For manual: additional guidance for human reviewer",
},
},
"required": [
"rule_id",
"violation_text",
"violation_reason",
"fix_action",
"revised_text",
],
},
},
},
"required": ["is_violation", "violations"],
}
# JSON Schema for global extraction output
GLOBAL_EXTRACT_SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"results": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"rule_id": {"type": "string"},
"extracted_results": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"entity": {"type": "string"},
"fields": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"name": {"type": "string"},
"value": {"type": "string"},
"evidence": {"type": "string"},
},
"required": ["name", "value", "evidence"],
},
},
},
"required": ["entity", "fields"],
},
},
},
"required": ["rule_id", "extracted_results"],
},
}
},
"required": ["results"],
}
# JSON Schema for global verification output
GLOBAL_VERIFY_SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"violations": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"rule_id": {"type": "string"},
"uuid": {"type": "string"},
"uuid_end": {"type": "string"},
"violation_text": {"type": "string"},
"violation_reason": {"type": "string"},
"fix_action": {"type": "string", "enum": ["replace", "manual"]},
"revised_text": {"type": "string"},
},
"required": [
"rule_id",
"uuid",
"uuid_end",
"violation_text",
"violation_reason",
"fix_action",
"revised_text",
],
},
}
},
"required": ["violations"],
}
async def global_extract_gemini_async(
user_prompt: str,
system_prompt: str,
model_name: str,
client,
thinking_level: str = None,
thinking_budget: int = None,
) -> dict:
thinking_config = None
if thinking_level and thinking_level.upper() in (
"MINIMAL",
"LOW",
"MEDIUM",
"HIGH",
):
level_map = {
"MINIMAL": types.ThinkingLevel.MINIMAL,
"LOW": types.ThinkingLevel.LOW,
"MEDIUM": types.ThinkingLevel.MEDIUM,
"HIGH": types.ThinkingLevel.HIGH,
}
thinking_config = types.ThinkingConfig(
thinking_level=level_map[thinking_level.upper()]
)
elif thinking_budget is not None:
thinking_config = types.ThinkingConfig(thinking_budget=int(thinking_budget))
config_params = {
"system_instruction": system_prompt,
"response_mime_type": "application/json",
"response_schema": GLOBAL_EXTRACT_SCHEMA,
}
if thinking_config:
config_params["thinking_config"] = thinking_config
response = await client.models.generate_content(
model=model_name,
contents=user_prompt,
config=types.GenerateContentConfig(**config_params),
)
return json.loads(response.text)
async def global_extract_openai_async(
user_prompt: str,
system_prompt: str,
model_name: str,
client,
reasoning_effort: str = None,
) -> dict:
request_params = {
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "global_extract",
"strict": True,
"schema": GLOBAL_EXTRACT_SCHEMA,
},
},
}
if (
reasoning_effort
and reasoning_effort.lower() in ("low", "medium", "high")
and is_openai_reasoning_model(model_name)
):
request_params["reasoning_effort"] = reasoning_effort.lower()
response = await client.chat.completions.create(**request_params)
return json.loads(response.choices[0].message.content)
async def global_verify_gemini_async(
user_prompt: str,
system_prompt: str,
model_name: str,
client,
thinking_level: str = None,
thinking_budget: int = None,
) -> dict:
thinking_config = None
if thinking_level and thinking_level.upper() in (
"MINIMAL",
"LOW",
"MEDIUM",
"HIGH",
):
level_map = {
"MINIMAL": types.ThinkingLevel.MINIMAL,
"LOW": types.ThinkingLevel.LOW,
"MEDIUM": types.ThinkingLevel.MEDIUM,
"HIGH": types.ThinkingLevel.HIGH,
}
thinking_config = types.ThinkingConfig(
thinking_level=level_map[thinking_level.upper()]
)
elif thinking_budget is not None:
thinking_config = types.ThinkingConfig(thinking_budget=int(thinking_budget))
config_params = {
"system_instruction": system_prompt,
"response_mime_type": "application/json",
"response_schema": GLOBAL_VERIFY_SCHEMA,
}
if thinking_config:
config_params["thinking_config"] = thinking_config
response = await client.models.generate_content(
model=model_name,
contents=user_prompt,
config=types.GenerateContentConfig(**config_params),
)
return json.loads(response.text)
async def global_verify_openai_async(
user_prompt: str,
system_prompt: str,
model_name: str,
client,
reasoning_effort: str = None,
) -> dict:
request_params = {
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "global_verify",
"strict": True,
"schema": GLOBAL_VERIFY_SCHEMA,
},
},
}
if (
reasoning_effort
and reasoning_effort.lower() in ("low", "medium", "high")
and is_openai_reasoning_model(model_name)
):
request_params["reasoning_effort"] = reasoning_effort.lower()
response = await client.chat.completions.create(**request_params)
return json.loads(response.choices[0].message.content)
async def audit_block_gemini_async(
user_prompt: str,
system_prompt: str,
model_name: str,
client,
thinking_level: str = None,
thinking_budget: int = None,
) -> dict:
"""
Audit a text block using Google Gemini with strict JSON mode (async version).
Args:
user_prompt: User prompt to audit
system_prompt: Cached system prompt with rules and instructions
model_name: Gemini model to use
client: Gemini async client instance (client.aio)
thinking_level: Thinking level for Gemini 3 models (MINIMAL, LOW, MEDIUM, HIGH)
thinking_budget: Thinking token budget for Gemini 2.5 models (integer)
Returns:
Audit result dictionary
"""
# Build thinking config based on model and parameters
thinking_config = None
if thinking_level and thinking_level.upper() in (
"MINIMAL",
"LOW",
"MEDIUM",
"HIGH",
):
# For Gemini 3 models
level_map = {
"MINIMAL": types.ThinkingLevel.MINIMAL,
"LOW": types.ThinkingLevel.LOW,
"MEDIUM": types.ThinkingLevel.MEDIUM,
"HIGH": types.ThinkingLevel.HIGH,
}
thinking_config = types.ThinkingConfig(
thinking_level=level_map[thinking_level.upper()]
)
elif thinking_budget is not None:
# For Gemini 2.5 models
thinking_config = types.ThinkingConfig(thinking_budget=int(thinking_budget))
config_params = {
"system_instruction": system_prompt,
"response_mime_type": "application/json",
"response_schema": AUDIT_RESULT_SCHEMA,
}
# Only add thinking_config if it's configured
if thinking_config:
config_params["thinking_config"] = thinking_config
response = await client.models.generate_content(
model=model_name,
contents=user_prompt,
config=types.GenerateContentConfig(**config_params),
)
# With structured output, response is guaranteed to be valid JSON
result = json.loads(response.text)
return result
async def audit_block_openai_async(
user_prompt: str,
system_prompt: str,
model_name: str,
client,
reasoning_effort: str = None,
) -> dict:
"""
Audit a text block using OpenAI with strict JSON mode (async version).
Args:
user_prompt: User prompt to audit
system_prompt: Cached system prompt with rules and instructions
model_name: OpenAI model to use
client: AsyncOpenAI client instance
reasoning_effort: Reasoning effort for o-series models (low, medium, high)
Returns:
Audit result dictionary
"""
request_params = {
"model": model_name,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "audit_result",
"strict": True,
"schema": AUDIT_RESULT_SCHEMA,
},
},
}
# Add reasoning_effort only for o-series models that support it
if (
reasoning_effort
and reasoning_effort.lower() in ("low", "medium", "high")
and is_openai_reasoning_model(model_name)
):
request_params["reasoning_effort"] = reasoning_effort.lower()
response = await client.chat.completions.create(**request_params)
# With structured output, response is guaranteed to be valid JSON
result = json.loads(response.choices[0].message.content)
return result