Skip to content
This repository was archived by the owner on Sep 7, 2026. It is now read-only.

Commit ece7467

Browse files
committed
docs(document-skills): verify rendered package content
1 parent a5cd131 commit ece7467

8 files changed

Lines changed: 552 additions & 22 deletions

File tree

plugins/Hylouis233/document-skills/skills/docx/references/read.md

Lines changed: 123 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,122 @@ tables). Prefer this when the goal is content, not coordinates.
1414
## Structured access (python-docx)
1515

1616
```python
17+
from contextlib import contextmanager
18+
import zipfile
19+
from pathlib import Path
20+
from tempfile import TemporaryFile
21+
1722
from docx import Document
1823
from docx.oxml.ns import qn
1924
from docx.table import Table
2025
from docx.text.paragraph import Paragraph
2126
from docx.text.run import Run
27+
from lxml import etree
28+
29+
MAX_ARCHIVE_BYTES = 200 * 1024 * 1024
30+
MAX_MEMBERS = 10_000
31+
MAX_XML_PART = 20 * 1024 * 1024
32+
MAX_ENTRY = 100 * 1024 * 1024
33+
MAX_TOTAL_UNCOMPRESSED = 500 * 1024 * 1024
34+
MAX_COMPRESSION_RATIO = 200
35+
CONTENT_TYPES_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/content-types"
36+
37+
def require(condition, message):
38+
if not condition:
39+
raise ValueError(message)
40+
41+
SAFE_XML_PARSER = etree.XMLParser(
42+
load_dtd=False, resolve_entities=False, no_network=True,
43+
huge_tree=False, recover=False,
44+
)
45+
46+
def xml_content_type(value):
47+
media_type = (value or "").split(";", 1)[0].strip().casefold()
48+
return media_type in {"application/xml", "text/xml"} or media_type.endswith("+xml")
49+
50+
def declared_xml_parts(archive, infos):
51+
"""Classify XML by OPC declarations, not filename spelling alone."""
52+
by_name = {info.filename: info for info in infos}
53+
content_types_info = by_name.get("[Content_Types].xml")
54+
require(content_types_info is not None, "missing [Content_Types].xml")
55+
require(content_types_info.file_size <= MAX_XML_PART,
56+
"oversized XML part: [Content_Types].xml")
57+
with archive.open(content_types_info) as stream:
58+
content_types_blob = stream.read(MAX_XML_PART + 1)
59+
require(len(content_types_blob) <= MAX_XML_PART,
60+
"oversized XML part: [Content_Types].xml")
61+
root = etree.fromstring(content_types_blob, parser=SAFE_XML_PARSER)
62+
require(root.tag == f"{{{CONTENT_TYPES_NAMESPACE}}}Types",
63+
"invalid [Content_Types].xml root")
64+
defaults = {}
65+
overrides = {}
66+
for child in root:
67+
if child.tag == f"{{{CONTENT_TYPES_NAMESPACE}}}Default":
68+
extension = (child.get("Extension") or "").casefold()
69+
require(extension and extension not in defaults,
70+
"invalid duplicate content-type default")
71+
defaults[extension] = child.get("ContentType") or ""
72+
elif child.tag == f"{{{CONTENT_TYPES_NAMESPACE}}}Override":
73+
part_name = child.get("PartName") or ""
74+
require(part_name.startswith("/") and part_name[1:] not in overrides,
75+
"invalid duplicate content-type override")
76+
overrides[part_name[1:]] = child.get("ContentType") or ""
77+
xml_names = {"[Content_Types].xml"}
78+
for info in infos:
79+
suffix = info.filename.rsplit(".", 1)[1].casefold() if "." in info.filename else ""
80+
content_type = overrides.get(info.filename, defaults.get(suffix, ""))
81+
if (info.filename.casefold().endswith((".xml", ".rels"))
82+
or xml_content_type(content_type)):
83+
xml_names.add(info.filename)
84+
return xml_names
85+
86+
def validate_docx_archive(archive):
87+
infos = archive.infolist()
88+
require(len(infos) <= MAX_MEMBERS, "archive member count above limit")
89+
names = {info.filename for info in infos}
90+
require(len(names) == len(infos), "duplicate archive member names are unsafe")
91+
require("[Content_Types].xml" in names and "word/document.xml" in names,
92+
"missing required OPC members")
93+
require(sum(info.file_size for info in infos) <= MAX_TOTAL_UNCOMPRESSED,
94+
"declared total uncompressed size above limit")
95+
xml_names = declared_xml_parts(archive, infos)
96+
actual_total = 0
97+
for info in infos:
98+
require(info.file_size <= MAX_ENTRY, f"oversized part: {info.filename}")
99+
require(info.file_size / max(info.compress_size, 1) <= MAX_COMPRESSION_RATIO,
100+
f"suspicious compression ratio: {info.filename}")
101+
is_xml = info.filename in xml_names
102+
if is_xml:
103+
require(info.file_size <= MAX_XML_PART, f"oversized XML part: {info.filename}")
104+
chunks = []
105+
actual_size = 0
106+
with archive.open(info) as stream:
107+
while chunk := stream.read(64 * 1024):
108+
actual_size += len(chunk)
109+
actual_total += len(chunk)
110+
require(actual_size <= MAX_ENTRY, f"part exceeded read limit: {info.filename}")
111+
require(actual_total <= MAX_TOTAL_UNCOMPRESSED,
112+
"archive exceeded total read limit")
113+
if is_xml:
114+
chunks.append(chunk)
115+
require(actual_size == info.file_size, f"size mismatch: {info.filename}")
116+
if is_xml:
117+
etree.fromstring(b"".join(chunks), parser=SAFE_XML_PARSER)
118+
119+
@contextmanager
120+
def validated_docx_source(path):
121+
"""Yield one private, bounded snapshot for both validation and python-docx."""
122+
with Path(path).open("rb") as external_source, TemporaryFile() as source:
123+
copied = 0
124+
while chunk := external_source.read(64 * 1024):
125+
copied += len(chunk)
126+
require(copied <= MAX_ARCHIVE_BYTES, "compressed DOCX file size above limit")
127+
source.write(chunk)
128+
source.seek(0)
129+
with zipfile.ZipFile(source) as archive:
130+
validate_docx_archive(archive)
131+
source.seek(0)
132+
yield source
22133

23134
MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006"
24135
MC_ALTERNATE_CONTENT = f"{{{MC_NAMESPACE}}}AlternateContent"
@@ -227,17 +338,18 @@ def table_content(table):
227338
})
228339
return rows
229340

230-
doc = Document("input.docx")
231-
content_controls = list(doc.element.body.iter(qn("w:sdt")))
232-
blocks = list(iter_part_blocks(doc.element.body, doc))
233-
print("content controls:", len(content_controls), "top-level blocks:", len(blocks))
234-
for kind, block in blocks:
235-
if kind == "paragraph":
236-
print(block.style.name, "|", paragraph_text(block))
237-
elif kind == "table":
238-
print("table |", table_content(block))
239-
else:
240-
print("unreadable |", block)
341+
with validated_docx_source("input.docx") as source:
342+
doc = Document(source)
343+
content_controls = list(doc.element.body.iter(qn("w:sdt")))
344+
blocks = list(iter_part_blocks(doc.element.body, doc))
345+
print("content controls:", len(content_controls), "top-level blocks:", len(blocks))
346+
for kind, block in blocks:
347+
if kind == "paragraph":
348+
print(block.style.name, "|", paragraph_text(block))
349+
elif kind == "table":
350+
print("table |", table_content(block))
351+
else:
352+
print("unreadable |", block)
241353
```
242354

243355
Notes:

plugins/Hylouis233/document-skills/skills/docx/references/review.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ MAX_XML_PART = 20 * 1024 * 1024
2424
MAX_ENTRY = 100 * 1024 * 1024
2525
MAX_TOTAL_UNCOMPRESSED = 500 * 1024 * 1024
2626
MAX_COMPRESSION_RATIO = 200
27+
CONTENT_TYPES_NAMESPACE = "http://schemas.openxmlformats.org/package/2006/content-types"
2728

2829
# Security limits must survive `python -O` (which strips assert statements),
2930
# so every check raises explicitly instead of asserting.
@@ -38,6 +39,46 @@ safe_xml_parser = etree.XMLParser(
3839
huge_tree=False,
3940
recover=False,
4041
)
42+
43+
def xml_content_type(value):
44+
media_type = (value or "").split(";", 1)[0].strip().casefold()
45+
return media_type in {"application/xml", "text/xml"} or media_type.endswith("+xml")
46+
47+
def declared_xml_parts(archive, infos):
48+
"""Classify XML from OPC declarations plus conventional suffixes."""
49+
by_name = {info.filename: info for info in infos}
50+
content_types_info = by_name.get("[Content_Types].xml")
51+
require(content_types_info is not None, "missing [Content_Types].xml")
52+
require(content_types_info.file_size <= MAX_XML_PART,
53+
"oversized XML part: [Content_Types].xml")
54+
with archive.open(content_types_info) as stream:
55+
content_types_blob = stream.read(MAX_XML_PART + 1)
56+
require(len(content_types_blob) <= MAX_XML_PART,
57+
"oversized XML part: [Content_Types].xml")
58+
root = etree.fromstring(content_types_blob, parser=safe_xml_parser)
59+
require(root.tag == f"{{{CONTENT_TYPES_NAMESPACE}}}Types",
60+
"invalid [Content_Types].xml root")
61+
defaults = {}
62+
overrides = {}
63+
for child in root:
64+
if child.tag == f"{{{CONTENT_TYPES_NAMESPACE}}}Default":
65+
extension = (child.get("Extension") or "").casefold()
66+
require(extension and extension not in defaults,
67+
"invalid duplicate content-type default")
68+
defaults[extension] = child.get("ContentType") or ""
69+
elif child.tag == f"{{{CONTENT_TYPES_NAMESPACE}}}Override":
70+
part_name = child.get("PartName") or ""
71+
require(part_name.startswith("/") and part_name[1:] not in overrides,
72+
"invalid duplicate content-type override")
73+
overrides[part_name[1:]] = child.get("ContentType") or ""
74+
xml_names = {"[Content_Types].xml"}
75+
for info in infos:
76+
suffix = info.filename.rsplit(".", 1)[1].casefold() if "." in info.filename else ""
77+
content_type = overrides.get(info.filename, defaults.get(suffix, ""))
78+
if (info.filename.casefold().endswith((".xml", ".rels"))
79+
or xml_content_type(content_type)):
80+
xml_names.add(info.filename)
81+
return xml_names
4182
# Check the package itself before ZipFile materializes its central directory.
4283
require(Path(path).stat().st_size <= MAX_ARCHIVE_BYTES,
4384
"compressed DOCX file size above limit")
@@ -51,12 +92,13 @@ with zipfile.ZipFile(path) as z:
5192
"missing required OPC members")
5293
require(sum(info.file_size for info in infos) <= MAX_TOTAL_UNCOMPRESSED,
5394
"declared total uncompressed size above limit")
95+
xml_names = declared_xml_parts(z, infos)
5496
actual_total = 0
5597
for info in infos:
5698
require(info.file_size <= MAX_ENTRY, f"oversized part: {info.filename}")
5799
ratio = info.file_size / max(info.compress_size, 1)
58100
require(ratio <= MAX_COMPRESSION_RATIO, f"suspicious compression ratio: {info.filename}")
59-
is_xml = info.filename.endswith((".xml", ".rels"))
101+
is_xml = info.filename in xml_names
60102
if is_xml:
61103
require(info.file_size <= MAX_XML_PART, f"oversized XML part: {info.filename}")
62104
chunks = []

plugins/Hylouis233/document-skills/skills/pdf/references/inspect.md

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,48 @@ MAX_TOTAL_IMAGE_RENDER_PIXELS = 20_000_000
163163
MAX_DRAWING_PATHS = 1_000
164164
MAX_DRAWING_RENDER_PIXELS = 4_000_000
165165
MAX_TOTAL_DRAWING_RENDER_PIXELS = 20_000_000
166+
MAX_TEXT_SPANS = 10_000
167+
MAX_TEXT_RENDER_PIXELS = 4_000_000
168+
MAX_TOTAL_TEXT_RENDER_PIXELS = 20_000_000
169+
170+
def viewable_text(page):
171+
"""Use text render mode, opacity, clipping, and bounded alpha rendering."""
172+
try:
173+
spans = page.get_texttrace()
174+
except (RuntimeError, ValueError):
175+
return [], [], True
176+
if len(spans) > MAX_TEXT_SPANS:
177+
return spans, [], True
178+
visible = []
179+
total_render_pixels = 0
180+
for span in spans:
181+
try:
182+
text = "".join(chr(character[0]) for character in span.get("chars", ()))
183+
render_type = int(span.get("type"))
184+
opacity = float(span.get("opacity"))
185+
except (TypeError, ValueError, OverflowError):
186+
return spans, visible, True
187+
if not text.strip() or render_type > 1 or opacity <= 0:
188+
continue
189+
if render_type not in (0, 1) or not math.isfinite(opacity):
190+
return spans, visible, True
191+
clip = visible_clip(page, span.get("bbox"))
192+
if clip is None:
193+
continue
194+
render_pixels = math.ceil(clip.width) * math.ceil(clip.height)
195+
total_render_pixels += render_pixels
196+
if (render_pixels > MAX_TEXT_RENDER_PIXELS
197+
or total_render_pixels > MAX_TOTAL_TEXT_RENDER_PIXELS):
198+
return spans, visible, True
199+
try:
200+
pixmap = page.get_pixmap(clip=clip, alpha=True, annots=False)
201+
except (RuntimeError, ValueError):
202+
return spans, visible, True
203+
if not pixmap.alpha:
204+
return spans, visible, True
205+
if any(pixmap.samples[pixmap.n - 1::pixmap.n]):
206+
visible.append(span)
207+
return spans, visible, False
166208

167209
def viewable_images(page):
168210
"""Render bounded placement clips; unknown visibility keeps the page nonblank."""
@@ -338,17 +380,20 @@ for page in doc:
338380
"crop_size": crop_size,
339381
"rotation": page.rotation,
340382
})
383+
text_spans, visible_text, text_visibility_unknown = viewable_text(page)
341384
image_placements, visible_images, image_visibility_unknown = viewable_images(page)
342385
drawings, visible_drawings, drawing_visibility_unknown = viewable_drawings(page)
343386
widgets, annotations, links, interaction_visibility_unknown = viewable_interactives(page)
344387
is_blank = not (
345-
page.get_text().strip() or visible_images or visible_drawings
388+
visible_text or visible_images or visible_drawings
346389
or widgets or annotations or links
347-
or image_visibility_unknown or drawing_visibility_unknown
390+
or text_visibility_unknown or image_visibility_unknown or drawing_visibility_unknown
348391
or interaction_visibility_unknown
349392
)
350393
print(page.number + 1, "media_size:", media_size, "crop_size:", crop_size,
351394
"rotation:", page.rotation, "text_len:", len(page.get_text()),
395+
"text_spans:", len(text_spans), "visible_text_spans:", len(visible_text),
396+
"text_visibility_unknown:", text_visibility_unknown,
352397
"resource_images:", len(page.get_images()),
353398
"image_placements:", len(image_placements),
354399
"visible_images:", len(visible_images),
@@ -366,7 +411,7 @@ print("crop_size_consistent:", len({row["crop_size"] for row in page_geometry})
366411

367412
## Checks worth automating
368413

369-
- **Blank page detection**: flag only when text, visible painted image placements and vector paths,
414+
- **Blank page detection**: flag only when rendered text, visible painted image placements and vector paths,
370415
and viewable widgets, annotations, and links are all absent. Ignore interactive objects carrying
371416
invisible, hidden, or no-view flags, as well as empty, off-page, or unrendered appearances.
372417
`page.get_images()` lists every image XObject resource, including unused resources, and also

plugins/Hylouis233/document-skills/skills/xlsx/SKILL.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ expected_number_formats = {
7878
expected_formulas = {
7979
# "Sales": {"D2": "=C2*1.08"},
8080
}
81+
# Populate every expected sheet with the exact used range required by the task.
82+
expected_dimensions = {
83+
# "Sales": "A1:E20",
84+
# "Summary": "A1:C8",
85+
}
8186
wb = load_validated_workbook(output_path)
8287

8388
def require(condition, message):
@@ -98,6 +103,10 @@ def formula_text(value):
98103
print("sheets:", wb.sheetnames)
99104
missing = set(expected_sheets) - set(wb.sheetnames)
100105
require(not missing, f"missing expected sheets: {sorted(missing)}")
106+
require(
107+
set(expected_dimensions) == set(expected_sheets),
108+
"expected_dimensions must declare the exact used range for every expected sheet",
109+
)
101110
calc = wb.calculation
102111
print("calcMode:", getattr(calc, "calcMode", None),
103112
"fullCalcOnLoad:", getattr(calc, "fullCalcOnLoad", None))
@@ -109,6 +118,12 @@ if any(expected_formulas.values()):
109118
)
110119
for ws in wb.worksheets:
111120
print(f"{ws.title} dims:", ws.dimensions)
121+
if ws.title in expected_dimensions:
122+
require(
123+
ws.dimensions == expected_dimensions[ws.title],
124+
f"{ws.title}: expected used range {expected_dimensions[ws.title]!r}, "
125+
f"got {ws.dimensions!r}",
126+
)
112127
# `expected_formulas` is the task contract, so verify those coordinates directly.
113128
# Never call unbounded iter_rows(): one styled extreme cell can make the rectangle huge.
114129
actual_formulas = {}

plugins/Hylouis233/document-skills/skills/xlsx/references/edit.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,9 @@ wb.save("input-edited.xlsx")
296296
"""Dry-run one workbook, then return a fresh edit copy from the same source."""
297297
require(not load_options.get("read_only"),
298298
"round-trip audit requires a normal writable Workbook")
299+
require(load_options.get("rich_text", True) is True,
300+
"round-trip audit must preserve rich-text cell runs")
301+
load_options["rich_text"] = True
299302
source.seek(0)
300303
before_names, before_extensions = archive_inventory(source)
301304
source.seek(0)

0 commit comments

Comments
 (0)