dsh-office-kit 0.2.0 → 0.3.0

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.
Files changed (197) hide show
  1. package/CAPABILITY.md +147 -68
  2. package/README.md +3 -1
  3. package/assets/skills/excel-live-control/SKILL.md +234 -0
  4. package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
  5. package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
  6. package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
  7. package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
  8. package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
  9. package/assets/skills/excel-live-control/references/office-automation.md +207 -0
  10. package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
  11. package/assets/skills/excel-live-control/style_guidelines.md +87 -0
  12. package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
  13. package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
  14. package/assets/skills/office-documents/SKILL.md +90 -77
  15. package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
  16. package/assets/skills/office-documents/ooxml/comments.md +40 -0
  17. package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
  18. package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
  19. package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
  20. package/assets/skills/office-documents/references/docx-structure.md +83 -0
  21. package/assets/skills/office-documents/references/template-elicitation.md +49 -0
  22. package/assets/skills/office-documents/references/writing-quality.md +49 -0
  23. package/assets/skills/office-documents/render_docx.py +92 -0
  24. package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
  25. package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
  26. package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
  27. package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
  28. package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
  29. package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
  30. package/assets/skills/office-documents/scripts/comments_add.py +136 -0
  31. package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
  32. package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
  33. package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
  34. package/assets/skills/office-documents/scripts/content_controls.py +192 -0
  35. package/assets/skills/office-documents/scripts/core_properties.py +124 -0
  36. package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
  37. package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
  38. package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
  39. package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
  40. package/assets/skills/office-documents/scripts/fields_report.py +59 -0
  41. package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
  42. package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
  43. package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
  44. package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
  45. package/assets/skills/office-documents/scripts/images_audit.py +80 -0
  46. package/assets/skills/office-documents/scripts/insert_note.py +126 -0
  47. package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
  48. package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
  49. package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
  50. package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
  51. package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
  52. package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
  53. package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
  54. package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
  55. package/assets/skills/office-documents/scripts/replace_text.py +154 -0
  56. package/assets/skills/office-documents/scripts/section_audit.py +91 -0
  57. package/assets/skills/office-documents/scripts/set_protection.py +92 -0
  58. package/assets/skills/office-documents/scripts/style_lint.py +111 -0
  59. package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
  60. package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
  61. package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
  62. package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
  63. package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -0
  64. package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
  65. package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
  66. package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
  67. package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
  68. package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
  69. package/assets/skills/office-documents/tasks/create_edit.md +107 -0
  70. package/assets/skills/office-documents/tasks/equations.md +59 -0
  71. package/assets/skills/office-documents/tasks/fields_update.md +44 -0
  72. package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
  73. package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
  74. package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
  75. package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
  76. package/assets/skills/office-documents/tasks/images_figures.md +63 -0
  77. package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
  78. package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
  79. package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
  80. package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
  81. package/assets/skills/office-documents/tasks/read_review.md +50 -0
  82. package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
  83. package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
  84. package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
  85. package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
  86. package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
  87. package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
  88. package/assets/skills/office-documents/tasks/verify_render.md +50 -0
  89. package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
  90. package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
  91. package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
  92. package/assets/skills/office-pdf/SKILL.md +73 -56
  93. package/assets/skills/office-pdf/references/form-fields.md +56 -0
  94. package/assets/skills/office-pdf/references/geometry.md +44 -0
  95. package/assets/skills/office-pdf/references/layout-quality.md +41 -0
  96. package/assets/skills/office-pdf/references/limits.md +58 -0
  97. package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
  98. package/assets/skills/office-pdf/scripts/forms.py +556 -0
  99. package/assets/skills/office-pdf/scripts/metadata.py +161 -0
  100. package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
  101. package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
  102. package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
  103. package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
  104. package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
  105. package/assets/skills/office-pdf/tasks/annotations.md +98 -0
  106. package/assets/skills/office-pdf/tasks/create.md +75 -0
  107. package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
  108. package/assets/skills/office-pdf/tasks/forms.md +67 -0
  109. package/assets/skills/office-pdf/tasks/read.md +62 -0
  110. package/assets/skills/office-pdf/tasks/redact.md +72 -0
  111. package/assets/skills/office-pdf/tasks/stamp.md +60 -0
  112. package/assets/skills/office-presentations/SKILL.md +141 -87
  113. package/assets/skills/office-presentations/container_tools/add_slides.py +136 -0
  114. package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
  115. package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
  116. package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
  117. package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
  118. package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
  119. package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
  120. package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
  121. package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
  122. package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
  123. package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
  124. package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
  125. package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -0
  126. package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
  127. package/assets/skills/office-presentations/recipes/README.md +71 -0
  128. package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
  129. package/assets/skills/office-presentations/recipes/connectors.md +83 -0
  130. package/assets/skills/office-presentations/recipes/images.md +80 -0
  131. package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
  132. package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
  133. package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
  134. package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
  135. package/assets/skills/office-presentations/recipes/tables.md +88 -0
  136. package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
  137. package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
  138. package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
  139. package/assets/skills/office-presentations/references/finalization.md +63 -0
  140. package/assets/skills/office-presentations/references/native-bullets.md +78 -0
  141. package/assets/skills/office-presentations/references/native-evidence.md +67 -0
  142. package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
  143. package/assets/skills/office-presentations/references/templates.md +76 -0
  144. package/assets/skills/office-presentations/routing/google_slides.md +45 -0
  145. package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
  146. package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
  147. package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
  148. package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
  149. package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
  150. package/assets/skills/office-presentations/tasks/template-following.md +132 -0
  151. package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +176 -0
  152. package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
  153. package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
  154. package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
  155. package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
  156. package/assets/skills/office-spreadsheets/SKILL.md +105 -101
  157. package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
  158. package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
  159. package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
  160. package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
  161. package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
  162. package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
  163. package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
  164. package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
  165. package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -0
  166. package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
  167. package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
  168. package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
  169. package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
  170. package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
  171. package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
  172. package/assets/skills/office-spreadsheets/references/charts.md +63 -0
  173. package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
  174. package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
  175. package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
  176. package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
  177. package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
  178. package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
  179. package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
  180. package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
  181. package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
  182. package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
  183. package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
  184. package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
  185. package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
  186. package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
  187. package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
  188. package/assets/skills/shared/annotation-recipes.md +252 -0
  189. package/assets/skills/shared/check_runtime.py +253 -0
  190. package/assets/skills/shared/common-workflow.md +139 -0
  191. package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
  192. package/dist/client.js +4 -4
  193. package/dist/client.js.map +1 -1
  194. package/dist/index.js +239 -89
  195. package/package.json +1 -1
  196. package/postinstall.mjs +92 -82
  197. package/python/office_cli.py +171 -246
@@ -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()