dsh-office-kit 0.1.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 (198) hide show
  1. package/CAPABILITY.md +147 -68
  2. package/LICENSE.md +21 -21
  3. package/README.md +108 -106
  4. package/assets/skills/excel-live-control/SKILL.md +234 -0
  5. package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
  6. package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
  7. package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
  8. package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
  9. package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
  10. package/assets/skills/excel-live-control/references/office-automation.md +207 -0
  11. package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
  12. package/assets/skills/excel-live-control/style_guidelines.md +87 -0
  13. package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
  14. package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
  15. package/assets/skills/office-documents/SKILL.md +90 -77
  16. package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
  17. package/assets/skills/office-documents/ooxml/comments.md +40 -0
  18. package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
  19. package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
  20. package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
  21. package/assets/skills/office-documents/references/docx-structure.md +83 -0
  22. package/assets/skills/office-documents/references/template-elicitation.md +49 -0
  23. package/assets/skills/office-documents/references/writing-quality.md +49 -0
  24. package/assets/skills/office-documents/render_docx.py +92 -0
  25. package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
  26. package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
  27. package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
  28. package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
  29. package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
  30. package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
  31. package/assets/skills/office-documents/scripts/comments_add.py +136 -0
  32. package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
  33. package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
  34. package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
  35. package/assets/skills/office-documents/scripts/content_controls.py +192 -0
  36. package/assets/skills/office-documents/scripts/core_properties.py +124 -0
  37. package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
  38. package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
  39. package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
  40. package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
  41. package/assets/skills/office-documents/scripts/fields_report.py +59 -0
  42. package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
  43. package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
  44. package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
  45. package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
  46. package/assets/skills/office-documents/scripts/images_audit.py +80 -0
  47. package/assets/skills/office-documents/scripts/insert_note.py +126 -0
  48. package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
  49. package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
  50. package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
  51. package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
  52. package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
  53. package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
  54. package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
  55. package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
  56. package/assets/skills/office-documents/scripts/replace_text.py +154 -0
  57. package/assets/skills/office-documents/scripts/section_audit.py +91 -0
  58. package/assets/skills/office-documents/scripts/set_protection.py +92 -0
  59. package/assets/skills/office-documents/scripts/style_lint.py +111 -0
  60. package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
  61. package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
  62. package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
  63. package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
  64. package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -0
  65. package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
  66. package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
  67. package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
  68. package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
  69. package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
  70. package/assets/skills/office-documents/tasks/create_edit.md +107 -0
  71. package/assets/skills/office-documents/tasks/equations.md +59 -0
  72. package/assets/skills/office-documents/tasks/fields_update.md +44 -0
  73. package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
  74. package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
  75. package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
  76. package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
  77. package/assets/skills/office-documents/tasks/images_figures.md +63 -0
  78. package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
  79. package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
  80. package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
  81. package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
  82. package/assets/skills/office-documents/tasks/read_review.md +50 -0
  83. package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
  84. package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
  85. package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
  86. package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
  87. package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
  88. package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
  89. package/assets/skills/office-documents/tasks/verify_render.md +50 -0
  90. package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
  91. package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
  92. package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
  93. package/assets/skills/office-pdf/SKILL.md +73 -56
  94. package/assets/skills/office-pdf/references/form-fields.md +56 -0
  95. package/assets/skills/office-pdf/references/geometry.md +44 -0
  96. package/assets/skills/office-pdf/references/layout-quality.md +41 -0
  97. package/assets/skills/office-pdf/references/limits.md +58 -0
  98. package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
  99. package/assets/skills/office-pdf/scripts/forms.py +556 -0
  100. package/assets/skills/office-pdf/scripts/metadata.py +161 -0
  101. package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
  102. package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
  103. package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
  104. package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
  105. package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
  106. package/assets/skills/office-pdf/tasks/annotations.md +98 -0
  107. package/assets/skills/office-pdf/tasks/create.md +75 -0
  108. package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
  109. package/assets/skills/office-pdf/tasks/forms.md +67 -0
  110. package/assets/skills/office-pdf/tasks/read.md +62 -0
  111. package/assets/skills/office-pdf/tasks/redact.md +72 -0
  112. package/assets/skills/office-pdf/tasks/stamp.md +60 -0
  113. package/assets/skills/office-presentations/SKILL.md +141 -87
  114. package/assets/skills/office-presentations/container_tools/add_slides.py +136 -0
  115. package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
  116. package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
  117. package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
  118. package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
  119. package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
  120. package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
  121. package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
  122. package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
  123. package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
  124. package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
  125. package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
  126. package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -0
  127. package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
  128. package/assets/skills/office-presentations/recipes/README.md +71 -0
  129. package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
  130. package/assets/skills/office-presentations/recipes/connectors.md +83 -0
  131. package/assets/skills/office-presentations/recipes/images.md +80 -0
  132. package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
  133. package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
  134. package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
  135. package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
  136. package/assets/skills/office-presentations/recipes/tables.md +88 -0
  137. package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
  138. package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
  139. package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
  140. package/assets/skills/office-presentations/references/finalization.md +63 -0
  141. package/assets/skills/office-presentations/references/native-bullets.md +78 -0
  142. package/assets/skills/office-presentations/references/native-evidence.md +67 -0
  143. package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
  144. package/assets/skills/office-presentations/references/templates.md +76 -0
  145. package/assets/skills/office-presentations/routing/google_slides.md +45 -0
  146. package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
  147. package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
  148. package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
  149. package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
  150. package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
  151. package/assets/skills/office-presentations/tasks/template-following.md +132 -0
  152. package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +176 -0
  153. package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
  154. package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
  155. package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
  156. package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
  157. package/assets/skills/office-spreadsheets/SKILL.md +105 -101
  158. package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
  159. package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
  160. package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
  161. package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
  162. package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
  163. package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
  164. package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
  165. package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
  166. package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -0
  167. package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
  168. package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
  169. package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
  170. package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
  171. package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
  172. package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
  173. package/assets/skills/office-spreadsheets/references/charts.md +63 -0
  174. package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
  175. package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
  176. package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
  177. package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
  178. package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
  179. package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
  180. package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
  181. package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
  182. package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
  183. package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
  184. package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
  185. package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
  186. package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
  187. package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
  188. package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
  189. package/assets/skills/shared/annotation-recipes.md +252 -0
  190. package/assets/skills/shared/check_runtime.py +253 -0
  191. package/assets/skills/shared/common-workflow.md +139 -0
  192. package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
  193. package/dist/client.js +4 -4
  194. package/dist/client.js.map +1 -1
  195. package/dist/index.js +239 -89
  196. package/package.json +146 -149
  197. package/postinstall.mjs +92 -82
  198. package/python/office_cli.py +171 -246
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env python3
2
+ """Read or rewrite the document information dictionary of a PDF.
3
+
4
+ Shows the standard metadata fields plus any custom keys the producer stored,
5
+ page geometry, and the PDF version. With --set it writes a new file whose
6
+ standard fields are merged over the originals and then reopens that file to
7
+ confirm the values actually persisted.
8
+
9
+ Exit codes: 0 success, 1 bad arguments, 2 unreadable input or write failure,
10
+ 3 refused (encrypted document or source overwrite attempt).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ for _stream in (sys.stdout, sys.stderr):
21
+ try:
22
+ _stream.reconfigure(encoding="utf-8", errors="replace")
23
+ except (AttributeError, ValueError):
24
+ pass
25
+
26
+ try:
27
+ import pymupdf
28
+ except ImportError: # pragma: no cover - PyMuPDF is a required dependency
29
+ import fitz as pymupdf # type: ignore[no-redef]
30
+
31
+ STANDARD = ("title", "author", "subject", "keywords", "creator", "producer", "creationDate", "modDate")
32
+ ALIASES = {
33
+ "creationdate": "creationDate",
34
+ "moddate": "modDate",
35
+ "modified": "modDate",
36
+ "creation_date": "creationDate",
37
+ "modified_date": "modDate",
38
+ }
39
+
40
+
41
+ def emit(value, out=None):
42
+ text = json.dumps(value, ensure_ascii=False, indent=2, default=str)
43
+ if out:
44
+ target = Path(out).expanduser()
45
+ target.parent.mkdir(parents=True, exist_ok=True)
46
+ target.write_text(text + "\n", encoding="utf-8")
47
+ print(text)
48
+
49
+
50
+ def fail(message, code=1):
51
+ print(json.dumps({"ok": False, "error": message}, ensure_ascii=False, indent=2), file=sys.stderr)
52
+ raise SystemExit(code)
53
+
54
+
55
+ def normalise(key):
56
+ lowered = key.strip().lstrip("/").lower()
57
+ lowered = ALIASES.get(lowered, lowered)
58
+ if lowered in STANDARD:
59
+ return lowered
60
+ fail(f"unknown metadata field {key!r}; supported: {', '.join(STANDARD)} (plain names or /Name keys)")
61
+
62
+
63
+ def clean(info):
64
+ values = {}
65
+ for key in STANDARD:
66
+ value = (info or {}).get(key)
67
+ if value:
68
+ values[key] = value
69
+ return values
70
+
71
+
72
+ def main():
73
+ parser = argparse.ArgumentParser(
74
+ description="Read PDF metadata, or write a copy with new standard fields.",
75
+ epilog='Example: metadata.py --input in.pdf --set "title=Field report" --set "author=Ada" --out out.pdf',
76
+ )
77
+ parser.add_argument("--input", required=True, help="Source PDF; it is never modified")
78
+ parser.add_argument(
79
+ "--set",
80
+ action="append",
81
+ default=[],
82
+ metavar="KEY=VALUE",
83
+ help="Metadata field to write; repeatable (title, author, subject, keywords, creator, producer, creationDate, modDate)",
84
+ )
85
+ parser.add_argument("--out", help="Destination PDF when --set is used")
86
+ parser.add_argument("--json-out", help="Also write the JSON report to this path")
87
+ args = parser.parse_args()
88
+
89
+ source = Path(args.input).expanduser()
90
+ if not source.is_file():
91
+ fail(f"input not found: {source}", code=2)
92
+ if args.set and not args.out:
93
+ fail("--set needs --out; the input PDF is never overwritten")
94
+ if args.out and not args.set:
95
+ fail("--out is only meaningful together with --set")
96
+
97
+ try:
98
+ document = pymupdf.open(str(source))
99
+ except Exception as exc:
100
+ fail(f"cannot open PDF: {exc}", code=2)
101
+ if document.needs_pass:
102
+ document.close()
103
+ fail("PDF is encrypted; remove the password before reading or writing metadata", code=3)
104
+
105
+ before = clean(document.metadata)
106
+ info = document.metadata or {}
107
+ custom = {key: value for key, value in info.items() if key not in STANDARD and key not in {"format", "encryption"}}
108
+ page_sizes = [
109
+ {"page": number, "width": round(page.rect.width, 2), "height": round(page.rect.height, 2), "rotation": page.rotation}
110
+ for number, page in enumerate(document, start=1)
111
+ ]
112
+ result = {
113
+ "ok": True,
114
+ "input": str(source),
115
+ "page_count": document.page_count,
116
+ "pdf_version": info.get("format"),
117
+ "metadata": before,
118
+ "custom_metadata": custom,
119
+ "page_sizes": page_sizes,
120
+ }
121
+
122
+ if args.set:
123
+ updates = {}
124
+ for assignment in args.set:
125
+ if "=" not in assignment:
126
+ document.close()
127
+ fail(f"--set needs KEY=VALUE, got {assignment!r}")
128
+ key, value = assignment.split("=", 1)
129
+ updates[normalise(key)] = value
130
+ out = Path(args.out).expanduser()
131
+ if out.resolve() == source.resolve():
132
+ document.close()
133
+ fail("refusing to overwrite the source PDF; choose a different --out", code=3)
134
+ merged = dict(before)
135
+ merged.update(updates)
136
+ try:
137
+ document.set_metadata(merged)
138
+ out.parent.mkdir(parents=True, exist_ok=True)
139
+ document.save(str(out), garbage=3, deflate=True)
140
+ except Exception as exc:
141
+ document.close()
142
+ fail(f"cannot write {out}: {exc}", code=2)
143
+ document.close()
144
+
145
+ try:
146
+ with pymupdf.open(str(out)) as written:
147
+ after = clean(written.metadata)
148
+ except Exception as exc:
149
+ fail(f"cannot reopen {out} to verify metadata: {exc}", code=2)
150
+ result["output"] = str(out)
151
+ result["metadata_after"] = after
152
+ result["applied"] = {key: value for key, value in updates.items() if after.get(key) == value}
153
+ result["not_applied"] = [key for key in updates if after.get(key) != updates[key]]
154
+ else:
155
+ document.close()
156
+
157
+ emit(result, args.json_out)
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env python3
2
+ """Rearrange PDF pages: rotate, remove, reorder, and merge.
3
+
4
+ Loads the input document, optionally appends pages from other PDFs, then
5
+ applies the requested operations in a fixed order — merge, reorder, remove,
6
+ rotate — so a command means the same thing every time. Page content streams
7
+ are carried over as-is; nothing is re-flowed or re-drawn.
8
+
9
+ Page indices are zero-based. After the merge step they address the combined
10
+ page list, which is the list the other operations see.
11
+
12
+ Exit codes: 0 success, 1 bad arguments or nothing to do, 2 unreadable input or
13
+ write failure, 3 refused (encrypted document or source overwrite attempt).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ for _stream in (sys.stdout, sys.stderr):
24
+ try:
25
+ _stream.reconfigure(encoding="utf-8", errors="replace")
26
+ except (AttributeError, ValueError):
27
+ pass
28
+
29
+ from pypdf import PdfReader, PdfWriter # noqa: E402
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):
42
+ print(json.dumps({"ok": False, "error": message}, ensure_ascii=False, indent=2), file=sys.stderr)
43
+ raise SystemExit(code)
44
+
45
+
46
+ def parse_indices(spec, label):
47
+ values = []
48
+ for part in spec.split(","):
49
+ part = part.strip()
50
+ if part == "":
51
+ continue
52
+ try:
53
+ values.append(int(part))
54
+ except ValueError:
55
+ fail(f"{label} needs comma-separated integers, got {spec!r}")
56
+ return values
57
+
58
+
59
+ def check_indices(values, page_count, label):
60
+ bad = [value for value in values if not 0 <= value < page_count]
61
+ if bad:
62
+ fail(f"{label} references page(s) {bad} but the combined document has pages 0..{page_count - 1}")
63
+ return values
64
+
65
+
66
+ def load(path):
67
+ file = Path(path).expanduser()
68
+ if not file.is_file():
69
+ fail(f"input not found: {file}", code=2)
70
+ try:
71
+ reader = PdfReader(str(file))
72
+ except Exception as exc:
73
+ fail(f"cannot open {file}: {exc}", code=2)
74
+ if reader.is_encrypted:
75
+ fail(f"{file} is encrypted; remove the password before rearranging pages", code=3)
76
+ return file, reader
77
+
78
+
79
+ def main():
80
+ parser = argparse.ArgumentParser(
81
+ description="Rotate, remove, reorder, and merge PDF pages into a new file.",
82
+ epilog=(
83
+ 'Example: page_ops.py --input in.pdf --out out.pdf --merge cover.pdf --order 2,0,1 --remove 0 --rotate 90 --pages 0'
84
+ ),
85
+ )
86
+ parser.add_argument("--input", required=True, help="Source PDF; it is never modified")
87
+ parser.add_argument("--out", help="Destination PDF (default: <input>-edited.pdf)")
88
+ parser.add_argument("--merge", action="append", default=[], metavar="PDF[,PDF]",
89
+ help="Append pages from these PDFs; repeatable or comma-separated")
90
+ parser.add_argument("--order", metavar="N,N,N",
91
+ help="Keep and reorder pages by these zero-based indices; unlisted pages are dropped")
92
+ parser.add_argument("--remove", metavar="N,N", help="Drop these zero-based indices from the combined list")
93
+ parser.add_argument("--rotate", type=int, choices=(90, 180, 270),
94
+ help="Clockwise rotation to apply to --pages")
95
+ parser.add_argument("--pages", metavar="N,N", help="Pages the rotation applies to (default: all)")
96
+ parser.add_argument("--json-out", help="Also write the JSON report to this path")
97
+ args = parser.parse_args()
98
+
99
+ merge_paths = []
100
+ for chunk in args.merge:
101
+ merge_paths.extend(part.strip() for part in chunk.split(",") if part.strip())
102
+
103
+ rotating = bool(args.rotate) or bool(args.pages)
104
+ if rotating and not args.rotate:
105
+ fail("--pages needs --rotate")
106
+ if args.rotate and not args.pages:
107
+ args.pages = None
108
+ if not (merge_paths or args.order or args.remove is not None or args.rotate):
109
+ fail("nothing to do: pass --merge, --order, --remove, or --rotate/--pages")
110
+
111
+ source, reader = load(args.input)
112
+ out = Path(args.out).expanduser() if args.out else source.with_name(f"{source.stem}-edited.pdf")
113
+ if out.resolve() == source.resolve():
114
+ fail("refusing to overwrite the source PDF; choose a different --out", code=3)
115
+
116
+ pages = list(reader.pages)
117
+ sources = [str(source)]
118
+
119
+ for path in merge_paths:
120
+ _, extra = load(path)
121
+ pages.extend(extra.pages)
122
+ sources.append(str(Path(path).expanduser()))
123
+
124
+ combined = len(pages)
125
+
126
+ if args.order is not None:
127
+ order = parse_indices(args.order, "--order")
128
+ check_indices(order, combined, "--order")
129
+ if len(set(order)) != len(order):
130
+ fail("--order contains a duplicate page index")
131
+ pages = [pages[index] for index in order]
132
+
133
+ removed = []
134
+ if args.remove is not None:
135
+ remove = parse_indices(args.remove, "--remove")
136
+ check_indices(remove, len(pages), "--remove")
137
+ removed = sorted(set(remove))
138
+ pages = [page for index, page in enumerate(pages) if index not in set(removed)]
139
+
140
+ if not pages:
141
+ fail("the operations removed every page")
142
+
143
+ selected = None
144
+ if args.rotate:
145
+ selected = parse_indices(args.pages, "--pages") if args.pages is not None else list(range(len(pages)))
146
+ check_indices(selected, len(pages), "--pages")
147
+
148
+ writer = PdfWriter()
149
+ for page in pages:
150
+ writer.add_page(page)
151
+ if reader.metadata:
152
+ writer.add_metadata({key: str(value) for key, value in reader.metadata.items()})
153
+ if args.rotate:
154
+ for index in selected:
155
+ writer.pages[index].rotate(args.rotate)
156
+
157
+ try:
158
+ out.parent.mkdir(parents=True, exist_ok=True)
159
+ with open(out, "wb") as handle:
160
+ writer.write(handle)
161
+ except Exception as exc:
162
+ fail(f"cannot write {out}: {exc}", code=2)
163
+
164
+ try:
165
+ with open(out, "rb") as handle:
166
+ written = PdfReader(handle)
167
+ report = [
168
+ {
169
+ "page": number,
170
+ "rotation": page.rotation,
171
+ "width": round(float(page.mediabox.width), 2),
172
+ "height": round(float(page.mediabox.height), 2),
173
+ }
174
+ for number, page in enumerate(written.pages, start=1)
175
+ ]
176
+ page_count = len(written.pages)
177
+ except Exception as exc:
178
+ fail(f"cannot reopen {out}: {exc}", code=2)
179
+
180
+ emit(
181
+ {
182
+ "ok": True,
183
+ "input": str(source),
184
+ "sources": sources,
185
+ "output": str(out),
186
+ "pages_in": combined,
187
+ "pages_out": page_count,
188
+ "order": args.order,
189
+ "removed": removed,
190
+ "rotated": {"angle": args.rotate, "pages": selected} if args.rotate else None,
191
+ "pages": report,
192
+ },
193
+ args.json_out,
194
+ )
195
+
196
+
197
+ if __name__ == "__main__":
198
+ main()