dsh-office-kit 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CAPABILITY.md +147 -68
- package/README.md +3 -1
- package/assets/skills/excel-live-control/SKILL.md +234 -0
- package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
- package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
- package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
- package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
- package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
- package/assets/skills/excel-live-control/references/office-automation.md +207 -0
- package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
- package/assets/skills/excel-live-control/style_guidelines.md +87 -0
- package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
- package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
- package/assets/skills/office-documents/SKILL.md +90 -77
- package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
- package/assets/skills/office-documents/ooxml/comments.md +40 -0
- package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
- package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
- package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
- package/assets/skills/office-documents/references/docx-structure.md +83 -0
- package/assets/skills/office-documents/references/template-elicitation.md +49 -0
- package/assets/skills/office-documents/references/writing-quality.md +49 -0
- package/assets/skills/office-documents/render_docx.py +99 -0
- package/assets/skills/office-documents/scripts/a11y_audit.py +201 -0
- package/assets/skills/office-documents/scripts/accept_tracked_changes.py +158 -0
- package/assets/skills/office-documents/scripts/add_tracked_replacements.py +155 -0
- package/assets/skills/office-documents/scripts/append_blocks.py +115 -0
- package/assets/skills/office-documents/scripts/apply_template_styles.py +111 -0
- package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +109 -0
- package/assets/skills/office-documents/scripts/comments_add.py +143 -0
- package/assets/skills/office-documents/scripts/comments_apply_patch.py +121 -0
- package/assets/skills/office-documents/scripts/comments_extract.py +92 -0
- package/assets/skills/office-documents/scripts/comments_strip.py +81 -0
- package/assets/skills/office-documents/scripts/content_controls.py +199 -0
- package/assets/skills/office-documents/scripts/core_properties.py +131 -0
- package/assets/skills/office-documents/scripts/docx_kit.py +594 -0
- package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +232 -0
- package/assets/skills/office-documents/scripts/docx_table_to_csv.py +106 -0
- package/assets/skills/office-documents/scripts/fields_materialize.py +73 -0
- package/assets/skills/office-documents/scripts/fields_report.py +66 -0
- package/assets/skills/office-documents/scripts/flatten_ref_fields.py +69 -0
- package/assets/skills/office-documents/scripts/footnotes_report.py +84 -0
- package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +103 -0
- package/assets/skills/office-documents/scripts/heading_audit.py +88 -0
- package/assets/skills/office-documents/scripts/images_audit.py +87 -0
- package/assets/skills/office-documents/scripts/insert_note.py +133 -0
- package/assets/skills/office-documents/scripts/insert_ref_fields.py +97 -0
- package/assets/skills/office-documents/scripts/insert_toc.py +91 -0
- package/assets/skills/office-documents/scripts/internal_nav.py +153 -0
- package/assets/skills/office-documents/scripts/make_fixtures.py +176 -0
- package/assets/skills/office-documents/scripts/merge_docx_append.py +246 -0
- package/assets/skills/office-documents/scripts/privacy_scrub.py +138 -0
- package/assets/skills/office-documents/scripts/redact_docx.py +120 -0
- package/assets/skills/office-documents/scripts/render_and_diff.py +108 -0
- package/assets/skills/office-documents/scripts/replace_text.py +161 -0
- package/assets/skills/office-documents/scripts/section_audit.py +98 -0
- package/assets/skills/office-documents/scripts/set_protection.py +99 -0
- package/assets/skills/office-documents/scripts/style_lint.py +118 -0
- package/assets/skills/office-documents/scripts/style_normalize.py +110 -0
- package/assets/skills/office-documents/scripts/table_geometry.py +174 -0
- package/assets/skills/office-documents/scripts/watermark_add.py +55 -0
- package/assets/skills/office-documents/scripts/watermark_audit_remove.py +74 -0
- package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +124 -0
- package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
- package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
- package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
- package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
- package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
- package/assets/skills/office-documents/tasks/create_edit.md +107 -0
- package/assets/skills/office-documents/tasks/equations.md +59 -0
- package/assets/skills/office-documents/tasks/fields_update.md +44 -0
- package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
- package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
- package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
- package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
- package/assets/skills/office-documents/tasks/images_figures.md +63 -0
- package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
- package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
- package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
- package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
- package/assets/skills/office-documents/tasks/read_review.md +50 -0
- package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
- package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
- package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
- package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
- package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
- package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
- package/assets/skills/office-documents/tasks/verify_render.md +50 -0
- package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
- package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
- package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
- package/assets/skills/office-pdf/SKILL.md +73 -56
- package/assets/skills/office-pdf/references/form-fields.md +56 -0
- package/assets/skills/office-pdf/references/geometry.md +44 -0
- package/assets/skills/office-pdf/references/layout-quality.md +41 -0
- package/assets/skills/office-pdf/references/limits.md +58 -0
- package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
- package/assets/skills/office-pdf/scripts/forms.py +556 -0
- package/assets/skills/office-pdf/scripts/metadata.py +161 -0
- package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
- package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
- package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
- package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
- package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
- package/assets/skills/office-pdf/tasks/annotations.md +98 -0
- package/assets/skills/office-pdf/tasks/create.md +75 -0
- package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
- package/assets/skills/office-pdf/tasks/forms.md +67 -0
- package/assets/skills/office-pdf/tasks/read.md +62 -0
- package/assets/skills/office-pdf/tasks/redact.md +72 -0
- package/assets/skills/office-pdf/tasks/stamp.md +60 -0
- package/assets/skills/office-presentations/SKILL.md +141 -87
- package/assets/skills/office-presentations/container_tools/add_slides.py +143 -0
- package/assets/skills/office-presentations/container_tools/core_properties.py +127 -0
- package/assets/skills/office-presentations/container_tools/create_montage.py +133 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +292 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +164 -0
- package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +151 -0
- package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +206 -0
- package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +219 -0
- package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +352 -0
- package/assets/skills/office-presentations/container_tools/preview_render.py +382 -0
- package/assets/skills/office-presentations/container_tools/render_slides.py +146 -0
- package/assets/skills/office-presentations/container_tools/replace_text.py +175 -0
- package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +236 -0
- package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
- package/assets/skills/office-presentations/recipes/README.md +71 -0
- package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
- package/assets/skills/office-presentations/recipes/connectors.md +83 -0
- package/assets/skills/office-presentations/recipes/images.md +80 -0
- package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
- package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
- package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
- package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
- package/assets/skills/office-presentations/recipes/tables.md +88 -0
- package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
- package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
- package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
- package/assets/skills/office-presentations/references/finalization.md +63 -0
- package/assets/skills/office-presentations/references/native-bullets.md +78 -0
- package/assets/skills/office-presentations/references/native-evidence.md +67 -0
- package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
- package/assets/skills/office-presentations/references/templates.md +76 -0
- package/assets/skills/office-presentations/routing/google_slides.md +45 -0
- package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
- package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
- package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
- package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
- package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
- package/assets/skills/office-presentations/tasks/template-following.md +132 -0
- package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +183 -0
- package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +161 -0
- package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +118 -0
- package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +246 -0
- package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +192 -0
- package/assets/skills/office-spreadsheets/SKILL.md +105 -101
- package/assets/skills/office-spreadsheets/container_tools/add_chart.py +243 -0
- package/assets/skills/office-spreadsheets/container_tools/add_image.py +114 -0
- package/assets/skills/office-spreadsheets/container_tools/append_rows.py +129 -0
- package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +313 -0
- package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +405 -0
- package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +287 -0
- package/assets/skills/office-spreadsheets/container_tools/set_cells.py +142 -0
- package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +141 -0
- package/assets/skills/office-spreadsheets/container_tools/style_cells.py +140 -0
- package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
- package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
- package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
- package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
- package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
- package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
- package/assets/skills/office-spreadsheets/references/charts.md +63 -0
- package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
- package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
- package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
- package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
- package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
- package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
- package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
- package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
- package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
- package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
- package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
- package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
- package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
- package/assets/skills/shared/annotation-recipes.md +252 -0
- package/assets/skills/shared/check_runtime.py +260 -0
- package/assets/skills/shared/common-workflow.md +139 -0
- package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/index.js +246 -89
- package/package.json +1 -1
- package/postinstall.mjs +92 -82
- package/python/office_cli.py +213 -254
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Erase a normalised page region and report exactly what disappeared.
|
|
3
|
+
|
|
4
|
+
Accepts the annotation payload the client sends (1-based page, region
|
|
5
|
+
normalised 0-1 against the rendered page) or explicit values, converts the
|
|
6
|
+
region into a PDF rectangle, pads it by a couple of points, and removes the text
|
|
7
|
+
and drawings the rectangle covers. The JSON result lists the removed characters
|
|
8
|
+
and removed drawing objects, so an agent can tell whether the region hit what
|
|
9
|
+
the user circled and nothing else.
|
|
10
|
+
|
|
11
|
+
Exit codes: 0 success, 1 bad arguments, 2 unreadable input or write failure,
|
|
12
|
+
3 refused (page or region outside the document).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
23
|
+
try:
|
|
24
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
25
|
+
except (AttributeError, ValueError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import pymupdf
|
|
30
|
+
except ImportError: # pragma: no cover - PyMuPDF is a required dependency
|
|
31
|
+
import fitz as pymupdf # type: ignore[no-redef]
|
|
32
|
+
|
|
33
|
+
PDF_REDACT_IMAGE_NONE = getattr(pymupdf, "PDF_REDACT_IMAGE_NONE", 0)
|
|
34
|
+
PDF_REDACT_IMAGE_PIXELS = getattr(pymupdf, "PDF_REDACT_IMAGE_PIXELS", 1)
|
|
35
|
+
PDF_REDACT_LINE_ART_NONE = getattr(pymupdf, "PDF_REDACT_LINE_ART_NONE", 0)
|
|
36
|
+
PDF_REDACT_LINE_ART_REMOVE_IF_TOUCHED = getattr(pymupdf, "PDF_REDACT_LINE_ART_REMOVE_IF_TOUCHED", 2)
|
|
37
|
+
PDF_REDACT_TEXT_REMOVE = getattr(pymupdf, "PDF_REDACT_TEXT_REMOVE", 0)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def emit(value, out=None):
|
|
41
|
+
text = json.dumps(value, ensure_ascii=False, indent=2, default=str)
|
|
42
|
+
if out:
|
|
43
|
+
target = Path(out).expanduser()
|
|
44
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
target.write_text(text + "\n", encoding="utf-8")
|
|
46
|
+
print(text)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fail(message, code=1, detail=None):
|
|
50
|
+
payload = {"ok": False, "error": message}
|
|
51
|
+
if detail is not None:
|
|
52
|
+
payload["detail"] = detail
|
|
53
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str), file=sys.stderr)
|
|
54
|
+
raise SystemExit(code)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_annotations(spec):
|
|
58
|
+
if spec == "-":
|
|
59
|
+
raw = sys.stdin.read()
|
|
60
|
+
elif spec.strip().startswith(("{", "[")):
|
|
61
|
+
raw = spec
|
|
62
|
+
else:
|
|
63
|
+
path = Path(spec).expanduser()
|
|
64
|
+
if not path.is_file():
|
|
65
|
+
fail(f"annotation payload not found: {path}", code=2)
|
|
66
|
+
raw = path.read_text(encoding="utf-8-sig")
|
|
67
|
+
try:
|
|
68
|
+
data = json.loads(raw)
|
|
69
|
+
except json.JSONDecodeError as exc:
|
|
70
|
+
fail(f"invalid annotation JSON: {exc}")
|
|
71
|
+
if isinstance(data, dict):
|
|
72
|
+
data = data.get("annotations", [data])
|
|
73
|
+
if not isinstance(data, list) or not data:
|
|
74
|
+
fail("annotation payload must be a non-empty list of annotations")
|
|
75
|
+
annotations = []
|
|
76
|
+
for index, item in enumerate(data, start=1):
|
|
77
|
+
if not isinstance(item, dict):
|
|
78
|
+
fail(f"annotation {index} is not an object")
|
|
79
|
+
region = item.get("region")
|
|
80
|
+
if not isinstance(region, dict):
|
|
81
|
+
fail(f"annotation {index} has no region object")
|
|
82
|
+
try:
|
|
83
|
+
page = int(item["page"])
|
|
84
|
+
left = float(region["left"])
|
|
85
|
+
top = float(region["top"])
|
|
86
|
+
width = float(region["width"])
|
|
87
|
+
height = float(region["height"])
|
|
88
|
+
except (KeyError, TypeError, ValueError):
|
|
89
|
+
fail(f"annotation {index} needs numeric page and region left/top/width/height")
|
|
90
|
+
annotations.append(
|
|
91
|
+
{
|
|
92
|
+
"index": index,
|
|
93
|
+
"page": page,
|
|
94
|
+
"region": {"left": left, "top": top, "width": width, "height": height},
|
|
95
|
+
"text": item.get("text"),
|
|
96
|
+
"requirement": item.get("requirement"),
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
return annotations
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_fill(spec):
|
|
103
|
+
if spec.lower() in {"none", "erase", ""}:
|
|
104
|
+
return None
|
|
105
|
+
value = spec.lstrip("#")
|
|
106
|
+
if len(value) != 6:
|
|
107
|
+
fail("--fill must be 'none' or a 6-digit hex colour like #ffffff")
|
|
108
|
+
try:
|
|
109
|
+
return tuple(int(value[offset : offset + 2], 16) / 255.0 for offset in (0, 2, 4))
|
|
110
|
+
except ValueError:
|
|
111
|
+
fail("--fill must be 'none' or a 6-digit hex colour like #ffffff")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def page_chars(page):
|
|
115
|
+
return len(page.get_text("text"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def page_words(page):
|
|
119
|
+
return len(page.get_text("words"))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def rounded(rect):
|
|
123
|
+
return [round(float(value), 2) for value in rect]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main():
|
|
127
|
+
parser = argparse.ArgumentParser(
|
|
128
|
+
description="Remove the text and drawings inside a normalised page region and report what vanished.",
|
|
129
|
+
epilog='Example: redact_region.py --input in.pdf --out out.pdf --annotations \'{"page":1,"region":{"left":0.064,"top":0.318,"width":0.891,"height":0.153}}\'',
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument("--input", required=True, help="Source PDF; it is never modified")
|
|
132
|
+
parser.add_argument("--out", required=True, help="Destination PDF")
|
|
133
|
+
parser.add_argument(
|
|
134
|
+
"--annotations",
|
|
135
|
+
help="Draft annotation payload: a JSON list of {page, region:{left,top,width,height}}, a file path, inline JSON, or '-'",
|
|
136
|
+
)
|
|
137
|
+
parser.add_argument("--page", type=int, help="Single-region shorthand: 1-based page number")
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--region",
|
|
140
|
+
help="Single-region shorthand: left,top,width,height normalised 0-1, e.g. 0.06,0.32,0.89,0.15",
|
|
141
|
+
)
|
|
142
|
+
parser.add_argument("--pad", type=float, default=2.5, help="Points added around the region (default: 2.5)")
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--fill",
|
|
145
|
+
default="none",
|
|
146
|
+
help="Paint the cleared area: 'none' erases content without drawing (default), or a hex colour",
|
|
147
|
+
)
|
|
148
|
+
parser.add_argument(
|
|
149
|
+
"--images",
|
|
150
|
+
choices=("none", "pixels"),
|
|
151
|
+
default="none",
|
|
152
|
+
help="Leave images alone (default) or also blank image pixels inside the region",
|
|
153
|
+
)
|
|
154
|
+
parser.add_argument(
|
|
155
|
+
"--graphics",
|
|
156
|
+
choices=("remove", "keep"),
|
|
157
|
+
default="remove",
|
|
158
|
+
help="Remove line art touching the region (default) or leave it",
|
|
159
|
+
)
|
|
160
|
+
parser.add_argument("--render-dir", help="Rasterise the pages this tool changed for a visual check")
|
|
161
|
+
parser.add_argument("--dpi", type=int, default=120, help="Render resolution (default: 120)")
|
|
162
|
+
parser.add_argument("--json-out", help="Also write the JSON report to this path")
|
|
163
|
+
args = parser.parse_args()
|
|
164
|
+
|
|
165
|
+
source = Path(args.input).expanduser()
|
|
166
|
+
if not source.is_file():
|
|
167
|
+
fail(f"input not found: {source}", code=2)
|
|
168
|
+
out = Path(args.out).expanduser()
|
|
169
|
+
if out.resolve() == source.resolve():
|
|
170
|
+
fail("refusing to overwrite the source PDF; choose a different --out", code=3)
|
|
171
|
+
if args.pad < 0 or args.pad > 36:
|
|
172
|
+
fail("--pad must be between 0 and 36 points")
|
|
173
|
+
if args.dpi < 24 or args.dpi > 600:
|
|
174
|
+
fail("--dpi must be between 24 and 600")
|
|
175
|
+
|
|
176
|
+
if args.annotations and (args.page or args.region):
|
|
177
|
+
fail("use either --annotations or --page/--region, not both")
|
|
178
|
+
if args.annotations:
|
|
179
|
+
annotations = parse_annotations(args.annotations)
|
|
180
|
+
elif args.page and args.region:
|
|
181
|
+
try:
|
|
182
|
+
parts = [float(part) for part in args.region.split(",")]
|
|
183
|
+
except ValueError:
|
|
184
|
+
fail("--region must be four comma-separated numbers")
|
|
185
|
+
if len(parts) != 4:
|
|
186
|
+
fail("--region must be four comma-separated numbers: left,top,width,height")
|
|
187
|
+
annotations = [
|
|
188
|
+
{
|
|
189
|
+
"index": 1,
|
|
190
|
+
"page": args.page,
|
|
191
|
+
"region": dict(zip(("left", "top", "width", "height"), parts)),
|
|
192
|
+
"text": None,
|
|
193
|
+
"requirement": None,
|
|
194
|
+
}
|
|
195
|
+
]
|
|
196
|
+
else:
|
|
197
|
+
fail("provide --annotations or both --page and --region")
|
|
198
|
+
|
|
199
|
+
fill = parse_fill(args.fill)
|
|
200
|
+
image_mode = PDF_REDACT_IMAGE_NONE if args.images == "none" else PDF_REDACT_IMAGE_PIXELS
|
|
201
|
+
graphics_mode = (
|
|
202
|
+
PDF_REDACT_LINE_ART_REMOVE_IF_TOUCHED if args.graphics == "remove" else PDF_REDACT_LINE_ART_NONE
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
document = pymupdf.open(str(source))
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
fail(f"cannot open PDF: {exc}", code=2)
|
|
209
|
+
if document.needs_pass:
|
|
210
|
+
fail("PDF is encrypted; decrypt it before redacting", code=3)
|
|
211
|
+
|
|
212
|
+
for annotation in annotations:
|
|
213
|
+
if annotation["page"] < 1 or annotation["page"] > document.page_count:
|
|
214
|
+
fail(
|
|
215
|
+
f"annotation {annotation['index']} names page {annotation['page']} but the document has {document.page_count} pages",
|
|
216
|
+
code=3,
|
|
217
|
+
)
|
|
218
|
+
region = annotation["region"]
|
|
219
|
+
if any(value < 0 or value > 1 for value in region.values()):
|
|
220
|
+
fail(f"annotation {annotation['index']} region must be normalised 0-1", code=3)
|
|
221
|
+
|
|
222
|
+
by_page = {}
|
|
223
|
+
for annotation in annotations:
|
|
224
|
+
by_page.setdefault(annotation["page"], []).append(annotation)
|
|
225
|
+
|
|
226
|
+
records = []
|
|
227
|
+
page_changes = []
|
|
228
|
+
for page_number in sorted(by_page):
|
|
229
|
+
page = document[page_number - 1]
|
|
230
|
+
page_rect = page.rect
|
|
231
|
+
before_chars = page_chars(page)
|
|
232
|
+
before_words = page_words(page)
|
|
233
|
+
before_drawings = len(page.get_drawings())
|
|
234
|
+
entries = []
|
|
235
|
+
for annotation in by_page[page_number]:
|
|
236
|
+
region = annotation["region"]
|
|
237
|
+
base = pymupdf.Rect(
|
|
238
|
+
page_rect.x0 + region["left"] * page_rect.width,
|
|
239
|
+
page_rect.y0 + region["top"] * page_rect.height,
|
|
240
|
+
page_rect.x0 + (region["left"] + region["width"]) * page_rect.width,
|
|
241
|
+
page_rect.y0 + (region["top"] + region["height"]) * page_rect.height,
|
|
242
|
+
)
|
|
243
|
+
padded = pymupdf.Rect(
|
|
244
|
+
base.x0 - args.pad,
|
|
245
|
+
base.y0 - args.pad,
|
|
246
|
+
base.x1 + args.pad,
|
|
247
|
+
base.y1 + args.pad,
|
|
248
|
+
)
|
|
249
|
+
padded &= page_rect
|
|
250
|
+
covered = page.get_textbox(padded)
|
|
251
|
+
entries.append(
|
|
252
|
+
{
|
|
253
|
+
"annotation": annotation,
|
|
254
|
+
"base": base,
|
|
255
|
+
"padded": padded,
|
|
256
|
+
"text_before": covered,
|
|
257
|
+
"words_before": len(page.get_textbox(padded).split()),
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
page.add_redact_annot(padded, fill=fill)
|
|
261
|
+
|
|
262
|
+
page.apply_redactions(images=image_mode, graphics=graphics_mode, text=PDF_REDACT_TEXT_REMOVE)
|
|
263
|
+
|
|
264
|
+
after_chars = page_chars(page)
|
|
265
|
+
after_words = page_words(page)
|
|
266
|
+
after_drawings = len(page.get_drawings())
|
|
267
|
+
page_changes.append(
|
|
268
|
+
{
|
|
269
|
+
"page": page_number,
|
|
270
|
+
"chars_before": before_chars,
|
|
271
|
+
"chars_after": after_chars,
|
|
272
|
+
"removed_chars": before_chars - after_chars,
|
|
273
|
+
"words_before": before_words,
|
|
274
|
+
"words_after": after_words,
|
|
275
|
+
"removed_words": before_words - after_words,
|
|
276
|
+
"drawings_before": before_drawings,
|
|
277
|
+
"drawings_after": after_drawings,
|
|
278
|
+
"removed_drawings": before_drawings - after_drawings,
|
|
279
|
+
}
|
|
280
|
+
)
|
|
281
|
+
for entry in entries:
|
|
282
|
+
leftover = page.get_textbox(entry["padded"])
|
|
283
|
+
region_text = entry["text_before"]
|
|
284
|
+
records.append(
|
|
285
|
+
{
|
|
286
|
+
"page": page_number,
|
|
287
|
+
"region": entry["annotation"]["region"],
|
|
288
|
+
"rect": rounded(entry["base"]),
|
|
289
|
+
"padded_rect": rounded(entry["padded"]),
|
|
290
|
+
"region_chars_removed": len(region_text) - len(leftover),
|
|
291
|
+
"leftover_chars_in_rect": len(leftover.strip()),
|
|
292
|
+
"text_before": region_text[:400],
|
|
293
|
+
"user_text": entry["annotation"]["text"],
|
|
294
|
+
"requirement": entry["annotation"]["requirement"],
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
299
|
+
try:
|
|
300
|
+
document.save(str(out), garbage=3, deflate=True)
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
document.close()
|
|
303
|
+
fail(f"cannot write {out}: {exc}", code=2)
|
|
304
|
+
page_count = document.page_count
|
|
305
|
+
document.close()
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
from pypdf import PdfReader
|
|
309
|
+
|
|
310
|
+
reader = PdfReader(str(out))
|
|
311
|
+
pypdf_chars = [len(page.extract_text() or "") for page in reader.pages]
|
|
312
|
+
except Exception:
|
|
313
|
+
pypdf_chars = []
|
|
314
|
+
|
|
315
|
+
rendered = []
|
|
316
|
+
if args.render_dir:
|
|
317
|
+
target_dir = Path(args.render_dir).expanduser()
|
|
318
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
319
|
+
zoom = args.dpi / 72.0
|
|
320
|
+
with pymupdf.open(str(out)) as rendered_doc:
|
|
321
|
+
for page_number in sorted(by_page):
|
|
322
|
+
page = rendered_doc[page_number - 1]
|
|
323
|
+
target = target_dir / f"page-{page_number}.png"
|
|
324
|
+
page.get_pixmap(matrix=pymupdf.Matrix(zoom, zoom), alpha=False).save(str(target))
|
|
325
|
+
rendered.append(str(target))
|
|
326
|
+
|
|
327
|
+
result = {
|
|
328
|
+
"ok": True,
|
|
329
|
+
"input": str(source),
|
|
330
|
+
"output": str(out),
|
|
331
|
+
"pages": page_count,
|
|
332
|
+
"pad_points": args.pad,
|
|
333
|
+
"fill": args.fill,
|
|
334
|
+
"images": args.images,
|
|
335
|
+
"graphics": args.graphics,
|
|
336
|
+
"removed_chars": sum(change["removed_chars"] for change in page_changes),
|
|
337
|
+
"removed_drawings": sum(change["removed_drawings"] for change in page_changes),
|
|
338
|
+
"page_changes": page_changes,
|
|
339
|
+
"annotations": records,
|
|
340
|
+
"pypdf_page_chars": pypdf_chars,
|
|
341
|
+
"rendered_pages": rendered,
|
|
342
|
+
}
|
|
343
|
+
emit(result, args.json_out)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
if __name__ == "__main__":
|
|
347
|
+
main()
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Rasterise PDF pages to PNG and report the geometry of each page.
|
|
3
|
+
|
|
4
|
+
Rendering runs here in-process through PyMuPDF, so no external rasteriser has
|
|
5
|
+
to be installed. Besides the PNGs, this prints the numbers an agent needs to
|
|
6
|
+
judge a file: page size, rotation, text volume, image and vector path counts,
|
|
7
|
+
annotations, form widgets, and links. A normalised region can be rendered on
|
|
8
|
+
its own so a change request can be checked against the exact area it named.
|
|
9
|
+
|
|
10
|
+
Exit codes: 0 success, 1 bad arguments, 2 unreadable input or write failure.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import pymupdf
|
|
22
|
+
except ImportError: # pragma: no cover - PyMuPDF is a required dependency
|
|
23
|
+
import fitz as pymupdf # type: ignore[no-redef]
|
|
24
|
+
|
|
25
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
26
|
+
try:
|
|
27
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
28
|
+
except (AttributeError, ValueError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def emit(value, out=None):
|
|
33
|
+
text = json.dumps(value, ensure_ascii=False, indent=2, default=str)
|
|
34
|
+
if out:
|
|
35
|
+
target = Path(out).expanduser()
|
|
36
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
target.write_text(text + "\n", encoding="utf-8")
|
|
38
|
+
print(text)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fail(message, code=1, detail=None):
|
|
42
|
+
payload = {"ok": False, "error": message}
|
|
43
|
+
if detail is not None:
|
|
44
|
+
payload["detail"] = detail
|
|
45
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str), file=sys.stderr)
|
|
46
|
+
raise SystemExit(code)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_pages(spec, page_count):
|
|
50
|
+
if not spec or spec.strip().lower() == "all":
|
|
51
|
+
return list(range(1, page_count + 1))
|
|
52
|
+
pages = []
|
|
53
|
+
try:
|
|
54
|
+
for part in spec.split(","):
|
|
55
|
+
part = part.strip()
|
|
56
|
+
if not part:
|
|
57
|
+
continue
|
|
58
|
+
if "-" in part:
|
|
59
|
+
start_text, end_text = part.split("-", 1)
|
|
60
|
+
start, end = int(start_text), int(end_text)
|
|
61
|
+
else:
|
|
62
|
+
start = end = int(part)
|
|
63
|
+
if start < 1 or end > page_count or start > end:
|
|
64
|
+
fail(f"page range out of bounds: {part} (document has {page_count} pages)")
|
|
65
|
+
pages.extend(range(start, end + 1))
|
|
66
|
+
except ValueError:
|
|
67
|
+
fail(f"cannot parse page selection: {spec!r}")
|
|
68
|
+
return sorted(dict.fromkeys(pages))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_region(spec, page_count):
|
|
72
|
+
"""Parse PAGE:LEFT,TOP,WIDTH,HEIGHT with normalised 0-1 geometry."""
|
|
73
|
+
page_text, _, box_text = spec.partition(":")
|
|
74
|
+
if not box_text:
|
|
75
|
+
fail("region must look like PAGE:LEFT,TOP,WIDTH,HEIGHT, for example 1:0.1,0.2,0.8,0.1")
|
|
76
|
+
try:
|
|
77
|
+
page = int(page_text)
|
|
78
|
+
left, top, width, height = (float(value) for value in box_text.split(","))
|
|
79
|
+
except ValueError:
|
|
80
|
+
fail(f"cannot parse region: {spec!r}")
|
|
81
|
+
if not 1 <= page <= page_count:
|
|
82
|
+
fail(f"region page {page} is outside 1-{page_count}")
|
|
83
|
+
if left < 0 or top < 0 or width <= 0 or height <= 0:
|
|
84
|
+
fail("region needs left/top >= 0 and width/height > 0")
|
|
85
|
+
if left + width > 1.000001 or top + height > 1.000001:
|
|
86
|
+
fail("region must fit inside the page: left+width and top+height are normalised 0-1")
|
|
87
|
+
return page, left, top, width, height
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def region_rect(page, left, top, width, height, padding):
|
|
91
|
+
box = page.rect
|
|
92
|
+
rect = pymupdf.Rect(
|
|
93
|
+
box.x0 + left * box.width,
|
|
94
|
+
box.y0 + top * box.height,
|
|
95
|
+
box.x0 + (left + width) * box.width,
|
|
96
|
+
box.y0 + (top + height) * box.height,
|
|
97
|
+
)
|
|
98
|
+
return (rect + (-padding, -padding, padding, padding)) & box
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def page_report(page, index):
|
|
102
|
+
text = page.get_text()
|
|
103
|
+
words = page.get_text("words")
|
|
104
|
+
return {
|
|
105
|
+
"page": index,
|
|
106
|
+
"width_pt": round(page.rect.width, 2),
|
|
107
|
+
"height_pt": round(page.rect.height, 2),
|
|
108
|
+
"rotation": page.rotation,
|
|
109
|
+
"chars": len(text),
|
|
110
|
+
"words": len(words),
|
|
111
|
+
"images": len(page.get_images(full=True)),
|
|
112
|
+
"drawings": len(page.get_drawings()),
|
|
113
|
+
"annotations": sum(1 for _ in page.annots() or []),
|
|
114
|
+
"widgets": sum(1 for _ in page.widgets() or []),
|
|
115
|
+
"links": len(page.get_links()),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main():
|
|
120
|
+
parser = argparse.ArgumentParser(
|
|
121
|
+
description="Rasterise PDF pages to PNG and report per-page geometry.",
|
|
122
|
+
epilog="Example: render_pages.py --input report.pdf --out-dir tmp/render --dpi 144 --pages 1-2",
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument("--input", required=True, help="PDF file to inspect")
|
|
125
|
+
parser.add_argument("--out-dir", help="Directory for the PNGs; omitted means inspect only")
|
|
126
|
+
parser.add_argument("--pages", default="all", help='1-based selection such as "1-3,7" (default: all)')
|
|
127
|
+
parser.add_argument("--dpi", type=int, default=110, help="Rasterisation resolution (default: 110)")
|
|
128
|
+
parser.add_argument(
|
|
129
|
+
"--region",
|
|
130
|
+
help='Also render one normalised region, PAGE:LEFT,TOP,WIDTH,HEIGHT (0-1, 1-based page)',
|
|
131
|
+
)
|
|
132
|
+
parser.add_argument("--pad", type=float, default=2.5, help="Padding in points around --region (default: 2.5)")
|
|
133
|
+
parser.add_argument("--json-out", help="Also write the JSON report to this path")
|
|
134
|
+
args = parser.parse_args()
|
|
135
|
+
|
|
136
|
+
if args.dpi < 24 or args.dpi > 600:
|
|
137
|
+
fail("--dpi must be between 24 and 600")
|
|
138
|
+
if args.region and not args.out_dir:
|
|
139
|
+
fail("--region needs --out-dir so the crop has somewhere to land")
|
|
140
|
+
|
|
141
|
+
source = Path(args.input).expanduser()
|
|
142
|
+
if not source.is_file():
|
|
143
|
+
fail(f"input not found: {source}", code=2)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
document = pymupdf.open(str(source))
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
fail(f"cannot open PDF: {exc}", code=2)
|
|
149
|
+
|
|
150
|
+
out_dir = Path(args.out_dir).expanduser() if args.out_dir else None
|
|
151
|
+
if out_dir:
|
|
152
|
+
try:
|
|
153
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
except OSError as exc:
|
|
155
|
+
fail(f"cannot create output directory: {exc}", code=2)
|
|
156
|
+
|
|
157
|
+
pages = parse_pages(args.pages, document.page_count)
|
|
158
|
+
region = parse_region(args.region, document.page_count) if args.region else None
|
|
159
|
+
|
|
160
|
+
zoom = args.dpi / 72.0
|
|
161
|
+
matrix = pymupdf.Matrix(zoom, zoom)
|
|
162
|
+
reports = []
|
|
163
|
+
warnings = []
|
|
164
|
+
for index in pages:
|
|
165
|
+
page = document[index - 1]
|
|
166
|
+
report = page_report(page, index)
|
|
167
|
+
if out_dir:
|
|
168
|
+
png = out_dir / f"page-{index}.png"
|
|
169
|
+
page.get_pixmap(matrix=matrix, alpha=False).save(str(png))
|
|
170
|
+
report["png"] = str(png)
|
|
171
|
+
if report["chars"] == 0 and report["images"] > 0:
|
|
172
|
+
warnings.append(f"page {index} has images but no text layer: it may be a scan, which cannot be read as text")
|
|
173
|
+
reports.append(report)
|
|
174
|
+
|
|
175
|
+
if region:
|
|
176
|
+
page_no, left, top, width, height = region
|
|
177
|
+
rect = region_rect(document[page_no - 1], left, top, width, height, args.pad)
|
|
178
|
+
png = out_dir / f"region-p{page_no}.png"
|
|
179
|
+
document[page_no - 1].get_pixmap(matrix=matrix, clip=rect, alpha=False).save(str(png))
|
|
180
|
+
crop = {
|
|
181
|
+
"page": page_no,
|
|
182
|
+
"region": {"left": left, "top": top, "width": width, "height": height},
|
|
183
|
+
"rect_pt": [round(value, 2) for value in rect],
|
|
184
|
+
"pad_pt": args.pad,
|
|
185
|
+
"png": str(png),
|
|
186
|
+
"chars_inside": len(document[page_no - 1].get_textbox(rect)),
|
|
187
|
+
}
|
|
188
|
+
else:
|
|
189
|
+
crop = None
|
|
190
|
+
|
|
191
|
+
emit(
|
|
192
|
+
{
|
|
193
|
+
"ok": True,
|
|
194
|
+
"input": str(source),
|
|
195
|
+
"page_count": document.page_count,
|
|
196
|
+
"dpi": args.dpi,
|
|
197
|
+
"out_dir": str(out_dir) if out_dir else None,
|
|
198
|
+
"pages": reports,
|
|
199
|
+
"region": crop,
|
|
200
|
+
"warnings": warnings,
|
|
201
|
+
},
|
|
202
|
+
args.json_out,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
if __name__ == "__main__":
|
|
207
|
+
main()
|