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.
- 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 +92 -0
- package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
- package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
- package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
- package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
- package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
- package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
- package/assets/skills/office-documents/scripts/comments_add.py +136 -0
- package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
- package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
- package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
- package/assets/skills/office-documents/scripts/content_controls.py +192 -0
- package/assets/skills/office-documents/scripts/core_properties.py +124 -0
- package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
- package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
- package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
- package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
- package/assets/skills/office-documents/scripts/fields_report.py +59 -0
- package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
- package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
- package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
- package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
- package/assets/skills/office-documents/scripts/images_audit.py +80 -0
- package/assets/skills/office-documents/scripts/insert_note.py +126 -0
- package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
- package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
- package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
- package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
- package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
- package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
- package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
- package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
- package/assets/skills/office-documents/scripts/replace_text.py +154 -0
- package/assets/skills/office-documents/scripts/section_audit.py +91 -0
- package/assets/skills/office-documents/scripts/set_protection.py +92 -0
- package/assets/skills/office-documents/scripts/style_lint.py +111 -0
- package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
- package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
- package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
- package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
- package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -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 +136 -0
- package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
- package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
- package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
- package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
- package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
- package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
- package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
- package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
- package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
- package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -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 +176 -0
- package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
- package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
- package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
- package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
- package/assets/skills/office-spreadsheets/SKILL.md +105 -101
- package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
- package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
- package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
- package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
- package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
- package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
- package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
- package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
- package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -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 +253 -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 +239 -89
- package/package.json +1 -1
- package/postinstall.mjs +92 -82
- 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()
|